feat: redesign collection UI + add StopHostAfterCollect + TCP ping pre-probe
- Single "Подключиться" button flow: probe first, then show collect options - Power management checkboxes: power on before / stop after collect - Modal confirmation when enabling shutdown on already-powered-on host - StopHostAfterCollect flag: host shuts down only when explicitly requested - TCP ping (10 attempts, min 3 successes) before Redfish probe - Debug payloads checkbox (Oem/Ami/Inventory/Crc, off by default) - Remove platform_config BIOS settings collection (unreliable on AMI) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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")
|
systemPaths := c.discoverMemberPaths(discoveryCtx, snapshotClient, req, baseURL, "/redfish/v1/Systems", "/redfish/v1/Systems/1")
|
||||||
primarySystem := firstNonEmptyPath(systemPaths, "/redfish/v1/Systems/1")
|
primarySystem := firstNonEmptyPath(systemPaths, "/redfish/v1/Systems/1")
|
||||||
poweredOnByCollector := false
|
|
||||||
if primarySystem != "" {
|
if primarySystem != "" {
|
||||||
if on, changed := c.ensureHostPowerForCollection(ctx, snapshotClient, req, baseURL, primarySystem, emit); on {
|
c.ensureHostPowerForCollection(ctx, snapshotClient, req, baseURL, primarySystem, emit)
|
||||||
poweredOnByCollector = changed
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
defer func() {
|
defer func() {
|
||||||
if !poweredOnByCollector || primarySystem == "" {
|
if primarySystem == "" || !req.StopHostAfterCollect {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
shutdownCtx, cancel := context.WithTimeout(context.Background(), 45*time.Second)
|
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).
|
// 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)
|
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{
|
rawPayloads := map[string]any{
|
||||||
"redfish_tree": rawTree,
|
"redfish_tree": rawTree,
|
||||||
"redfish_profiles": map[string]any{
|
"redfish_profiles": map[string]any{
|
||||||
@@ -418,6 +419,9 @@ func (c *RedfishConnector) Collect(ctx context.Context, req Request, emit Progre
|
|||||||
if len(rawLogEntries) > 0 {
|
if len(rawLogEntries) > 0 {
|
||||||
rawPayloads["redfish_log_entries"] = rawLogEntries
|
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.
|
// Unified tunnel: live collection and raw import go through the same analyzer over redfish_tree.
|
||||||
result, err := ReplayRedfishFromRawPayloads(rawPayloads, nil)
|
result, err := ReplayRedfishFromRawPayloads(rawPayloads, nil)
|
||||||
if err != 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
|
// 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
|
// 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
|
// 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},
|
{"CPU": 0},
|
||||||
{"DIMM": 0},
|
{"DIMM": 0},
|
||||||
{"PCIE": 0},
|
{"PCIE": 0},
|
||||||
{"CERTIFICATES": 0},
|
|
||||||
{"SECUREBOOT": 0},
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
if err := c.postJSON(ctx, client, req, baseURL, crcPath, body); err != nil {
|
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
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
func mapStatus(statusAny interface{}) string {
|
func mapStatus(statusAny interface{}) string {
|
||||||
if statusAny == nil {
|
if statusAny == nil {
|
||||||
return ""
|
return ""
|
||||||
|
|||||||
@@ -123,7 +123,7 @@ func ReplayRedfishFromRawPayloads(rawPayloads map[string]any, emit ProgressFn) (
|
|||||||
PowerSupply: psus,
|
PowerSupply: psus,
|
||||||
NetworkAdapters: nics,
|
NetworkAdapters: nics,
|
||||||
Firmware: firmware,
|
Firmware: firmware,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
match := profileMatch
|
match := profileMatch
|
||||||
for _, profile := range match.Profiles {
|
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 {
|
func buildDriveFetchWarningEvents(rawPayloads map[string]any) []models.Event {
|
||||||
errs := redfishFetchErrorsFromRawPayloads(rawPayloads)
|
errs := redfishFetchErrorsFromRawPayloads(rawPayloads)
|
||||||
if len(errs) == 0 {
|
if len(errs) == 0 {
|
||||||
|
|||||||
@@ -15,7 +15,9 @@ type Request struct {
|
|||||||
Password string
|
Password string
|
||||||
Token string
|
Token string
|
||||||
TLSMode string
|
TLSMode string
|
||||||
PowerOnIfHostOff bool
|
PowerOnIfHostOff bool
|
||||||
|
StopHostAfterCollect bool
|
||||||
|
DebugPayloads bool
|
||||||
}
|
}
|
||||||
|
|
||||||
type Progress struct {
|
type Progress struct {
|
||||||
|
|||||||
@@ -43,13 +43,13 @@ func ConvertToReanimator(result *models.AnalysisResult) (*ReanimatorExport, erro
|
|||||||
TargetHost: targetHost,
|
TargetHost: targetHost,
|
||||||
CollectedAt: collectedAt,
|
CollectedAt: collectedAt,
|
||||||
Hardware: ReanimatorHardware{
|
Hardware: ReanimatorHardware{
|
||||||
Board: convertBoard(result.Hardware.BoardInfo),
|
Board: convertBoard(result.Hardware.BoardInfo),
|
||||||
Firmware: dedupeFirmware(convertFirmware(result.Hardware.Firmware)),
|
Firmware: dedupeFirmware(convertFirmware(result.Hardware.Firmware)),
|
||||||
CPUs: dedupeCPUs(convertCPUsFromDevices(devices, collectedAt, result.Hardware.BoardInfo.SerialNumber, buildCPUMicrocodeBySocket(result.Hardware.Firmware))),
|
CPUs: dedupeCPUs(convertCPUsFromDevices(devices, collectedAt, result.Hardware.BoardInfo.SerialNumber, buildCPUMicrocodeBySocket(result.Hardware.Firmware))),
|
||||||
Memory: dedupeMemory(convertMemoryFromDevices(devices, collectedAt)),
|
Memory: dedupeMemory(convertMemoryFromDevices(devices, collectedAt)),
|
||||||
Storage: dedupeStorage(convertStorageFromDevices(devices, collectedAt)),
|
Storage: dedupeStorage(convertStorageFromDevices(devices, collectedAt)),
|
||||||
PCIeDevices: dedupePCIe(convertPCIeFromDevices(devices, collectedAt)),
|
PCIeDevices: dedupePCIe(convertPCIeFromDevices(devices, collectedAt)),
|
||||||
PowerSupplies: dedupePSUs(convertPSUsFromDevices(devices, collectedAt)),
|
PowerSupplies: dedupePSUs(convertPSUsFromDevices(devices, collectedAt)),
|
||||||
Sensors: convertSensors(result.Sensors),
|
Sensors: convertSensors(result.Sensors),
|
||||||
EventLogs: convertEventLogs(result.Events, collectedAt),
|
EventLogs: convertEventLogs(result.Events, collectedAt),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -19,7 +19,9 @@ type CollectRequest struct {
|
|||||||
Password string `json:"password,omitempty"`
|
Password string `json:"password,omitempty"`
|
||||||
Token string `json:"token,omitempty"`
|
Token string `json:"token,omitempty"`
|
||||||
TLSMode string `json:"tls_mode"`
|
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 {
|
type CollectProbeResponse struct {
|
||||||
|
|||||||
@@ -10,8 +10,10 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"html/template"
|
"html/template"
|
||||||
"io"
|
"io"
|
||||||
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
|
"sync/atomic"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"regexp"
|
"regexp"
|
||||||
"sort"
|
"sort"
|
||||||
@@ -1574,6 +1576,32 @@ func (s *Server) handleCollectStart(w http.ResponseWriter, r *http.Request) {
|
|||||||
_ = json.NewEncoder(w).Encode(job.toJobResponse("Collection job accepted"))
|
_ = 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) {
|
func (s *Server) handleCollectProbe(w http.ResponseWriter, r *http.Request) {
|
||||||
var req CollectRequest
|
var req CollectRequest
|
||||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
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
|
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)
|
ctx, cancel := context.WithTimeout(r.Context(), 20*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
@@ -1967,7 +2000,9 @@ func toCollectorRequest(req CollectRequest) collector.Request {
|
|||||||
Password: req.Password,
|
Password: req.Password,
|
||||||
Token: req.Token,
|
Token: req.Token,
|
||||||
TLSMode: req.TLSMode,
|
TLSMode: req.TLSMode,
|
||||||
PowerOnIfHostOff: req.PowerOnIfHostOff,
|
PowerOnIfHostOff: req.PowerOnIfHostOff,
|
||||||
|
StopHostAfterCollect: req.StopHostAfterCollect,
|
||||||
|
DebugPayloads: req.DebugPayloads,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -210,7 +210,6 @@ main {
|
|||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
#api-check-btn,
|
|
||||||
#api-connect-btn,
|
#api-connect-btn,
|
||||||
#api-power-on-collect-btn,
|
#api-power-on-collect-btn,
|
||||||
#api-collect-off-btn,
|
#api-collect-off-btn,
|
||||||
@@ -229,7 +228,6 @@ main {
|
|||||||
transition: background-color 0.2s ease, opacity 0.2s ease;
|
transition: background-color 0.2s ease, opacity 0.2s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
#api-check-btn:hover,
|
|
||||||
#api-connect-btn:hover,
|
#api-connect-btn:hover,
|
||||||
#api-power-on-collect-btn:hover,
|
#api-power-on-collect-btn:hover,
|
||||||
#api-collect-off-btn:hover,
|
#api-collect-off-btn:hover,
|
||||||
@@ -242,7 +240,6 @@ main {
|
|||||||
|
|
||||||
#convert-run-btn:disabled,
|
#convert-run-btn:disabled,
|
||||||
#convert-folder-btn:disabled,
|
#convert-folder-btn:disabled,
|
||||||
#api-check-btn:disabled,
|
|
||||||
#api-connect-btn:disabled,
|
#api-connect-btn:disabled,
|
||||||
#api-power-on-collect-btn:disabled,
|
#api-power-on-collect-btn:disabled,
|
||||||
#api-collect-off-btn:disabled,
|
#api-collect-off-btn:disabled,
|
||||||
@@ -252,6 +249,127 @@ main {
|
|||||||
cursor: not-allowed;
|
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 {
|
.api-connect-status {
|
||||||
margin-top: 0.75rem;
|
margin-top: 0.75rem;
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
|
|||||||
@@ -91,14 +91,18 @@ function initApiSource() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const cancelJobButton = document.getElementById('cancel-job-btn');
|
const cancelJobButton = document.getElementById('cancel-job-btn');
|
||||||
const checkButton = document.getElementById('api-check-btn');
|
const connectButton = document.getElementById('api-connect-btn');
|
||||||
const collectOffButton = document.getElementById('api-collect-off-btn');
|
const collectButton = document.getElementById('api-collect-btn');
|
||||||
const powerOnCollectButton = document.getElementById('api-power-on-collect-btn');
|
const powerOffCheckbox = document.getElementById('api-power-off');
|
||||||
const fieldNames = ['host', 'port', 'username', 'password'];
|
const fieldNames = ['host', 'port', 'username', 'password'];
|
||||||
|
|
||||||
apiForm.addEventListener('submit', (event) => {
|
apiForm.addEventListener('submit', (event) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
startCollectionFromCurrentProbe(false);
|
if (apiProbeResult && apiProbeResult.reachable) {
|
||||||
|
startCollectionWithOptions();
|
||||||
|
} else {
|
||||||
|
startApiProbe();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
if (cancelJobButton) {
|
if (cancelJobButton) {
|
||||||
@@ -106,21 +110,29 @@ function initApiSource() {
|
|||||||
cancelCollectionJob();
|
cancelCollectionJob();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (checkButton) {
|
if (connectButton) {
|
||||||
checkButton.addEventListener('click', () => {
|
connectButton.addEventListener('click', () => {
|
||||||
startApiProbe();
|
startApiProbe();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (collectOffButton) {
|
if (collectButton) {
|
||||||
collectOffButton.addEventListener('click', () => {
|
collectButton.addEventListener('click', () => {
|
||||||
clearApiPowerDecisionTimer();
|
startCollectionWithOptions();
|
||||||
startCollectionFromCurrentProbe(false);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (powerOnCollectButton) {
|
if (powerOffCheckbox) {
|
||||||
powerOnCollectButton.addEventListener('click', () => {
|
powerOffCheckbox.addEventListener('change', () => {
|
||||||
clearApiPowerDecisionTimer();
|
if (!powerOffCheckbox.checked) {
|
||||||
startCollectionFromCurrentProbe(true);
|
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();
|
renderCollectionJob();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function showConfirmModal(message, onConfirm, onCancel) {
|
||||||
|
const backdrop = document.createElement('div');
|
||||||
|
backdrop.className = 'api-confirm-modal-backdrop';
|
||||||
|
backdrop.innerHTML = `
|
||||||
|
<div class="api-confirm-modal" role="dialog" aria-modal="true">
|
||||||
|
<p>${escapeHtml(message)}</p>
|
||||||
|
<div class="api-confirm-modal-actions">
|
||||||
|
<button class="btn-cancel">Отмена</button>
|
||||||
|
<button class="btn-confirm">Да, выключить</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
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() {
|
function startApiProbe() {
|
||||||
const { isValid, payload, errors } = validateCollectForm();
|
const { isValid, payload, errors } = validateCollectForm();
|
||||||
renderFormErrors(errors);
|
renderFormErrors(errors);
|
||||||
if (!isValid) {
|
if (!isValid) {
|
||||||
renderApiConnectStatus(false, null);
|
renderApiConnectStatus(false);
|
||||||
resetApiProbeState();
|
resetApiProbeState();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -163,7 +206,7 @@ function startApiProbe() {
|
|||||||
apiConnectPayload = payload;
|
apiConnectPayload = payload;
|
||||||
resetApiProbeState();
|
resetApiProbeState();
|
||||||
setApiFormBlocked(true);
|
setApiFormBlocked(true);
|
||||||
renderApiConnectStatus(true, { ...payload, password: '***' });
|
renderApiConnectStatus(true);
|
||||||
|
|
||||||
fetch('/api/collect/probe', {
|
fetch('/api/collect/probe', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -181,7 +224,7 @@ function startApiProbe() {
|
|||||||
})
|
})
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
resetApiProbeState();
|
resetApiProbeState();
|
||||||
renderApiConnectStatus(false, null);
|
renderApiConnectStatus(false);
|
||||||
const status = document.getElementById('api-connect-status');
|
const status = document.getElementById('api-connect-status');
|
||||||
if (status) {
|
if (status) {
|
||||||
status.textContent = err.message || 'Проверка подключения не удалась';
|
status.textContent = err.message || 'Проверка подключения не удалась';
|
||||||
@@ -195,12 +238,11 @@ function startApiProbe() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function startCollectionFromCurrentProbe(powerOnIfHostOff) {
|
function startCollectionWithOptions() {
|
||||||
const { isValid, payload, errors } = validateCollectForm();
|
const { isValid, payload, errors } = validateCollectForm();
|
||||||
renderFormErrors(errors);
|
renderFormErrors(errors);
|
||||||
if (!isValid) {
|
if (!isValid) {
|
||||||
renderApiConnectStatus(false, null);
|
renderApiConnectStatus(false);
|
||||||
resetApiProbeState();
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -213,71 +255,78 @@ function startCollectionFromCurrentProbe(powerOnIfHostOff) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
clearApiPowerDecisionTimer();
|
const powerOnCheckbox = document.getElementById('api-power-on');
|
||||||
payload.power_on_if_host_off = Boolean(powerOnIfHostOff);
|
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);
|
startCollectionJob(payload);
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderApiProbeState() {
|
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 status = document.getElementById('api-connect-status');
|
||||||
const decision = document.getElementById('api-power-decision');
|
const powerOnCheckbox = document.getElementById('api-power-on');
|
||||||
const decisionText = document.getElementById('api-power-decision-text');
|
const powerOffCheckbox = document.getElementById('api-power-off');
|
||||||
if (!collectButton || !status || !decision || !decisionText) {
|
if (!connectButton || !probeOptions || !status) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
decision.classList.add('hidden');
|
|
||||||
clearApiPowerDecisionTimer();
|
|
||||||
collectButton.disabled = !apiProbeResult || !apiProbeResult.reachable;
|
|
||||||
|
|
||||||
if (!apiProbeResult || !apiProbeResult.reachable) {
|
if (!apiProbeResult || !apiProbeResult.reachable) {
|
||||||
status.textContent = 'Проверка подключения не пройдена.';
|
status.textContent = 'Проверка подключения не пройдена.';
|
||||||
status.className = 'api-connect-status error';
|
status.className = 'api-connect-status error';
|
||||||
|
probeOptions.classList.add('hidden');
|
||||||
|
connectButton.textContent = 'Подключиться';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (apiProbeResult.host_powered_on) {
|
const hostOn = apiProbeResult.host_powered_on;
|
||||||
status.textContent = apiProbeResult.message || 'Связь с BMC есть, host включен.';
|
const powerControlAvailable = apiProbeResult.power_control_available;
|
||||||
|
|
||||||
|
if (hostOn) {
|
||||||
|
status.textContent = apiProbeResult.message || 'Связь с BMC есть, host включён.';
|
||||||
status.className = 'api-connect-status success';
|
status.className = 'api-connect-status success';
|
||||||
collectButton.disabled = false;
|
} else {
|
||||||
return;
|
status.textContent = apiProbeResult.message || 'Связь с BMC есть, host выключен.';
|
||||||
|
status.className = 'api-connect-status warning';
|
||||||
}
|
}
|
||||||
|
|
||||||
status.textContent = apiProbeResult.message || 'Связь с BMC есть, host выключен.';
|
probeOptions.classList.remove('hidden');
|
||||||
status.className = 'api-connect-status warning';
|
|
||||||
if (!apiProbeResult.power_control_available) {
|
|
||||||
collectButton.disabled = false;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
decision.classList.remove('hidden');
|
// "Включить" checkbox
|
||||||
let secondsLeft = 5;
|
if (powerOnCheckbox) {
|
||||||
const updateDecisionText = () => {
|
if (hostOn) {
|
||||||
decisionText.textContent = `Если не выбрать действие, сбор начнется без включения через ${secondsLeft} сек.`;
|
// Host already on — checkbox is checked and disabled
|
||||||
};
|
powerOnCheckbox.checked = true;
|
||||||
updateDecisionText();
|
powerOnCheckbox.disabled = true;
|
||||||
apiPowerDecisionTimer = window.setInterval(() => {
|
} else {
|
||||||
secondsLeft -= 1;
|
// Host off — default: checked (will power on), enabled
|
||||||
if (secondsLeft <= 0) {
|
powerOnCheckbox.checked = true;
|
||||||
clearApiPowerDecisionTimer();
|
powerOnCheckbox.disabled = !powerControlAvailable;
|
||||||
startCollectionFromCurrentProbe(false);
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
updateDecisionText();
|
}
|
||||||
}, 1000);
|
|
||||||
|
// "Выключить" checkbox — default: unchecked
|
||||||
|
if (powerOffCheckbox) {
|
||||||
|
powerOffCheckbox.checked = false;
|
||||||
|
powerOffCheckbox.disabled = !powerControlAvailable;
|
||||||
|
}
|
||||||
|
|
||||||
|
connectButton.textContent = 'Переподключиться';
|
||||||
}
|
}
|
||||||
|
|
||||||
function resetApiProbeState() {
|
function resetApiProbeState() {
|
||||||
apiProbeResult = null;
|
apiProbeResult = null;
|
||||||
clearApiPowerDecisionTimer();
|
clearApiPowerDecisionTimer();
|
||||||
const collectButton = document.getElementById('api-connect-btn');
|
const connectButton = document.getElementById('api-connect-btn');
|
||||||
const decision = document.getElementById('api-power-decision');
|
const probeOptions = document.getElementById('api-probe-options');
|
||||||
if (collectButton) {
|
if (connectButton) {
|
||||||
collectButton.disabled = true;
|
connectButton.textContent = 'Подключиться';
|
||||||
}
|
}
|
||||||
if (decision) {
|
if (probeOptions) {
|
||||||
decision.classList.add('hidden');
|
probeOptions.classList.add('hidden');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -368,7 +417,7 @@ function renderFormErrors(errors) {
|
|||||||
summary.innerHTML = `<strong>Исправьте ошибки в форме:</strong><ul>${messages.map(msg => `<li>${escapeHtml(msg)}</li>`).join('')}</ul>`;
|
summary.innerHTML = `<strong>Исправьте ошибки в форме:</strong><ul>${messages.map(msg => `<li>${escapeHtml(msg)}</li>`).join('')}</ul>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderApiConnectStatus(isValid, payload) {
|
function renderApiConnectStatus(isValid) {
|
||||||
const status = document.getElementById('api-connect-status');
|
const status = document.getElementById('api-connect-status');
|
||||||
if (!status) {
|
if (!status) {
|
||||||
return;
|
return;
|
||||||
@@ -380,16 +429,8 @@ function renderApiConnectStatus(isValid, payload) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const payloadPreview = { ...payload };
|
status.textContent = 'Подключение...';
|
||||||
if (payloadPreview.password) {
|
status.className = 'api-connect-status info';
|
||||||
payloadPreview.password = '***';
|
|
||||||
}
|
|
||||||
if (payloadPreview.token) {
|
|
||||||
payloadPreview.token = '***';
|
|
||||||
}
|
|
||||||
|
|
||||||
status.textContent = `Payload сформирован: ${JSON.stringify(payloadPreview)}`;
|
|
||||||
status.className = 'api-connect-status success';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function clearApiConnectStatus() {
|
function clearApiConnectStatus() {
|
||||||
@@ -440,7 +481,7 @@ function startCollectionJob(payload) {
|
|||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
setApiFormBlocked(false);
|
setApiFormBlocked(false);
|
||||||
clearApiConnectStatus();
|
clearApiConnectStatus();
|
||||||
renderApiConnectStatus(false, null);
|
renderApiConnectStatus(false);
|
||||||
const status = document.getElementById('api-connect-status');
|
const status = document.getElementById('api-connect-status');
|
||||||
if (status) {
|
if (status) {
|
||||||
status.textContent = err.message || 'Ошибка запуска задачи';
|
status.textContent = err.message || 'Ошибка запуска задачи';
|
||||||
|
|||||||
@@ -76,16 +76,25 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="api-form-actions">
|
<div class="api-form-actions">
|
||||||
<button id="api-check-btn" type="button">Проверить</button>
|
<button id="api-connect-btn" type="button">Подключиться</button>
|
||||||
<button id="api-connect-btn" type="submit" disabled>Собрать</button>
|
|
||||||
</div>
|
</div>
|
||||||
<div id="api-connect-status" class="api-connect-status"></div>
|
<div id="api-connect-status" class="api-connect-status"></div>
|
||||||
<div id="api-power-decision" class="api-connect-status hidden">
|
<div id="api-probe-options" class="api-probe-options hidden">
|
||||||
<strong>Host выключен.</strong>
|
<label class="api-form-checkbox" for="api-power-on">
|
||||||
<p id="api-power-decision-text">Если не выбрать действие, сбор начнется без включения через 5 секунд.</p>
|
<input id="api-power-on" name="power_on_if_host_off" type="checkbox">
|
||||||
|
<span>Включить перед сбором</span>
|
||||||
|
</label>
|
||||||
|
<label class="api-form-checkbox" for="api-power-off">
|
||||||
|
<input id="api-power-off" name="stop_host_after_collect" type="checkbox">
|
||||||
|
<span>Выключить после сбора</span>
|
||||||
|
</label>
|
||||||
|
<div class="api-probe-options-separator"></div>
|
||||||
|
<label class="api-form-checkbox" for="api-debug-payloads">
|
||||||
|
<input id="api-debug-payloads" name="debug_payloads" type="checkbox">
|
||||||
|
<span>Сбор расширенных метрик для отладки</span>
|
||||||
|
</label>
|
||||||
<div class="api-form-actions">
|
<div class="api-form-actions">
|
||||||
<button id="api-power-on-collect-btn" type="button">Включить и собрать</button>
|
<button id="api-collect-btn" type="submit">Собрать</button>
|
||||||
<button id="api-collect-off-btn" type="button">Собирать выключенный</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
Reference in New Issue
Block a user