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:
2026-01-25 13:49:43 +03:00
parent e52eb909f7
commit c7422e95aa
11 changed files with 507 additions and 61 deletions

View File

@@ -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)
}
}

View File

@@ -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 {

View File

@@ -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

View File

@@ -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

View File

@@ -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"
}
}
}
}

View File

@@ -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.)

View File

@@ -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)

View File

@@ -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)

View File

@@ -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;
}

View File

@@ -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 || ''}`;
}

View File

@@ -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>