diff --git a/internal/collector/redfish.go b/internal/collector/redfish.go index 882e1ca..ebbb25d 100644 --- a/internal/collector/redfish.go +++ b/internal/collector/redfish.go @@ -110,6 +110,12 @@ func (c *RedfishConnector) Collect(ctx context.Context, req Request, emit Progre if recoveredN := c.recoverCriticalRedfishDocsPlanB(ctx, criticalClient, req, baseURL, criticalPaths, rawTree, fetchErrMap, emit); recoveredN > 0 { c.debugSnapshotf("critical plan-b recovered docs=%d", recoveredN) } + // Hide transient fetch errors for endpoints that were eventually recovered into rawTree. + for p := range fetchErrMap { + if _, ok := rawTree[p]; ok { + delete(fetchErrMap, p) + } + } if emit != nil { emit(Progress{Status: "running", Progress: 99, Message: "Redfish: анализ raw snapshot..."}) } diff --git a/internal/server/handlers.go b/internal/server/handlers.go index 8f2cb00..18bdc59 100644 --- a/internal/server/handlers.go +++ b/internal/server/handlers.go @@ -590,6 +590,203 @@ func (s *Server) handleGetFirmware(w http.ResponseWriter, r *http.Request) { jsonResponse(w, buildFirmwareEntries(result.Hardware)) } +type parseErrorEntry struct { + Source string `json:"source"` // redfish | parser | file | collect_log + Category string `json:"category"` // fetch | partial_inventory | collect_log + Severity string `json:"severity,omitempty"` // error | warning | info + Path string `json:"path,omitempty"` + Message string `json:"message"` + Detail string `json:"detail,omitempty"` +} + +func (s *Server) handleGetParseErrors(w http.ResponseWriter, r *http.Request) { + result := s.GetResult() + rawPkg := s.GetRawExport() + + items := make([]parseErrorEntry, 0) + seen := make(map[string]struct{}) + add := func(e parseErrorEntry) { + key := strings.TrimSpace(e.Source) + "|" + strings.TrimSpace(e.Category) + "|" + strings.TrimSpace(e.Path) + "|" + strings.TrimSpace(e.Message) + if _, ok := seen[key]; ok { + return + } + seen[key] = struct{}{} + items = append(items, e) + } + + var fetchErrMap map[string]string + if result != nil && result.RawPayloads != nil { + fetchErrMap = extractRedfishFetchErrors(result.RawPayloads["redfish_fetch_errors"]) + for path, msg := range fetchErrMap { + add(parseErrorEntry{ + Source: "redfish", + Category: "fetch", + Severity: parseErrorSeverityFromMessage(msg), + Path: path, + Message: msg, + }) + } + } + + if rawPkg != nil && len(rawPkg.Source.CollectLogs) > 0 { + for _, line := range rawPkg.Source.CollectLogs { + if !looksLikeErrorLogLine(line) { + continue + } + add(parseErrorEntry{ + Source: "collect_log", + Category: "collect_log", + Severity: parseErrorSeverityFromMessage(line), + Message: strings.TrimSpace(line), + }) + } + } + + if result != nil && result.Protocol == "redfish" && result.Hardware != nil { + hw := result.Hardware + if len(hw.Memory) == 0 && hasFetchErrorSuffix(fetchErrMap, "/Memory") { + add(parseErrorEntry{ + Source: "parser", + Category: "partial_inventory", + Severity: "warning", + Path: "/redfish/v1/Systems/*/Memory", + Message: "Memory inventory is empty because Redfish Memory endpoint failed during collection", + }) + } + if len(hw.CPUs) == 0 && hasFetchErrorSuffix(fetchErrMap, "/Processors") { + add(parseErrorEntry{ + Source: "parser", + Category: "partial_inventory", + Severity: "warning", + Path: "/redfish/v1/Systems/*/Processors", + Message: "CPU inventory is empty because Redfish Processors endpoint failed during collection", + }) + } + if len(hw.Firmware) == 0 && (hasFetchErrorSuffix(fetchErrMap, "/Managers/1") || hasFetchErrorSuffix(fetchErrMap, "/UpdateService")) { + add(parseErrorEntry{ + Source: "parser", + Category: "partial_inventory", + Severity: "warning", + Message: "Firmware inventory may be incomplete due to Redfish Manager/UpdateService endpoint failures", + }) + } + } + + sort.Slice(items, func(i, j int) bool { + if items[i].Severity != items[j].Severity { + // error > warning > info + return parseErrorSeverityRank(items[i].Severity) < parseErrorSeverityRank(items[j].Severity) + } + if items[i].Source != items[j].Source { + return items[i].Source < items[j].Source + } + if items[i].Category != items[j].Category { + return items[i].Category < items[j].Category + } + return items[i].Path < items[j].Path + }) + + jsonResponse(w, map[string]any{ + "items": items, + "summary": map[string]any{ + "total": len(items), + "source_kind": func() string { + if rawPkg != nil { + return rawPkg.Source.Kind + } + return "" + }(), + }, + }) +} + +func extractRedfishFetchErrors(raw any) map[string]string { + out := make(map[string]string) + list, ok := raw.([]map[string]interface{}) + if ok { + for _, item := range list { + p := strings.TrimSpace(fmt.Sprintf("%v", item["path"])) + if p == "" { + continue + } + out[p] = strings.TrimSpace(fmt.Sprintf("%v", item["error"])) + } + return out + } + if generic, ok := raw.([]interface{}); ok { + for _, itemAny := range generic { + item, ok := itemAny.(map[string]interface{}) + if !ok { + continue + } + p := strings.TrimSpace(fmt.Sprintf("%v", item["path"])) + if p == "" { + continue + } + out[p] = strings.TrimSpace(fmt.Sprintf("%v", item["error"])) + } + } + return out +} + +func looksLikeErrorLogLine(line string) bool { + s := strings.ToLower(strings.TrimSpace(line)) + if s == "" { + return false + } + return strings.Contains(s, "ошибка") || + strings.Contains(s, "error") || + strings.Contains(s, "failed") || + strings.Contains(s, "timeout") || + strings.Contains(s, "deadline exceeded") +} + +func hasFetchErrorSuffix(fetchErrs map[string]string, suffix string) bool { + if len(fetchErrs) == 0 { + return false + } + for p := range fetchErrs { + if strings.HasSuffix(p, suffix) { + return true + } + } + return false +} + +func parseErrorSeverityFromMessage(msg string) string { + s := strings.ToLower(strings.TrimSpace(msg)) + if s == "" { + return "info" + } + if strings.Contains(s, "timeout") || strings.Contains(s, "deadline exceeded") { + return "error" + } + if strings.HasPrefix(s, "status 500 ") || strings.HasPrefix(s, "status 502 ") || strings.HasPrefix(s, "status 503 ") || strings.HasPrefix(s, "status 504 ") { + return "error" + } + if strings.HasPrefix(s, "status 401 ") || strings.HasPrefix(s, "status 403 ") { + return "error" + } + if strings.HasPrefix(s, "status 404 ") || strings.HasPrefix(s, "status 405 ") || strings.HasPrefix(s, "status 410 ") || strings.HasPrefix(s, "status 501 ") { + return "info" + } + if strings.Contains(s, "ошибка") || strings.Contains(s, "error") || strings.Contains(s, "failed") { + return "warning" + } + return "info" +} + +func parseErrorSeverityRank(severity string) int { + switch strings.ToLower(strings.TrimSpace(severity)) { + case "error": + return 0 + case "warning": + return 1 + default: + return 2 + } +} + type firmwareEntry struct { Component string `json:"component"` Model string `json:"model"` diff --git a/internal/server/server.go b/internal/server/server.go index 5290525..e7d8ade 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -68,6 +68,7 @@ func (s *Server) setupRoutes() { s.mux.HandleFunc("GET /api/config", s.handleGetConfig) s.mux.HandleFunc("GET /api/serials", s.handleGetSerials) s.mux.HandleFunc("GET /api/firmware", s.handleGetFirmware) + s.mux.HandleFunc("GET /api/parse-errors", s.handleGetParseErrors) s.mux.HandleFunc("GET /api/export/csv", s.handleExportCSV) s.mux.HandleFunc("GET /api/export/json", s.handleExportJSON) s.mux.HandleFunc("GET /api/export/reanimator", s.handleExportReanimator) diff --git a/web/static/css/style.css b/web/static/css/style.css index 37362f5..7860f5e 100644 --- a/web/static/css/style.css +++ b/web/static/css/style.css @@ -473,6 +473,12 @@ table { border-collapse: collapse; } +.table-scroll { + width: 100%; + overflow-x: auto; + -webkit-overflow-scrolling: touch; +} + th, td { padding: 0.75rem; text-align: left; @@ -488,6 +494,54 @@ tr:hover { background: #f8f9fa; } +#parse-errors-table { + min-width: 980px; + table-layout: fixed; +} + +#parse-errors-table th:nth-child(1), +#parse-errors-table td:nth-child(1) { + width: 92px; +} + +#parse-errors-table th:nth-child(2), +#parse-errors-table td:nth-child(2) { + width: 110px; +} + +#parse-errors-table th:nth-child(3), +#parse-errors-table td:nth-child(3) { + width: 95px; +} + +#parse-errors-table th:nth-child(4), +#parse-errors-table td:nth-child(4) { + width: 300px; +} + +#parse-errors-table th:nth-child(5), +#parse-errors-table td:nth-child(5) { + width: auto; +} + +#parse-errors-table td, +#parse-errors-table th { + vertical-align: top; +} + +#parse-errors-table td code { + white-space: normal; + word-break: break-word; + overflow-wrap: anywhere; + display: inline-block; +} + +#parse-errors-table td:last-child { + white-space: normal; + word-break: break-word; + overflow-wrap: anywhere; +} + code { font-family: 'Monaco', 'Menlo', monospace; font-size: 0.85em; diff --git a/web/static/js/app.js b/web/static/js/app.js index 12fa434..e027409 100644 --- a/web/static/js/app.js +++ b/web/static/js/app.js @@ -590,6 +590,7 @@ function initFilters() { let allSensors = []; let allEvents = []; let allSerials = []; +let allParseErrors = []; let currentVendor = ''; @@ -622,7 +623,8 @@ async function loadData(vendor, filename) { loadFirmware(), loadSensors(), loadSerials(), - loadEvents() + loadEvents(), + loadParseErrors() ]); } @@ -647,7 +649,6 @@ function renderConfig(data) { const config = data.hardware || data; const spec = data.specification; const redfishFetchErrors = Array.isArray(data.redfish_fetch_errors) ? data.redfish_fetch_errors : []; - const visibleRedfishFetchErrors = filterVisibleRedfishFetchErrors(redfishFetchErrors); const devices = Array.isArray(config.devices) ? config.devices : []; const volumes = Array.isArray(config.volumes) ? config.volumes : []; @@ -701,21 +702,6 @@ function renderConfig(data) {
${escapeHtml(partialInventory)}
`; } - if (visibleRedfishFetchErrors.length > 0) { - html += `Сохранено в raw snapshot для последующего анализа в GUI.
-| Endpoint | Ошибка |
|---|---|
${escapeHtml(String(path))} |
- ${escapeHtml(String(err))} | -
| Location | Наличие | Размер | Тип | Max частота | Текущая частота | Производитель | Артикул | Статус | +Location | Наличие | Размер | Тип | Max частота | Текущая частота | Производитель | Артикул | Серийный номер | Статус | ${(mem.details && mem.details.current_speed_mhz) || mem.speed_mhz || '-'} MHz | ${escapeHtml(mem.manufacturer || '-')} | ${escapeHtml(mem.part_number || '-')} |
+ ${escapeHtml(mem.serial_number || '-')} |
${escapeHtml(mem.status || 'OK')} | `; }); @@ -1260,6 +1247,47 @@ async function loadEvents() { } } +async function loadParseErrors() { + try { + const response = await fetch('/api/parse-errors'); + const payload = await response.json(); + allParseErrors = Array.isArray(payload && payload.items) ? payload.items : []; + renderParseErrors(allParseErrors); + } catch (err) { + console.error('Failed to load parse errors:', err); + allParseErrors = []; + renderParseErrors([]); + } +} + +function renderParseErrors(items) { + const tbody = document.querySelector('#parse-errors-table tbody'); + if (!tbody) return; + tbody.innerHTML = ''; + + if (!items || items.length === 0) { + tbody.innerHTML = '
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Ошибок разбора не обнаружено | ${escapeHtml(source)} | +${escapeHtml(category)} | +${escapeHtml(severity)} | +${escapeHtml(path)} |
+ ${escapeHtml(message)} | + `; + tbody.appendChild(row); + }); +} + function renderEvents(events) { const tbody = document.querySelector('#events-table tbody'); tbody.innerHTML = ''; @@ -1306,6 +1334,7 @@ async function clearData() { allSensors = []; allEvents = []; allSerials = []; + allParseErrors = []; } catch (err) { console.error('Failed to clear data:', err); } @@ -1347,19 +1376,6 @@ function escapeHtml(text) { return div.innerHTML; } -function filterVisibleRedfishFetchErrors(items) { - if (!Array.isArray(items)) return []; - return items.filter(item => { - const message = String(item && typeof item === 'object' ? (item.error || '') : item || '').toLowerCase(); - return !( - message.startsWith('status 404 ') || - message.startsWith('status 405 ') || - message.startsWith('status 410 ') || - message.startsWith('status 501 ') - ); - }); -} - function detectPartialRedfishInventory({ cpus, memory, redfishFetchErrors }) { const errors = Array.isArray(redfishFetchErrors) ? redfishFetchErrors : []; const paths = errors.map(item => String(item && typeof item === 'object' ? (item.path || '') : '')).filter(Boolean); diff --git a/web/templates/index.html b/web/templates/index.html index 2c76cc4..4785ef6 100644 --- a/web/templates/index.html +++ b/web/templates/index.html @@ -106,6 +106,7 @@ +|||||||||||||
| Источник | +Категория | +Важность | +Endpoint / Path | +Сообщение | +
|---|