v1.1.0: Parser versioning, server info, auto-browser, section overviews
- Add parser versioning with Version() method and version display on main screen - Add server model and serial number to Configuration tab and TXT export - Add auto-browser opening on startup with --no-browser flag - Add Restart and Exit buttons with graceful shutdown - Add section overview stats (CPU, Power, Storage, GPU, Network) - Change PCIe Link display to "x16 PCIe Gen4" format - Add Location column to Serials section - Extract BoardInfo from FRU and PlatformId from ThermalConfig Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -5,6 +5,9 @@ import (
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"os/exec"
|
||||
"runtime"
|
||||
"time"
|
||||
|
||||
"git.mchus.pro/mchus/logpile/internal/parser"
|
||||
_ "git.mchus.pro/mchus/logpile/internal/parser/vendors" // Register all vendor parsers
|
||||
@@ -21,6 +24,7 @@ func main() {
|
||||
port := flag.Int("port", 8080, "HTTP server port")
|
||||
file := flag.String("file", "", "Pre-load archive file")
|
||||
showVersion := flag.Bool("version", false, "Show version")
|
||||
noBrowser := flag.Bool("no-browser", false, "Don't open browser automatically")
|
||||
flag.Parse()
|
||||
|
||||
if *showVersion {
|
||||
@@ -38,9 +42,37 @@ func main() {
|
||||
|
||||
srv := server.New(cfg)
|
||||
|
||||
log.Printf("LOGPile starting on http://localhost:%d", *port)
|
||||
url := fmt.Sprintf("http://localhost:%d", *port)
|
||||
log.Printf("LOGPile starting on %s", url)
|
||||
log.Printf("Registered parsers: %v", parser.ListParsers())
|
||||
|
||||
// Open browser automatically
|
||||
if !*noBrowser {
|
||||
go func() {
|
||||
time.Sleep(500 * time.Millisecond) // Wait for server to start
|
||||
openBrowser(url)
|
||||
}()
|
||||
}
|
||||
|
||||
if err := srv.Run(); err != nil {
|
||||
log.Fatalf("Server error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// openBrowser opens the default browser with the given URL
|
||||
func openBrowser(url string) {
|
||||
var cmd *exec.Cmd
|
||||
|
||||
switch runtime.GOOS {
|
||||
case "darwin":
|
||||
cmd = exec.Command("open", url)
|
||||
case "windows":
|
||||
cmd = exec.Command("rundll32", "url.dll,FileProtocolHandler", url)
|
||||
default: // linux and others
|
||||
cmd = exec.Command("xdg-open", url)
|
||||
}
|
||||
|
||||
if err := cmd.Start(); err != nil {
|
||||
log.Printf("Failed to open browser: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,7 +25,7 @@ func (e *Exporter) ExportCSV(w io.Writer) error {
|
||||
defer writer.Flush()
|
||||
|
||||
// Header
|
||||
if err := writer.Write([]string{"Component", "Serial Number", "Manufacturer", "Part Number"}); err != nil {
|
||||
if err := writer.Write([]string{"Component", "Serial Number", "Manufacturer", "Location"}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -59,11 +59,15 @@ func (e *Exporter) ExportCSV(w io.Writer) error {
|
||||
if mem.SerialNumber == "" {
|
||||
continue
|
||||
}
|
||||
location := mem.Location
|
||||
if location == "" {
|
||||
location = mem.Slot
|
||||
}
|
||||
if err := writer.Write([]string{
|
||||
fmt.Sprintf("DIMM Slot %d (%s)", mem.Slot, mem.PartNumber),
|
||||
mem.PartNumber,
|
||||
mem.SerialNumber,
|
||||
mem.Manufacturer,
|
||||
mem.PartNumber,
|
||||
location,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -75,10 +79,10 @@ func (e *Exporter) ExportCSV(w io.Writer) error {
|
||||
continue
|
||||
}
|
||||
if err := writer.Write([]string{
|
||||
fmt.Sprintf("%s %s", stor.Type, stor.Model),
|
||||
stor.Model,
|
||||
stor.SerialNumber,
|
||||
"",
|
||||
"",
|
||||
stor.Manufacturer,
|
||||
stor.Slot,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -90,10 +94,10 @@ func (e *Exporter) ExportCSV(w io.Writer) error {
|
||||
continue
|
||||
}
|
||||
if err := writer.Write([]string{
|
||||
fmt.Sprintf("%s (%s)", pcie.DeviceClass, pcie.Slot),
|
||||
pcie.DeviceClass,
|
||||
pcie.SerialNumber,
|
||||
"",
|
||||
pcie.PartNumber,
|
||||
pcie.Manufacturer,
|
||||
pcie.Slot,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -121,7 +125,15 @@ func (e *Exporter) ExportTXT(w io.Writer) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
fmt.Fprintf(w, "File: %s\n\n", e.result.Filename)
|
||||
fmt.Fprintf(w, "File: %s\n", e.result.Filename)
|
||||
|
||||
// Server model and serial number
|
||||
if e.result.Hardware != nil && e.result.Hardware.BoardInfo.ProductName != "" {
|
||||
fmt.Fprintln(w)
|
||||
fmt.Fprintf(w, "Server Model: %s\n", e.result.Hardware.BoardInfo.ProductName)
|
||||
fmt.Fprintf(w, "Serial Number: %s\n", e.result.Hardware.BoardInfo.SerialNumber)
|
||||
}
|
||||
fmt.Fprintln(w)
|
||||
|
||||
// Hardware summary
|
||||
if e.result.Hardware != nil {
|
||||
|
||||
@@ -12,6 +12,10 @@ type VendorParser interface {
|
||||
// Vendor returns vendor identifier (e.g., "inspur", "supermicro", "dell")
|
||||
Vendor() string
|
||||
|
||||
// Version returns parser version string
|
||||
// IMPORTANT: Increment version when modifying parser logic!
|
||||
Version() string
|
||||
|
||||
// Detect checks if this parser can handle the given files
|
||||
// Returns confidence score 0-100 (0 = cannot parse, 100 = definitely this format)
|
||||
Detect(files []ExtractedFile) int
|
||||
|
||||
@@ -46,6 +46,35 @@ func ListParsers() []string {
|
||||
return vendors
|
||||
}
|
||||
|
||||
// ParserInfo contains information about a registered parser
|
||||
type ParserInfo struct {
|
||||
Vendor string `json:"vendor"`
|
||||
Name string `json:"name"`
|
||||
Version string `json:"version"`
|
||||
}
|
||||
|
||||
// ListParsersInfo returns detailed info about all registered parsers
|
||||
func ListParsersInfo() []ParserInfo {
|
||||
registryLock.RLock()
|
||||
defer registryLock.RUnlock()
|
||||
|
||||
parsers := make([]ParserInfo, 0, len(registry))
|
||||
for _, p := range registry {
|
||||
parsers = append(parsers, ParserInfo{
|
||||
Vendor: p.Vendor(),
|
||||
Name: p.Name(),
|
||||
Version: p.Version(),
|
||||
})
|
||||
}
|
||||
|
||||
// Sort by vendor name
|
||||
sort.Slice(parsers, func(i, j int) bool {
|
||||
return parsers[i].Vendor < parsers[j].Vendor
|
||||
})
|
||||
|
||||
return parsers
|
||||
}
|
||||
|
||||
// DetectResult holds detection result for a parser
|
||||
type DetectResult struct {
|
||||
Parser VendorParser
|
||||
|
||||
68
internal/parser/vendors/inspur/fru.go
vendored
68
internal/parser/vendors/inspur/fru.go
vendored
@@ -9,8 +9,9 @@ import (
|
||||
)
|
||||
|
||||
var (
|
||||
fruDeviceRegex = regexp.MustCompile(`^FRU Device Description\s*:\s*(.+)$`)
|
||||
fruFieldRegex = regexp.MustCompile(`^\s+(.+?)\s*:\s*(.*)$`)
|
||||
fruDeviceRegex = regexp.MustCompile(`^FRU Device Description\s*:\s*(.+)$`)
|
||||
fruFieldRegex = regexp.MustCompile(`^\s+(.+?)\s*:\s*(.*)$`)
|
||||
platformIdRegex = regexp.MustCompile(`(?i)PlatformId\s*=\s*(\S+)`)
|
||||
)
|
||||
|
||||
// ParseFRU parses BMC FRU (Field Replaceable Unit) output
|
||||
@@ -95,3 +96,66 @@ func ParseFRU(content []byte) []models.FRUInfo {
|
||||
|
||||
return fruList
|
||||
}
|
||||
|
||||
// extractBoardInfo extracts main board/chassis information from FRU data
|
||||
func extractBoardInfo(fruList []models.FRUInfo, hw *models.HardwareConfig) {
|
||||
if hw == nil || len(fruList) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
// Look for the main board/chassis FRU entry
|
||||
// Usually it's the first entry or one with "Builtin FRU" or containing board info
|
||||
for _, fru := range fruList {
|
||||
// Skip empty entries
|
||||
if fru.ProductName == "" && fru.SerialNumber == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
// Prioritize entries that look like main board info
|
||||
desc := strings.ToLower(fru.Description)
|
||||
isMainBoard := strings.Contains(desc, "builtin") ||
|
||||
strings.Contains(desc, "fru device") ||
|
||||
strings.Contains(desc, "chassis") ||
|
||||
strings.Contains(desc, "board")
|
||||
|
||||
// If we haven't set board info yet, or this is a main board entry
|
||||
if hw.BoardInfo.ProductName == "" || isMainBoard {
|
||||
if fru.ProductName != "" {
|
||||
hw.BoardInfo.ProductName = fru.ProductName
|
||||
}
|
||||
if fru.SerialNumber != "" {
|
||||
hw.BoardInfo.SerialNumber = fru.SerialNumber
|
||||
}
|
||||
if fru.Manufacturer != "" {
|
||||
hw.BoardInfo.Manufacturer = fru.Manufacturer
|
||||
}
|
||||
if fru.PartNumber != "" {
|
||||
hw.BoardInfo.PartNumber = fru.PartNumber
|
||||
}
|
||||
|
||||
// If we found a main board entry, stop searching
|
||||
if isMainBoard && fru.ProductName != "" && fru.SerialNumber != "" {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// extractPlatformId extracts server model from ThermalConfig (PlatformId)
|
||||
func extractPlatformId(content []byte, hw *models.HardwareConfig) {
|
||||
if hw == nil {
|
||||
return
|
||||
}
|
||||
|
||||
if match := platformIdRegex.FindSubmatch(content); match != nil {
|
||||
platformId := strings.TrimSpace(string(match[1]))
|
||||
if platformId != "" {
|
||||
// Set as ProductName (server model) - this takes priority over FRU data
|
||||
hw.BoardInfo.ProductName = platformId
|
||||
// Also set manufacturer as Inspur if not already set
|
||||
if hw.BoardInfo.Manufacturer == "" {
|
||||
hw.BoardInfo.Manufacturer = "Inspur"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
29
internal/parser/vendors/inspur/parser.go
vendored
29
internal/parser/vendors/inspur/parser.go
vendored
@@ -1,5 +1,8 @@
|
||||
// Package inspur provides parser for Inspur/Kaytus BMC diagnostic archives
|
||||
// Tested with: Kaytus KR4268X2 (onekeylog format)
|
||||
// Tested with: Inspur NF5468M7 / Kaytus KR4268X2 (onekeylog format)
|
||||
//
|
||||
// IMPORTANT: Increment parserVersion when modifying parser logic!
|
||||
// This helps track which version was used to parse specific logs.
|
||||
package inspur
|
||||
|
||||
import (
|
||||
@@ -9,6 +12,10 @@ import (
|
||||
"git.mchus.pro/mchus/logpile/internal/parser"
|
||||
)
|
||||
|
||||
// parserVersion - version of this parser module
|
||||
// IMPORTANT: Increment this version when making changes to parser logic!
|
||||
const parserVersion = "1.0.0"
|
||||
|
||||
func init() {
|
||||
parser.Register(&Parser{})
|
||||
}
|
||||
@@ -26,6 +33,12 @@ func (p *Parser) Vendor() string {
|
||||
return "inspur"
|
||||
}
|
||||
|
||||
// Version returns parser version
|
||||
// IMPORTANT: Update parserVersion constant when modifying parser logic!
|
||||
func (p *Parser) Version() string {
|
||||
return parserVersion
|
||||
}
|
||||
|
||||
// Detect checks if archive matches Inspur/Kaytus format
|
||||
// Returns confidence 0-100
|
||||
func (p *Parser) Detect(files []parser.ExtractedFile) int {
|
||||
@@ -90,11 +103,19 @@ func (p *Parser) Parse(files []parser.ExtractedFile) (*models.AnalysisResult, er
|
||||
}
|
||||
}
|
||||
|
||||
// Extract BoardInfo from FRU data
|
||||
if result.Hardware == nil {
|
||||
result.Hardware = &models.HardwareConfig{}
|
||||
}
|
||||
extractBoardInfo(result.FRU, result.Hardware)
|
||||
|
||||
// Extract PlatformId (server model) from ThermalConfig
|
||||
if f := parser.FindFileByName(files, "ThermalConfig_Cur.conf"); f != nil {
|
||||
extractPlatformId(f.Content, result.Hardware)
|
||||
}
|
||||
|
||||
// Parse component.log for additional data (PSU, etc.)
|
||||
if f := parser.FindFileByName(files, "component.log"); f != nil {
|
||||
if result.Hardware == nil {
|
||||
result.Hardware = &models.HardwareConfig{}
|
||||
}
|
||||
ParseComponentLog(f.Content, result.Hardware)
|
||||
|
||||
// Extract events from component.log (memory errors, etc.)
|
||||
|
||||
@@ -5,7 +5,9 @@ import (
|
||||
"fmt"
|
||||
"html/template"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.mchus.pro/mchus/logpile/internal/exporter"
|
||||
"git.mchus.pro/mchus/logpile/internal/models"
|
||||
@@ -74,7 +76,7 @@ func (s *Server) handleUpload(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
func (s *Server) handleGetParsers(w http.ResponseWriter, r *http.Request) {
|
||||
jsonResponse(w, map[string]interface{}{
|
||||
"parsers": parser.ListParsers(),
|
||||
"parsers": parser.ListParsersInfo(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -232,6 +234,7 @@ func (s *Server) handleGetSerials(w http.ResponseWriter, r *http.Request) {
|
||||
// Collect all serial numbers from various sources
|
||||
type SerialEntry struct {
|
||||
Component string `json:"component"`
|
||||
Location string `json:"location,omitempty"`
|
||||
SerialNumber string `json:"serial_number"`
|
||||
Manufacturer string `json:"manufacturer,omitempty"`
|
||||
PartNumber string `json:"part_number,omitempty"`
|
||||
@@ -282,6 +285,7 @@ func (s *Server) handleGetSerials(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
serials = append(serials, SerialEntry{
|
||||
Component: cpu.Model,
|
||||
Location: fmt.Sprintf("CPU%d", cpu.Socket),
|
||||
SerialNumber: sn,
|
||||
Category: "CPU",
|
||||
})
|
||||
@@ -292,8 +296,13 @@ func (s *Server) handleGetSerials(w http.ResponseWriter, r *http.Request) {
|
||||
if mem.SerialNumber == "" {
|
||||
continue
|
||||
}
|
||||
location := mem.Location
|
||||
if location == "" {
|
||||
location = mem.Slot
|
||||
}
|
||||
serials = append(serials, SerialEntry{
|
||||
Component: mem.PartNumber,
|
||||
Location: location,
|
||||
SerialNumber: mem.SerialNumber,
|
||||
Manufacturer: mem.Manufacturer,
|
||||
PartNumber: mem.PartNumber,
|
||||
@@ -308,9 +317,9 @@ func (s *Server) handleGetSerials(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
serials = append(serials, SerialEntry{
|
||||
Component: stor.Model,
|
||||
Location: stor.Slot,
|
||||
SerialNumber: stor.SerialNumber,
|
||||
Manufacturer: stor.Manufacturer,
|
||||
PartNumber: stor.Slot,
|
||||
Category: "Storage",
|
||||
})
|
||||
}
|
||||
@@ -321,7 +330,8 @@ func (s *Server) handleGetSerials(w http.ResponseWriter, r *http.Request) {
|
||||
continue
|
||||
}
|
||||
serials = append(serials, SerialEntry{
|
||||
Component: pcie.DeviceClass + " (" + pcie.Slot + ")",
|
||||
Component: pcie.DeviceClass,
|
||||
Location: pcie.Slot,
|
||||
SerialNumber: pcie.SerialNumber,
|
||||
Manufacturer: pcie.Manufacturer,
|
||||
PartNumber: pcie.PartNumber,
|
||||
@@ -348,8 +358,9 @@ func (s *Server) handleGetSerials(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
serials = append(serials, SerialEntry{
|
||||
Component: psu.Model,
|
||||
Location: psu.Slot,
|
||||
SerialNumber: psu.SerialNumber,
|
||||
PartNumber: psu.Slot,
|
||||
Manufacturer: psu.Vendor,
|
||||
Category: "PSU",
|
||||
})
|
||||
}
|
||||
@@ -510,6 +521,20 @@ func (s *Server) handleClear(w http.ResponseWriter, r *http.Request) {
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleShutdown(w http.ResponseWriter, r *http.Request) {
|
||||
jsonResponse(w, map[string]string{
|
||||
"status": "ok",
|
||||
"message": "Server shutting down",
|
||||
})
|
||||
|
||||
// Shutdown in a goroutine so the response can be sent
|
||||
go func() {
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
s.Shutdown()
|
||||
os.Exit(0)
|
||||
}()
|
||||
}
|
||||
|
||||
func jsonResponse(w http.ResponseWriter, data interface{}) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(data)
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"embed"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"git.mchus.pro/mchus/logpile/internal/models"
|
||||
)
|
||||
@@ -19,8 +21,9 @@ type Config struct {
|
||||
}
|
||||
|
||||
type Server struct {
|
||||
config Config
|
||||
mux *http.ServeMux
|
||||
config Config
|
||||
mux *http.ServeMux
|
||||
httpServer *http.Server
|
||||
|
||||
mu sync.RWMutex
|
||||
result *models.AnalysisResult
|
||||
@@ -60,11 +63,25 @@ func (s *Server) setupRoutes() {
|
||||
s.mux.HandleFunc("GET /api/export/json", s.handleExportJSON)
|
||||
s.mux.HandleFunc("GET /api/export/txt", s.handleExportTXT)
|
||||
s.mux.HandleFunc("DELETE /api/clear", s.handleClear)
|
||||
s.mux.HandleFunc("POST /api/shutdown", s.handleShutdown)
|
||||
}
|
||||
|
||||
func (s *Server) Run() error {
|
||||
addr := fmt.Sprintf(":%d", s.config.Port)
|
||||
return http.ListenAndServe(addr, s.mux)
|
||||
s.httpServer = &http.Server{
|
||||
Addr: addr,
|
||||
Handler: s.mux,
|
||||
}
|
||||
return s.httpServer.ListenAndServe()
|
||||
}
|
||||
|
||||
// Shutdown gracefully shuts down the server
|
||||
func (s *Server) Shutdown() {
|
||||
if s.httpServer != nil {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
defer cancel()
|
||||
s.httpServer.Shutdown(ctx)
|
||||
}
|
||||
}
|
||||
|
||||
// SetResult sets the analysis result (thread-safe)
|
||||
|
||||
@@ -88,6 +88,48 @@ main {
|
||||
color: #27ae60;
|
||||
}
|
||||
|
||||
/* Parsers info */
|
||||
.parsers-info {
|
||||
margin-top: 1.5rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.parsers-title {
|
||||
font-size: 0.85rem;
|
||||
color: #666;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.parsers-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.parser-item {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
background: #f8f9fa;
|
||||
padding: 0.4rem 0.8rem;
|
||||
border-radius: 4px;
|
||||
border: 1px solid #e0e0e0;
|
||||
}
|
||||
|
||||
.parser-name {
|
||||
font-size: 0.85rem;
|
||||
color: #2c3e50;
|
||||
}
|
||||
|
||||
.parser-version {
|
||||
font-size: 0.75rem;
|
||||
color: #888;
|
||||
background: #e8e8e8;
|
||||
padding: 0.1rem 0.4rem;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
/* Tabs */
|
||||
.tabs {
|
||||
display: flex;
|
||||
@@ -375,6 +417,13 @@ footer {
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.footer-buttons {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
justify-content: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.footer-info {
|
||||
margin-top: 1rem;
|
||||
font-size: 0.8rem;
|
||||
@@ -403,6 +452,32 @@ footer {
|
||||
background: #c0392b;
|
||||
}
|
||||
|
||||
#restart-btn {
|
||||
background: #3498db;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
#restart-btn:hover {
|
||||
background: #2980b9;
|
||||
}
|
||||
|
||||
#exit-btn {
|
||||
background: #95a5a6;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
#exit-btn:hover {
|
||||
background: #7f8c8d;
|
||||
}
|
||||
|
||||
/* Utility */
|
||||
.hidden {
|
||||
display: none !important;
|
||||
@@ -414,6 +489,40 @@ footer {
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
/* Server info header */
|
||||
.server-info {
|
||||
background: #2c3e50;
|
||||
color: white;
|
||||
padding: 1rem 1.5rem;
|
||||
border-radius: 8px 8px 0 0;
|
||||
margin-bottom: 0;
|
||||
display: flex;
|
||||
gap: 2rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.server-info-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.server-info-label {
|
||||
opacity: 0.8;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.server-info strong {
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.server-info code {
|
||||
background: rgba(255,255,255,0.15);
|
||||
padding: 0.2rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
/* Configuration tabs */
|
||||
.config-tabs {
|
||||
display: flex;
|
||||
@@ -510,30 +619,46 @@ footer {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
/* Memory overview stats */
|
||||
.memory-overview {
|
||||
/* Section overview stats */
|
||||
.memory-overview,
|
||||
.section-overview {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.stat-box {
|
||||
background: #f8f9fa;
|
||||
padding: 1rem 1.5rem;
|
||||
padding: 0.75rem 1.25rem;
|
||||
border-radius: 8px;
|
||||
text-align: center;
|
||||
border-left: 4px solid #3498db;
|
||||
min-width: 80px;
|
||||
}
|
||||
|
||||
.stat-box.model-box {
|
||||
flex-grow: 1;
|
||||
text-align: left;
|
||||
border-left-color: #27ae60;
|
||||
}
|
||||
|
||||
.stat-box.model-box .stat-value {
|
||||
font-size: 1rem;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
display: block;
|
||||
font-size: 1.5rem;
|
||||
font-size: 1.25rem;
|
||||
font-weight: bold;
|
||||
color: #2c3e50;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 0.75rem;
|
||||
font-size: 0.7rem;
|
||||
color: #666;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
@@ -4,8 +4,32 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
initUpload();
|
||||
initTabs();
|
||||
initFilters();
|
||||
loadParsersInfo();
|
||||
});
|
||||
|
||||
// Load and display available parsers
|
||||
async function loadParsersInfo() {
|
||||
try {
|
||||
const response = await fetch('/api/parsers');
|
||||
const data = await response.json();
|
||||
const container = document.getElementById('parsers-info');
|
||||
|
||||
if (data.parsers && data.parsers.length > 0) {
|
||||
let html = '<p class="parsers-title">Поддерживаемые платформы:</p><div class="parsers-list">';
|
||||
data.parsers.forEach(p => {
|
||||
html += `<div class="parser-item">
|
||||
<span class="parser-name">${escapeHtml(p.name)}</span>
|
||||
<span class="parser-version">v${escapeHtml(p.version)}</span>
|
||||
</div>`;
|
||||
});
|
||||
html += '</div>';
|
||||
container.innerHTML = html;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to load parsers info:', err);
|
||||
}
|
||||
}
|
||||
|
||||
// Upload handling
|
||||
function initUpload() {
|
||||
const dropZone = document.getElementById('drop-zone');
|
||||
@@ -145,6 +169,14 @@ function renderConfig(data) {
|
||||
|
||||
let html = '';
|
||||
|
||||
// Server info header
|
||||
if (config.board) {
|
||||
html += `<div class="server-info">
|
||||
<div class="server-info-item"><span class="server-info-label">Модель сервера:</span> <strong>${escapeHtml(config.board.product_name || '-')}</strong></div>
|
||||
<div class="server-info-item"><span class="server-info-label">Серийный номер:</span> <code>${escapeHtml(config.board.serial_number || '-')}</code></div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// Configuration sub-tabs
|
||||
html += `<div class="config-tabs">
|
||||
<button class="config-tab active" data-config-tab="spec">Спецификация</button>
|
||||
@@ -155,7 +187,6 @@ function renderConfig(data) {
|
||||
<button class="config-tab" data-config-tab="gpu">GPU</button>
|
||||
<button class="config-tab" data-config-tab="network">Network</button>
|
||||
<button class="config-tab" data-config-tab="pcie">Device Inventory</button>
|
||||
<button class="config-tab" data-config-tab="fw">Firmware</button>
|
||||
</div>`;
|
||||
|
||||
// Specification tab
|
||||
@@ -174,7 +205,18 @@ function renderConfig(data) {
|
||||
// CPU tab
|
||||
html += '<div class="config-tab-content" id="config-cpu">';
|
||||
if (config.cpus && config.cpus.length > 0) {
|
||||
html += '<h3>Процессоры</h3><table class="config-table"><thead><tr><th>Socket</th><th>Модель</th><th>Ядра</th><th>Потоки</th><th>Частота</th><th>Max Turbo</th><th>TDP</th><th>L3 Cache</th><th>PPIN</th></tr></thead><tbody>';
|
||||
const cpuCount = config.cpus.length;
|
||||
const cpuModel = config.cpus[0].model || '-';
|
||||
const totalCores = config.cpus.reduce((sum, c) => sum + (c.cores || 0), 0);
|
||||
const totalThreads = config.cpus.reduce((sum, c) => sum + (c.threads || 0), 0);
|
||||
html += `<h3>Процессоры</h3>
|
||||
<div class="section-overview">
|
||||
<div class="stat-box"><span class="stat-value">${cpuCount}</span><span class="stat-label">Процессоров</span></div>
|
||||
<div class="stat-box"><span class="stat-value">${totalCores}</span><span class="stat-label">Ядер</span></div>
|
||||
<div class="stat-box"><span class="stat-value">${totalThreads}</span><span class="stat-label">Потоков</span></div>
|
||||
<div class="stat-box model-box"><span class="stat-value">${escapeHtml(cpuModel)}</span><span class="stat-label">Модель</span></div>
|
||||
</div>
|
||||
<table class="config-table"><thead><tr><th>Socket</th><th>Модель</th><th>Ядра</th><th>Потоки</th><th>Частота</th><th>Max Turbo</th><th>TDP</th><th>L3 Cache</th><th>PPIN</th></tr></thead><tbody>`;
|
||||
config.cpus.forEach(cpu => {
|
||||
html += `<tr>
|
||||
<td>CPU${cpu.socket}</td>
|
||||
@@ -236,7 +278,20 @@ function renderConfig(data) {
|
||||
// Power tab
|
||||
html += '<div class="config-tab-content" id="config-power">';
|
||||
if (config.power_supplies && config.power_supplies.length > 0) {
|
||||
html += '<h3>Блоки питания</h3><table class="config-table"><thead><tr><th>Слот</th><th>Производитель</th><th>Модель</th><th>Мощность</th><th>Вход</th><th>Выход</th><th>Напряжение</th><th>Температура</th><th>Статус</th></tr></thead><tbody>';
|
||||
const psuTotal = config.power_supplies.length;
|
||||
const psuPresent = config.power_supplies.filter(p => p.present !== false).length;
|
||||
const psuOK = config.power_supplies.filter(p => p.status === 'OK').length;
|
||||
const psuModel = config.power_supplies[0].model || '-';
|
||||
const psuWattage = config.power_supplies[0].wattage_w || 0;
|
||||
html += `<h3>Блоки питания</h3>
|
||||
<div class="section-overview">
|
||||
<div class="stat-box"><span class="stat-value">${psuTotal}</span><span class="stat-label">Всего</span></div>
|
||||
<div class="stat-box"><span class="stat-value">${psuPresent}</span><span class="stat-label">Подключено</span></div>
|
||||
<div class="stat-box"><span class="stat-value">${psuOK}</span><span class="stat-label">Работает</span></div>
|
||||
<div class="stat-box"><span class="stat-value">${psuWattage}W</span><span class="stat-label">Мощность</span></div>
|
||||
<div class="stat-box model-box"><span class="stat-value">${escapeHtml(psuModel)}</span><span class="stat-label">Модель</span></div>
|
||||
</div>
|
||||
<table class="config-table"><thead><tr><th>Слот</th><th>Производитель</th><th>Модель</th><th>Мощность</th><th>Вход</th><th>Выход</th><th>Напряжение</th><th>Температура</th><th>Статус</th></tr></thead><tbody>`;
|
||||
config.power_supplies.forEach(psu => {
|
||||
const statusClass = psu.status === 'OK' ? '' : 'status-warning';
|
||||
html += `<tr>
|
||||
@@ -260,7 +315,22 @@ function renderConfig(data) {
|
||||
// Storage tab
|
||||
html += '<div class="config-tab-content" id="config-storage">';
|
||||
if (config.storage && config.storage.length > 0) {
|
||||
html += '<h3>Накопители</h3><table class="config-table"><thead><tr><th>Слот</th><th>Тип</th><th>Интерфейс</th><th>Модель</th><th>Производитель</th><th>Размер</th><th>Серийный номер</th></tr></thead><tbody>';
|
||||
const storTotal = config.storage.length;
|
||||
const storHDD = config.storage.filter(s => s.type === 'HDD').length;
|
||||
const storSSD = config.storage.filter(s => s.type === 'SSD').length;
|
||||
const storNVMe = config.storage.filter(s => s.type === 'NVMe').length;
|
||||
const totalTB = (config.storage.reduce((sum, s) => sum + (s.size_gb || 0), 0) / 1000).toFixed(1);
|
||||
let typesSummary = [];
|
||||
if (storHDD > 0) typesSummary.push(`${storHDD} HDD`);
|
||||
if (storSSD > 0) typesSummary.push(`${storSSD} SSD`);
|
||||
if (storNVMe > 0) typesSummary.push(`${storNVMe} NVMe`);
|
||||
html += `<h3>Накопители</h3>
|
||||
<div class="section-overview">
|
||||
<div class="stat-box"><span class="stat-value">${storTotal}</span><span class="stat-label">Всего</span></div>
|
||||
<div class="stat-box"><span class="stat-value">${totalTB} TB</span><span class="stat-label">Объём</span></div>
|
||||
<div class="stat-box model-box"><span class="stat-value">${typesSummary.join(', ') || '-'}</span><span class="stat-label">По типам</span></div>
|
||||
</div>
|
||||
<table class="config-table"><thead><tr><th>Слот</th><th>Тип</th><th>Интерфейс</th><th>Модель</th><th>Производитель</th><th>Размер</th><th>Серийный номер</th></tr></thead><tbody>`;
|
||||
config.storage.forEach(s => {
|
||||
html += `<tr>
|
||||
<td>${escapeHtml(s.slot || '-')}</td>
|
||||
@@ -281,7 +351,16 @@ function renderConfig(data) {
|
||||
// GPU tab
|
||||
html += '<div class="config-tab-content" id="config-gpu">';
|
||||
if (config.gpus && config.gpus.length > 0) {
|
||||
html += '<h3>Графические процессоры</h3><table class="config-table"><thead><tr><th>Слот</th><th>Модель</th><th>Производитель</th><th>BDF</th><th>PCIe</th><th>Серийный номер</th></tr></thead><tbody>';
|
||||
const gpuCount = config.gpus.length;
|
||||
const gpuModel = config.gpus[0].model || '-';
|
||||
const gpuVendor = config.gpus[0].manufacturer || '-';
|
||||
html += `<h3>Графические процессоры</h3>
|
||||
<div class="section-overview">
|
||||
<div class="stat-box"><span class="stat-value">${gpuCount}</span><span class="stat-label">Всего GPU</span></div>
|
||||
<div class="stat-box"><span class="stat-value">${escapeHtml(gpuVendor)}</span><span class="stat-label">Производитель</span></div>
|
||||
<div class="stat-box model-box"><span class="stat-value">${escapeHtml(gpuModel)}</span><span class="stat-label">Модель</span></div>
|
||||
</div>
|
||||
<table class="config-table"><thead><tr><th>Слот</th><th>Модель</th><th>Производитель</th><th>BDF</th><th>PCIe</th><th>Серийный номер</th></tr></thead><tbody>`;
|
||||
config.gpus.forEach(gpu => {
|
||||
html += `<tr>
|
||||
<td>${escapeHtml(gpu.slot || '-')}</td>
|
||||
@@ -301,7 +380,18 @@ function renderConfig(data) {
|
||||
// Network tab
|
||||
html += '<div class="config-tab-content" id="config-network">';
|
||||
if (config.network_adapters && config.network_adapters.length > 0) {
|
||||
html += '<h3>Сетевые адаптеры</h3><table class="config-table"><thead><tr><th>Слот</th><th>Модель</th><th>Производитель</th><th>Порты</th><th>Тип</th><th>MAC адреса</th><th>Статус</th></tr></thead><tbody>';
|
||||
const nicCount = config.network_adapters.length;
|
||||
const totalPorts = config.network_adapters.reduce((sum, n) => sum + (n.port_count || 0), 0);
|
||||
const nicTypes = [...new Set(config.network_adapters.map(n => n.port_type).filter(t => t))];
|
||||
const nicModels = [...new Set(config.network_adapters.map(n => n.model).filter(m => m))];
|
||||
html += `<h3>Сетевые адаптеры</h3>
|
||||
<div class="section-overview">
|
||||
<div class="stat-box"><span class="stat-value">${nicCount}</span><span class="stat-label">Адаптеров</span></div>
|
||||
<div class="stat-box"><span class="stat-value">${totalPorts}</span><span class="stat-label">Портов</span></div>
|
||||
<div class="stat-box"><span class="stat-value">${nicTypes.join(', ') || '-'}</span><span class="stat-label">Тип портов</span></div>
|
||||
<div class="stat-box model-box"><span class="stat-value">${escapeHtml(nicModels.join(', ') || '-')}</span><span class="stat-label">Модели</span></div>
|
||||
</div>
|
||||
<table class="config-table"><thead><tr><th>Слот</th><th>Модель</th><th>Производитель</th><th>Порты</th><th>Тип</th><th>MAC адреса</th><th>Статус</th></tr></thead><tbody>`;
|
||||
config.network_adapters.forEach(nic => {
|
||||
const macs = nic.mac_addresses ? nic.mac_addresses.join(', ') : '-';
|
||||
const statusClass = nic.status === 'OK' ? '' : 'status-warning';
|
||||
@@ -326,13 +416,14 @@ function renderConfig(data) {
|
||||
if (config.pcie_devices && config.pcie_devices.length > 0) {
|
||||
html += '<h3>PCIe устройства</h3><table class="config-table"><thead><tr><th>Слот</th><th>BDF</th><th>Тип</th><th>Производитель</th><th>Vendor:Device ID</th><th>PCIe Link</th></tr></thead><tbody>';
|
||||
config.pcie_devices.forEach(p => {
|
||||
const pcieLink = formatPCIeLink(p.link_width, p.link_speed);
|
||||
html += `<tr>
|
||||
<td>${escapeHtml(p.slot || '-')}</td>
|
||||
<td><code>${escapeHtml(p.bdf || '-')}</code></td>
|
||||
<td>${escapeHtml(p.device_class || '-')}</td>
|
||||
<td>${escapeHtml(p.manufacturer || '-')}</td>
|
||||
<td><code>${p.vendor_id ? p.vendor_id.toString(16) : '-'}:${p.device_id ? p.device_id.toString(16) : '-'}</code></td>
|
||||
<td>x${p.link_width || '-'} ${escapeHtml(p.link_speed || '-')}</td>
|
||||
<td>${pcieLink}</td>
|
||||
</tr>`;
|
||||
});
|
||||
html += '</tbody></table>';
|
||||
@@ -341,9 +432,6 @@ function renderConfig(data) {
|
||||
}
|
||||
html += '</div>';
|
||||
|
||||
// Firmware tab (content will be populated after firmware loads)
|
||||
html += '<div class="config-tab-content" id="config-fw"><div id="config-fw-content"><p class="no-data">Загрузка...</p></div></div>';
|
||||
|
||||
container.innerHTML = html;
|
||||
|
||||
// Initialize config sub-tabs
|
||||
@@ -394,25 +482,6 @@ function renderFirmware(firmware) {
|
||||
tbody.appendChild(row);
|
||||
});
|
||||
}
|
||||
|
||||
// Render in Config -> Firmware tab
|
||||
const configFwContent = document.getElementById('config-fw-content');
|
||||
if (configFwContent) {
|
||||
if (!firmware || firmware.length === 0) {
|
||||
configFwContent.innerHTML = '<p class="no-data">Нет данных о прошивках</p>';
|
||||
} else {
|
||||
let html = '<h3>Прошивки компонентов</h3><table class="config-table"><thead><tr><th>Компонент</th><th>Модель</th><th>Версия</th></tr></thead><tbody>';
|
||||
firmware.forEach(fw => {
|
||||
html += `<tr>
|
||||
<td>${escapeHtml(fw.component)}</td>
|
||||
<td>${escapeHtml(fw.model)}</td>
|
||||
<td><code>${escapeHtml(fw.version)}</code></td>
|
||||
</tr>`;
|
||||
});
|
||||
html += '</tbody></table>';
|
||||
configFwContent.innerHTML = html;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function loadSensors() {
|
||||
@@ -528,9 +597,9 @@ function renderSerials(serials) {
|
||||
row.innerHTML = `
|
||||
<td><span class="category-badge ${item.category.toLowerCase()}">${categoryNames[item.category] || item.category}</span></td>
|
||||
<td>${escapeHtml(item.component)}</td>
|
||||
<td>${escapeHtml(item.location || '-')}</td>
|
||||
<td><code>${escapeHtml(item.serial_number)}</code></td>
|
||||
<td>${escapeHtml(item.manufacturer || '-')}</td>
|
||||
<td>${escapeHtml(item.part_number || '-')}</td>
|
||||
`;
|
||||
tbody.appendChild(row);
|
||||
});
|
||||
@@ -606,6 +675,28 @@ async function clearData() {
|
||||
}
|
||||
}
|
||||
|
||||
// Restart app (reload page)
|
||||
function restartApp() {
|
||||
if (confirm('Перезапустить приложение? Все загруженные данные будут потеряны.')) {
|
||||
fetch('/api/clear', { method: 'DELETE' }).then(() => {
|
||||
window.location.reload();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Exit app (shutdown server)
|
||||
async function exitApp() {
|
||||
if (confirm('Завершить работу приложения?')) {
|
||||
try {
|
||||
await fetch('/api/shutdown', { method: 'POST' });
|
||||
document.body.innerHTML = '<div style="display:flex;align-items:center;justify-content:center;height:100vh;font-family:sans-serif;"><div style="text-align:center;"><h1>LOGPile</h1><p>Приложение завершено. Можете закрыть эту вкладку.</p></div></div>';
|
||||
} catch (err) {
|
||||
// Server shutdown, connection will fail
|
||||
document.body.innerHTML = '<div style="display:flex;align-items:center;justify-content:center;height:100vh;font-family:sans-serif;"><div style="text-align:center;"><h1>LOGPile</h1><p>Приложение завершено. Можете закрыть эту вкладку.</p></div></div>';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Utilities
|
||||
function formatDate(isoString) {
|
||||
if (!isoString) return '-';
|
||||
@@ -619,3 +710,24 @@ function escapeHtml(text) {
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
function formatPCIeLink(width, speed) {
|
||||
if (!width && !speed) return '-';
|
||||
|
||||
// Convert GT/s to PCIe generation
|
||||
let gen = '';
|
||||
if (speed) {
|
||||
const gtMatch = speed.match(/(\d+\.?\d*)\s*GT/i);
|
||||
if (gtMatch) {
|
||||
const gts = parseFloat(gtMatch[1]);
|
||||
if (gts >= 32) gen = 'Gen5';
|
||||
else if (gts >= 16) gen = 'Gen4';
|
||||
else if (gts >= 8) gen = 'Gen3';
|
||||
else if (gts >= 5) gen = 'Gen2';
|
||||
else if (gts >= 2.5) gen = 'Gen1';
|
||||
}
|
||||
}
|
||||
|
||||
const widthStr = width ? `x${width}` : '';
|
||||
return gen ? `${widthStr} PCIe ${gen}` : `${widthStr} ${speed || ''}`;
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
<p class="hint">Поддерживаемые форматы: tar.gz, zip</p>
|
||||
</div>
|
||||
<div id="upload-status"></div>
|
||||
<div id="parsers-info" class="parsers-info"></div>
|
||||
</section>
|
||||
|
||||
<section id="data-section" class="hidden">
|
||||
@@ -90,9 +91,9 @@
|
||||
<tr>
|
||||
<th>Категория</th>
|
||||
<th>Компонент</th>
|
||||
<th>Расположение</th>
|
||||
<th>Серийный номер</th>
|
||||
<th>Производитель</th>
|
||||
<th>Part Number</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody></tbody>
|
||||
@@ -124,7 +125,11 @@
|
||||
</main>
|
||||
|
||||
<footer>
|
||||
<button id="clear-btn" class="hidden" onclick="clearData()">Очистить данные</button>
|
||||
<div class="footer-buttons">
|
||||
<button id="clear-btn" class="hidden" onclick="clearData()">Очистить данные</button>
|
||||
<button id="restart-btn" onclick="restartApp()">Перезапуск</button>
|
||||
<button id="exit-btn" onclick="exitApp()">Выход</button>
|
||||
</div>
|
||||
<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></p>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user