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:
Mikhail Chusavitin
2026-03-19 18:50:01 +03:00
parent e3ff1745fc
commit 063b08d5fb
9 changed files with 325 additions and 100 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 || 'Ошибка запуска задачи';

View File

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