diff --git a/internal/collector/redfish.go b/internal/collector/redfish.go index b11484f..8425cd2 100644 --- a/internal/collector/redfish.go +++ b/internal/collector/redfish.go @@ -159,14 +159,11 @@ func (c *RedfishConnector) Collect(ctx context.Context, req Request, emit Progre systemPaths := c.discoverMemberPaths(discoveryCtx, snapshotClient, req, baseURL, "/redfish/v1/Systems", "/redfish/v1/Systems/1") primarySystem := firstNonEmptyPath(systemPaths, "/redfish/v1/Systems/1") - poweredOnByCollector := false if primarySystem != "" { - if on, changed := c.ensureHostPowerForCollection(ctx, snapshotClient, req, baseURL, primarySystem, emit); on { - poweredOnByCollector = changed - } + c.ensureHostPowerForCollection(ctx, snapshotClient, req, baseURL, primarySystem, emit) } defer func() { - if !poweredOnByCollector || primarySystem == "" { + if primarySystem == "" || !req.StopHostAfterCollect { return } shutdownCtx, cancel := context.WithTimeout(context.Background(), 45*time.Second) @@ -313,6 +310,10 @@ func (c *RedfishConnector) Collect(ctx context.Context, req Request, emit Progre } // Collect hardware event logs separately (not part of tree-walk to avoid bloat). rawLogEntries := c.collectRedfishLogEntries(withRedfishTelemetryPhase(ctx, "log_entries"), snapshotClient, req, baseURL, systemPaths, managerPaths) + var debugPayloads map[string]any + if req.DebugPayloads { + debugPayloads = c.collectDebugPayloads(ctx, snapshotClient, req, baseURL, systemPaths) + } rawPayloads := map[string]any{ "redfish_tree": rawTree, "redfish_profiles": map[string]any{ @@ -418,6 +419,9 @@ func (c *RedfishConnector) Collect(ctx context.Context, req Request, emit Progre if len(rawLogEntries) > 0 { rawPayloads["redfish_log_entries"] = rawLogEntries } + if len(debugPayloads) > 0 { + rawPayloads["redfish_debug_payloads"] = debugPayloads + } // Unified tunnel: live collection and raw import go through the same analyzer over redfish_tree. result, err := ReplayRedfishFromRawPayloads(rawPayloads, nil) if err != nil { @@ -618,6 +622,20 @@ func (c *RedfishConnector) restoreHostPowerAfterCollection(ctx context.Context, } } +// collectDebugPayloads fetches vendor-specific diagnostic endpoints on a best-effort basis. +// Results are stored in rawPayloads["redfish_debug_payloads"] and exported with the bundle. +// Enabled only when Request.DebugPayloads is true. +func (c *RedfishConnector) collectDebugPayloads(ctx context.Context, client *http.Client, req Request, baseURL string, systemPaths []string) map[string]any { + out := map[string]any{} + for _, systemPath := range systemPaths { + // AMI/MSI: inventory CRC groups — reveals which groups are supported by this BMC. + if doc, err := c.getJSON(ctx, client, req, baseURL, joinPath(systemPath, "/Oem/Ami/Inventory/Crc")); err == nil { + out[joinPath(systemPath, "/Oem/Ami/Inventory/Crc")] = doc + } + } + return out +} + // invalidateRedfishInventory POSTs to the AMI/MSI InventoryCrc endpoint to zero out // all known CRC groups before a host power-on. This causes the BMC to accept fresh // inventory from the host after boot, preventing stale inventory (ghost GPUs, wrong @@ -630,8 +648,6 @@ func (c *RedfishConnector) invalidateRedfishInventory(ctx context.Context, clien {"CPU": 0}, {"DIMM": 0}, {"PCIE": 0}, - {"CERTIFICATES": 0}, - {"SECUREBOOT": 0}, }, } if err := c.postJSON(ctx, client, req, baseURL, crcPath, body); err != nil { @@ -5609,6 +5625,7 @@ func parseFirmware(system, bios, manager, networkProtocol map[string]interface{} return out } + func mapStatus(statusAny interface{}) string { if statusAny == nil { return "" diff --git a/internal/collector/redfish_replay.go b/internal/collector/redfish_replay.go index 9127a0a..0934ed9 100644 --- a/internal/collector/redfish_replay.go +++ b/internal/collector/redfish_replay.go @@ -123,7 +123,7 @@ func ReplayRedfishFromRawPayloads(rawPayloads map[string]any, emit ProgressFn) ( PowerSupply: psus, NetworkAdapters: nics, Firmware: firmware, - }, + }, } match := profileMatch for _, profile := range match.Profiles { @@ -277,6 +277,7 @@ func redfishFetchErrorsFromRawPayloads(rawPayloads map[string]any) map[string]st } } + func buildDriveFetchWarningEvents(rawPayloads map[string]any) []models.Event { errs := redfishFetchErrorsFromRawPayloads(rawPayloads) if len(errs) == 0 { diff --git a/internal/collector/types.go b/internal/collector/types.go index 9e23a4f..390b195 100644 --- a/internal/collector/types.go +++ b/internal/collector/types.go @@ -15,7 +15,9 @@ type Request struct { Password string Token string TLSMode string - PowerOnIfHostOff bool + PowerOnIfHostOff bool + StopHostAfterCollect bool + DebugPayloads bool } type Progress struct { diff --git a/internal/exporter/reanimator_converter.go b/internal/exporter/reanimator_converter.go index ac8c0ad..7790e54 100644 --- a/internal/exporter/reanimator_converter.go +++ b/internal/exporter/reanimator_converter.go @@ -43,13 +43,13 @@ func ConvertToReanimator(result *models.AnalysisResult) (*ReanimatorExport, erro TargetHost: targetHost, CollectedAt: collectedAt, Hardware: ReanimatorHardware{ - Board: convertBoard(result.Hardware.BoardInfo), - Firmware: dedupeFirmware(convertFirmware(result.Hardware.Firmware)), - CPUs: dedupeCPUs(convertCPUsFromDevices(devices, collectedAt, result.Hardware.BoardInfo.SerialNumber, buildCPUMicrocodeBySocket(result.Hardware.Firmware))), - Memory: dedupeMemory(convertMemoryFromDevices(devices, collectedAt)), - Storage: dedupeStorage(convertStorageFromDevices(devices, collectedAt)), - PCIeDevices: dedupePCIe(convertPCIeFromDevices(devices, collectedAt)), - PowerSupplies: dedupePSUs(convertPSUsFromDevices(devices, collectedAt)), + Board: convertBoard(result.Hardware.BoardInfo), + Firmware: dedupeFirmware(convertFirmware(result.Hardware.Firmware)), + CPUs: dedupeCPUs(convertCPUsFromDevices(devices, collectedAt, result.Hardware.BoardInfo.SerialNumber, buildCPUMicrocodeBySocket(result.Hardware.Firmware))), + Memory: dedupeMemory(convertMemoryFromDevices(devices, collectedAt)), + Storage: dedupeStorage(convertStorageFromDevices(devices, collectedAt)), + PCIeDevices: dedupePCIe(convertPCIeFromDevices(devices, collectedAt)), + PowerSupplies: dedupePSUs(convertPSUsFromDevices(devices, collectedAt)), Sensors: convertSensors(result.Sensors), EventLogs: convertEventLogs(result.Events, collectedAt), }, diff --git a/internal/server/collect_types.go b/internal/server/collect_types.go index 3d4d43b..7b60ae7 100644 --- a/internal/server/collect_types.go +++ b/internal/server/collect_types.go @@ -19,7 +19,9 @@ type CollectRequest struct { Password string `json:"password,omitempty"` Token string `json:"token,omitempty"` TLSMode string `json:"tls_mode"` - PowerOnIfHostOff bool `json:"power_on_if_host_off,omitempty"` + PowerOnIfHostOff bool `json:"power_on_if_host_off,omitempty"` + StopHostAfterCollect bool `json:"stop_host_after_collect,omitempty"` + DebugPayloads bool `json:"debug_payloads,omitempty"` } type CollectProbeResponse struct { diff --git a/internal/server/handlers.go b/internal/server/handlers.go index 62f0588..18f4b07 100644 --- a/internal/server/handlers.go +++ b/internal/server/handlers.go @@ -10,8 +10,10 @@ import ( "fmt" "html/template" "io" + "net" "net/http" "os" + "sync/atomic" "path/filepath" "regexp" "sort" @@ -1574,6 +1576,32 @@ func (s *Server) handleCollectStart(w http.ResponseWriter, r *http.Request) { _ = json.NewEncoder(w).Encode(job.toJobResponse("Collection job accepted")) } +// pingHost dials host:port up to total times with 2s timeout each, returns true if +// at least need attempts succeeded. +func pingHost(host string, port int, total, need int) (bool, string) { + addr := fmt.Sprintf("%s:%d", host, port) + var successes atomic.Int32 + done := make(chan struct{}, total) + for i := 0; i < total; i++ { + go func() { + defer func() { done <- struct{}{} }() + conn, err := net.DialTimeout("tcp", addr, 2*time.Second) + if err == nil { + conn.Close() + successes.Add(1) + } + }() + } + for i := 0; i < total; i++ { + <-done + } + n := int(successes.Load()) + if n < need { + return false, fmt.Sprintf("Хост недоступен: только %d из %d попыток подключения к %s прошли успешно (требуется минимум %d)", n, total, addr, need) + } + return true, "" +} + func (s *Server) handleCollectProbe(w http.ResponseWriter, r *http.Request) { var req CollectRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { @@ -1595,6 +1623,11 @@ func (s *Server) handleCollectProbe(w http.ResponseWriter, r *http.Request) { return } + if ok, msg := pingHost(req.Host, req.Port, 10, 3); !ok { + jsonError(w, msg, http.StatusBadRequest) + return + } + ctx, cancel := context.WithTimeout(r.Context(), 20*time.Second) defer cancel() @@ -1967,7 +2000,9 @@ func toCollectorRequest(req CollectRequest) collector.Request { Password: req.Password, Token: req.Token, TLSMode: req.TLSMode, - PowerOnIfHostOff: req.PowerOnIfHostOff, + PowerOnIfHostOff: req.PowerOnIfHostOff, + StopHostAfterCollect: req.StopHostAfterCollect, + DebugPayloads: req.DebugPayloads, } } diff --git a/web/static/css/style.css b/web/static/css/style.css index b9bbcf0..ab99ab4 100644 --- a/web/static/css/style.css +++ b/web/static/css/style.css @@ -210,7 +210,6 @@ main { pointer-events: none; } -#api-check-btn, #api-connect-btn, #api-power-on-collect-btn, #api-collect-off-btn, @@ -229,7 +228,6 @@ main { transition: background-color 0.2s ease, opacity 0.2s ease; } -#api-check-btn:hover, #api-connect-btn:hover, #api-power-on-collect-btn:hover, #api-collect-off-btn:hover, @@ -242,7 +240,6 @@ main { #convert-run-btn:disabled, #convert-folder-btn:disabled, -#api-check-btn:disabled, #api-connect-btn:disabled, #api-power-on-collect-btn:disabled, #api-collect-off-btn:disabled, @@ -252,6 +249,127 @@ main { cursor: not-allowed; } +#api-collect-btn { + background: #1f8f4c; + color: #fff; + border: none; + border-radius: 6px; + padding: 0.6rem 1.25rem; + font-size: 0.95rem; + font-weight: 600; + cursor: pointer; + transition: background-color 0.2s ease, opacity 0.2s ease; +} + +#api-collect-btn:hover { + background: #176e3a; +} + +#api-collect-btn:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +.api-probe-options { + margin-top: 0.9rem; + display: flex; + flex-direction: column; + gap: 0.45rem; +} + +.api-form-checkbox { + display: flex; + align-items: center; + gap: 0.45rem; + font-size: 0.9rem; + cursor: pointer; + user-select: none; +} + +.api-form-checkbox input[type="checkbox"] { + width: 1rem; + height: 1rem; + cursor: pointer; +} + +.api-form-checkbox input[type="checkbox"]:disabled { + cursor: not-allowed; + opacity: 0.6; +} + +.api-form-checkbox span { + color: #444; +} + +.api-form-checkbox-sub { + padding-left: 0.25rem; + opacity: 0.8; +} + +.api-probe-options-separator { + margin: 0.5rem 0; + border-top: 1px solid #e2e8f0; +} + +.api-confirm-modal-backdrop { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.45); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; +} + +.api-confirm-modal { + background: #fff; + border-radius: 10px; + padding: 1.5rem 1.75rem; + max-width: 380px; + width: 90%; + box-shadow: 0 8px 32px rgba(0,0,0,0.18); +} + +.api-confirm-modal p { + margin-bottom: 1.1rem; + font-size: 0.95rem; + color: #333; + line-height: 1.5; +} + +.api-confirm-modal-actions { + display: flex; + gap: 0.6rem; + justify-content: flex-end; +} + +.api-confirm-modal-actions button { + border: none; + border-radius: 6px; + padding: 0.5rem 1rem; + font-size: 0.9rem; + font-weight: 600; + cursor: pointer; +} + +.api-confirm-modal-actions .btn-cancel { + background: #e2e8f0; + color: #333; +} + +.api-confirm-modal-actions .btn-cancel:hover { + background: #cbd5e1; +} + +.api-confirm-modal-actions .btn-confirm { + background: #dc3545; + color: #fff; +} + +.api-confirm-modal-actions .btn-confirm:hover { + background: #b02a37; +} + .api-connect-status { margin-top: 0.75rem; font-size: 0.85rem; diff --git a/web/static/js/app.js b/web/static/js/app.js index fcfb301..b6474a8 100644 --- a/web/static/js/app.js +++ b/web/static/js/app.js @@ -91,14 +91,18 @@ function initApiSource() { } const cancelJobButton = document.getElementById('cancel-job-btn'); - const checkButton = document.getElementById('api-check-btn'); - const collectOffButton = document.getElementById('api-collect-off-btn'); - const powerOnCollectButton = document.getElementById('api-power-on-collect-btn'); + const connectButton = document.getElementById('api-connect-btn'); + const collectButton = document.getElementById('api-collect-btn'); + const powerOffCheckbox = document.getElementById('api-power-off'); const fieldNames = ['host', 'port', 'username', 'password']; apiForm.addEventListener('submit', (event) => { event.preventDefault(); - startCollectionFromCurrentProbe(false); + if (apiProbeResult && apiProbeResult.reachable) { + startCollectionWithOptions(); + } else { + startApiProbe(); + } }); if (cancelJobButton) { @@ -106,21 +110,29 @@ function initApiSource() { cancelCollectionJob(); }); } - if (checkButton) { - checkButton.addEventListener('click', () => { + if (connectButton) { + connectButton.addEventListener('click', () => { startApiProbe(); }); } - if (collectOffButton) { - collectOffButton.addEventListener('click', () => { - clearApiPowerDecisionTimer(); - startCollectionFromCurrentProbe(false); + if (collectButton) { + collectButton.addEventListener('click', () => { + startCollectionWithOptions(); }); } - if (powerOnCollectButton) { - powerOnCollectButton.addEventListener('click', () => { - clearApiPowerDecisionTimer(); - startCollectionFromCurrentProbe(true); + if (powerOffCheckbox) { + powerOffCheckbox.addEventListener('change', () => { + if (!powerOffCheckbox.checked) { + return; + } + // If host was already on when probed, warn before enabling shutdown + if (apiProbeResult && apiProbeResult.host_powered_on) { + showConfirmModal( + 'Хост был включён до начала сбора. Вы уверены, что хотите выключить его после завершения сбора?', + () => { /* confirmed — leave checked */ }, + () => { powerOffCheckbox.checked = false; } + ); + } }); } @@ -151,11 +163,42 @@ function initApiSource() { renderCollectionJob(); } +function showConfirmModal(message, onConfirm, onCancel) { + const backdrop = document.createElement('div'); + backdrop.className = 'api-confirm-modal-backdrop'; + backdrop.innerHTML = ` + + `; + document.body.appendChild(backdrop); + + const close = () => document.body.removeChild(backdrop); + backdrop.querySelector('.btn-cancel').addEventListener('click', () => { + close(); + if (onCancel) onCancel(); + }); + backdrop.querySelector('.btn-confirm').addEventListener('click', () => { + close(); + if (onConfirm) onConfirm(); + }); + backdrop.addEventListener('click', (e) => { + if (e.target === backdrop) { + close(); + if (onCancel) onCancel(); + } + }); +} + function startApiProbe() { const { isValid, payload, errors } = validateCollectForm(); renderFormErrors(errors); if (!isValid) { - renderApiConnectStatus(false, null); + renderApiConnectStatus(false); resetApiProbeState(); return; } @@ -163,7 +206,7 @@ function startApiProbe() { apiConnectPayload = payload; resetApiProbeState(); setApiFormBlocked(true); - renderApiConnectStatus(true, { ...payload, password: '***' }); + renderApiConnectStatus(true); fetch('/api/collect/probe', { method: 'POST', @@ -181,7 +224,7 @@ function startApiProbe() { }) .catch((err) => { resetApiProbeState(); - renderApiConnectStatus(false, null); + renderApiConnectStatus(false); const status = document.getElementById('api-connect-status'); if (status) { status.textContent = err.message || 'Проверка подключения не удалась'; @@ -195,12 +238,11 @@ function startApiProbe() { }); } -function startCollectionFromCurrentProbe(powerOnIfHostOff) { +function startCollectionWithOptions() { const { isValid, payload, errors } = validateCollectForm(); renderFormErrors(errors); if (!isValid) { - renderApiConnectStatus(false, null); - resetApiProbeState(); + renderApiConnectStatus(false); return; } @@ -213,71 +255,78 @@ function startCollectionFromCurrentProbe(powerOnIfHostOff) { return; } - clearApiPowerDecisionTimer(); - payload.power_on_if_host_off = Boolean(powerOnIfHostOff); + const powerOnCheckbox = document.getElementById('api-power-on'); + const powerOffCheckbox = document.getElementById('api-power-off'); + const debugPayloads = document.getElementById('api-debug-payloads'); + payload.power_on_if_host_off = powerOnCheckbox ? powerOnCheckbox.checked : false; + payload.stop_host_after_collect = powerOffCheckbox ? powerOffCheckbox.checked : false; + payload.debug_payloads = debugPayloads ? debugPayloads.checked : false; startCollectionJob(payload); } function renderApiProbeState() { - const collectButton = document.getElementById('api-connect-btn'); + const connectButton = document.getElementById('api-connect-btn'); + const probeOptions = document.getElementById('api-probe-options'); const status = document.getElementById('api-connect-status'); - const decision = document.getElementById('api-power-decision'); - const decisionText = document.getElementById('api-power-decision-text'); - if (!collectButton || !status || !decision || !decisionText) { + const powerOnCheckbox = document.getElementById('api-power-on'); + const powerOffCheckbox = document.getElementById('api-power-off'); + if (!connectButton || !probeOptions || !status) { return; } - decision.classList.add('hidden'); - clearApiPowerDecisionTimer(); - collectButton.disabled = !apiProbeResult || !apiProbeResult.reachable; - if (!apiProbeResult || !apiProbeResult.reachable) { status.textContent = 'Проверка подключения не пройдена.'; status.className = 'api-connect-status error'; + probeOptions.classList.add('hidden'); + connectButton.textContent = 'Подключиться'; return; } - if (apiProbeResult.host_powered_on) { - status.textContent = apiProbeResult.message || 'Связь с BMC есть, host включен.'; + const hostOn = apiProbeResult.host_powered_on; + const powerControlAvailable = apiProbeResult.power_control_available; + + if (hostOn) { + status.textContent = apiProbeResult.message || 'Связь с BMC есть, host включён.'; status.className = 'api-connect-status success'; - collectButton.disabled = false; - return; + } else { + status.textContent = apiProbeResult.message || 'Связь с BMC есть, host выключен.'; + status.className = 'api-connect-status warning'; } - status.textContent = apiProbeResult.message || 'Связь с BMC есть, host выключен.'; - status.className = 'api-connect-status warning'; - if (!apiProbeResult.power_control_available) { - collectButton.disabled = false; - return; - } + probeOptions.classList.remove('hidden'); - decision.classList.remove('hidden'); - let secondsLeft = 5; - const updateDecisionText = () => { - decisionText.textContent = `Если не выбрать действие, сбор начнется без включения через ${secondsLeft} сек.`; - }; - updateDecisionText(); - apiPowerDecisionTimer = window.setInterval(() => { - secondsLeft -= 1; - if (secondsLeft <= 0) { - clearApiPowerDecisionTimer(); - startCollectionFromCurrentProbe(false); - return; + // "Включить" checkbox + if (powerOnCheckbox) { + if (hostOn) { + // Host already on — checkbox is checked and disabled + powerOnCheckbox.checked = true; + powerOnCheckbox.disabled = true; + } else { + // Host off — default: checked (will power on), enabled + powerOnCheckbox.checked = true; + powerOnCheckbox.disabled = !powerControlAvailable; } - updateDecisionText(); - }, 1000); + } + + // "Выключить" checkbox — default: unchecked + if (powerOffCheckbox) { + powerOffCheckbox.checked = false; + powerOffCheckbox.disabled = !powerControlAvailable; + } + + connectButton.textContent = 'Переподключиться'; } function resetApiProbeState() { apiProbeResult = null; clearApiPowerDecisionTimer(); - const collectButton = document.getElementById('api-connect-btn'); - const decision = document.getElementById('api-power-decision'); - if (collectButton) { - collectButton.disabled = true; + const connectButton = document.getElementById('api-connect-btn'); + const probeOptions = document.getElementById('api-probe-options'); + if (connectButton) { + connectButton.textContent = 'Подключиться'; } - if (decision) { - decision.classList.add('hidden'); + if (probeOptions) { + probeOptions.classList.add('hidden'); } } @@ -368,7 +417,7 @@ function renderFormErrors(errors) { summary.innerHTML = `Исправьте ошибки в форме:`; } -function renderApiConnectStatus(isValid, payload) { +function renderApiConnectStatus(isValid) { const status = document.getElementById('api-connect-status'); if (!status) { return; @@ -380,16 +429,8 @@ function renderApiConnectStatus(isValid, payload) { return; } - const payloadPreview = { ...payload }; - if (payloadPreview.password) { - payloadPreview.password = '***'; - } - if (payloadPreview.token) { - payloadPreview.token = '***'; - } - - status.textContent = `Payload сформирован: ${JSON.stringify(payloadPreview)}`; - status.className = 'api-connect-status success'; + status.textContent = 'Подключение...'; + status.className = 'api-connect-status info'; } function clearApiConnectStatus() { @@ -440,7 +481,7 @@ function startCollectionJob(payload) { .catch((err) => { setApiFormBlocked(false); clearApiConnectStatus(); - renderApiConnectStatus(false, null); + renderApiConnectStatus(false); const status = document.getElementById('api-connect-status'); if (status) { status.textContent = err.message || 'Ошибка запуска задачи'; diff --git a/web/templates/index.html b/web/templates/index.html index 6610237..8834945 100644 --- a/web/templates/index.html +++ b/web/templates/index.html @@ -76,16 +76,25 @@
- - +
-