From f6a10d4eacccdcd6837f844931adbe2f2f89a7b5 Mon Sep 17 00:00:00 2001 From: Mikhail Chusavitin Date: Wed, 4 Feb 2026 11:38:35 +0300 Subject: [PATCH] fix: align live flow contracts and preserve existing result state Closes #9 --- internal/server/collect_handlers_test.go | 81 +++++++++++++++++++++ internal/server/handlers.go | 4 +- web/static/js/app.js | 89 ++++++++++++++++++------ web/templates/index.html | 14 +++- 4 files changed, 163 insertions(+), 25 deletions(-) diff --git a/internal/server/collect_handlers_test.go b/internal/server/collect_handlers_test.go index e27669b..57a408f 100644 --- a/internal/server/collect_handlers_test.go +++ b/internal/server/collect_handlers_test.go @@ -8,6 +8,8 @@ import ( "strings" "testing" "time" + + "git.mchus.pro/mchus/logpile/internal/models" ) func newCollectTestServer() (*Server, *httptest.Server) { @@ -140,6 +142,85 @@ func TestCollectNotFoundAndSecretLeak(t *testing.T) { } } +func TestCollectStartPreservesCurrentResultUntilSuccess(t *testing.T) { + s, ts := newCollectTestServer() + defer ts.Close() + + existing := &models.AnalysisResult{ + Filename: "archive.tar.gz", + SourceType: models.SourceTypeArchive, + CollectedAt: time.Now().UTC(), + } + s.SetResult(existing) + + body := `{"host":"bmc-success.local","protocol":"redfish","port":443,"username":"admin","auth_type":"password","password":"secret","tls_mode":"strict"}` + resp, err := http.Post(ts.URL+"/api/collect", "application/json", bytes.NewBufferString(body)) + if err != nil { + t.Fatalf("post collect failed: %v", err) + } + defer resp.Body.Close() + + var created CollectJobResponse + if err := json.NewDecoder(resp.Body).Decode(&created); err != nil { + t.Fatalf("decode create response: %v", err) + } + + current := s.GetResult() + if current != existing { + t.Fatalf("expected current result to stay unchanged before success") + } + + status := waitForTerminalStatus(t, ts.URL, created.JobID, 4*time.Second) + if status.Status != CollectStatusSuccess { + t.Fatalf("expected success, got %q", status.Status) + } + + finalResult := s.GetResult() + if finalResult == nil { + t.Fatalf("expected result to be set on success") + } + if finalResult.SourceType != models.SourceTypeAPI { + t.Fatalf("expected api source type after success, got %q", finalResult.SourceType) + } + if finalResult.TargetHost != "bmc-success.local" { + t.Fatalf("expected target host to be updated, got %q", finalResult.TargetHost) + } +} + +func TestCollectFailedDoesNotOverwriteCurrentResult(t *testing.T) { + s, ts := newCollectTestServer() + defer ts.Close() + + existing := &models.AnalysisResult{ + Filename: "still-archive.tar.gz", + SourceType: models.SourceTypeArchive, + CollectedAt: time.Now().UTC(), + } + s.SetResult(existing) + + body := `{"host":"contains-fail.local","protocol":"redfish","port":443,"username":"admin","auth_type":"password","password":"secret","tls_mode":"strict"}` + resp, err := http.Post(ts.URL+"/api/collect", "application/json", bytes.NewBufferString(body)) + if err != nil { + t.Fatalf("post collect failed: %v", err) + } + defer resp.Body.Close() + + var created CollectJobResponse + if err := json.NewDecoder(resp.Body).Decode(&created); err != nil { + t.Fatalf("decode create response: %v", err) + } + + status := waitForTerminalStatus(t, ts.URL, created.JobID, 4*time.Second) + if status.Status != CollectStatusFailed { + t.Fatalf("expected failed, got %q", status.Status) + } + + finalResult := s.GetResult() + if finalResult != existing { + t.Fatalf("expected existing result to remain on failed job") + } +} + func waitForTerminalStatus(t *testing.T, baseURL, jobID string, timeout time.Duration) CollectJobStatusResponse { t.Helper() deadline := time.Now().Add(timeout) diff --git a/internal/server/handlers.go b/internal/server/handlers.go index e706a81..4ec6134 100644 --- a/internal/server/handlers.go +++ b/internal/server/handlers.go @@ -592,8 +592,6 @@ func (s *Server) handleCollectStart(w http.ResponseWriter, r *http.Request) { } job := s.jobManager.CreateJob(req) - s.SetResult(newAPIResult(req)) - s.SetDetectedVendor("") s.startMockCollectionJob(job.ID, req) w.Header().Set("Content-Type", "application/json") @@ -681,6 +679,8 @@ func (s *Server) startMockCollectionJob(jobID string, req CollectRequest) { s.jobManager.UpdateJobStatus(jobID, CollectStatusSuccess, 100, "") s.jobManager.AppendJobLog(jobID, "Сбор завершен") + s.SetResult(newAPIResult(req)) + s.SetDetectedVendor("") }() } diff --git a/web/static/js/app.js b/web/static/js/app.js index 8a12a3f..dd987eb 100644 --- a/web/static/js/app.js +++ b/web/static/js/app.js @@ -16,6 +16,8 @@ let collectionJobPollTimer = null; let collectionJobScenario = []; let collectionJobScenarioIndex = 0; let collectionJobLogCounter = 0; +let apiPortTouchedByUser = false; +let isAutoUpdatingApiPort = false; function initSourceType() { const sourceButtons = document.querySelectorAll('.source-switch-btn'); @@ -49,7 +51,7 @@ function initApiSource() { const authTypeField = document.getElementById('api-auth-type'); const cancelJobButton = document.getElementById('cancel-job-btn'); - const fieldNames = ['host', 'protocol', 'port', 'username', 'authType', 'password', 'token']; + const fieldNames = ['host', 'protocol', 'port', 'username', 'auth_type', 'tls_mode', 'password', 'token']; apiForm.addEventListener('submit', (event) => { event.preventDefault(); @@ -65,7 +67,6 @@ function initApiSource() { apiConnectPayload = payload; renderApiConnectStatus(true, payload); startCollectionJob(payload); - console.log('API payload prepared:', apiConnectPayload); }); if (cancelJobButton) { @@ -82,9 +83,15 @@ function initApiSource() { const eventName = field.tagName.toLowerCase() === 'select' ? 'change' : 'input'; field.addEventListener(eventName, () => { - if (fieldName === 'authType') { + if (fieldName === 'auth_type') { toggleApiAuthFields(authTypeField.value); } + if (fieldName === 'protocol') { + applyProtocolDefaultPort(field.value); + } + if (fieldName === 'port') { + handleApiPortInput(field.value); + } const { errors } = validateCollectForm(); renderFormErrors(errors); @@ -96,6 +103,7 @@ function initApiSource() { }); }); + applyProtocolDefaultPort(getApiValue('protocol')); toggleApiAuthFields(authTypeField.value); renderCollectionJob(); } @@ -105,7 +113,8 @@ function validateCollectForm() { const protocol = getApiValue('protocol'); const portRaw = getApiValue('port'); const username = getApiValue('username'); - const authType = getApiValue('authType'); + const authType = getApiValue('auth_type'); + const tlsMode = getApiValue('tls_mode'); const password = getApiValue('password'); const token = getApiValue('token'); @@ -132,7 +141,10 @@ function validateCollectForm() { } if (!['password', 'token'].includes(authType)) { - errors.authType = 'Выберите тип авторизации.'; + errors.auth_type = 'Выберите тип авторизации.'; + } + if (!['strict', 'insecure'].includes(tlsMode)) { + errors.tls_mode = 'Выберите TLS режим.'; } if (authType === 'password' && !password) { @@ -148,20 +160,18 @@ function validateCollectForm() { } const payload = { - sourceType: 'api', - connection: { - host, - protocol, - port, - username, - authType - } + host, + protocol, + port, + username, + auth_type: authType, + tls_mode: tlsMode }; if (authType === 'password') { - payload.connection.password = password; + payload.password = password; } else { - payload.connection.token = token; + payload.token = token; } return { isValid: true, errors: {}, payload }; @@ -174,7 +184,7 @@ function renderFormErrors(errors) { return; } - const errorFields = ['host', 'protocol', 'port', 'username', 'authType', 'password', 'token']; + const errorFields = ['host', 'protocol', 'port', 'username', 'auth_type', 'tls_mode', 'password', 'token']; errorFields.forEach((fieldName) => { const errorNode = apiForm.querySelector(`[data-error-for="${fieldName}"]`); if (!errorNode) { @@ -212,12 +222,12 @@ function renderApiConnectStatus(isValid, payload) { return; } - const payloadPreview = { ...payload, connection: { ...payload.connection } }; - if (payloadPreview.connection.password) { - payloadPreview.connection.password = '***'; + const payloadPreview = { ...payload }; + if (payloadPreview.password) { + payloadPreview.password = '***'; } - if (payloadPreview.connection.token) { - payloadPreview.connection.token = '***'; + if (payloadPreview.token) { + payloadPreview.token = '***'; } status.textContent = `Payload сформирован: ${JSON.stringify(payloadPreview)}`; @@ -408,6 +418,43 @@ function toggleApiAuthFields(authType) { tokenField.classList.toggle('hidden', !useToken); } +function applyProtocolDefaultPort(protocol) { + const defaults = { + redfish: '443', + ipmi: '623' + }; + const defaultPort = defaults[protocol]; + if (!defaultPort) { + return; + } + + const apiForm = document.getElementById('api-connect-form'); + if (!apiForm) { + return; + } + + const portField = apiForm.elements.namedItem('port'); + if (!portField || typeof portField.value !== 'string') { + return; + } + + const currentValue = portField.value.trim(); + if (apiPortTouchedByUser && currentValue !== '') { + return; + } + + isAutoUpdatingApiPort = true; + portField.value = defaultPort; + isAutoUpdatingApiPort = false; +} + +function handleApiPortInput(value) { + if (isAutoUpdatingApiPort) { + return; + } + apiPortTouchedByUser = value.trim() !== ''; +} + function getApiValue(fieldName) { const apiForm = document.getElementById('api-connect-form'); if (!apiForm) { diff --git a/web/templates/index.html b/web/templates/index.html index c4560ea..cf25267 100644 --- a/web/templates/index.html +++ b/web/templates/index.html @@ -66,12 +66,22 @@ + +