diff --git a/web/static/css/style.css b/web/static/css/style.css index d4181a8..318b8d8 100644 --- a/web/static/css/style.css +++ b/web/static/css/style.css @@ -168,6 +168,11 @@ main { margin-top: 0.9rem; } +#api-connect-form.is-disabled { + opacity: 0.6; + pointer-events: none; +} + #api-connect-btn { background: #3498db; color: white; @@ -194,6 +199,108 @@ main { color: #dc3545; } +.job-status { + margin-top: 1rem; + border: 1px solid #d0d7de; + border-radius: 8px; + padding: 1rem; + background: #f8fafc; +} + +.job-status-header { + display: flex; + justify-content: space-between; + align-items: center; + gap: 0.75rem; + margin-bottom: 0.75rem; +} + +.job-status-header h4 { + margin: 0; + color: #2c3e50; +} + +#cancel-job-btn { + background: #dc3545; + color: #fff; + border: none; + border-radius: 4px; + padding: 0.45rem 0.75rem; + cursor: pointer; +} + +#cancel-job-btn:disabled { + background: #9ca3af; + cursor: default; +} + +.job-status-meta { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(230px, 1fr)); + gap: 0.5rem 0.75rem; + margin-bottom: 0.75rem; + font-size: 0.9rem; +} + +.meta-label { + color: #64748b; + font-weight: 600; +} + +.job-status-badge { + display: inline-flex; + align-items: center; + border-radius: 999px; + padding: 0.2rem 0.6rem; + font-size: 0.8rem; + font-weight: 600; +} + +.job-status-badge.status-queued, +.job-status-badge.status-running { + background: #eff6ff; + color: #1d4ed8; +} + +.job-status-badge.status-success { + background: #ecfdf3; + color: #15803d; +} + +.job-status-badge.status-failed { + background: #fef2f2; + color: #b91c1c; +} + +.job-status-badge.status-canceled { + background: #f1f5f9; + color: #334155; +} + +.job-status-logs ul { + list-style: none; + margin-top: 0.35rem; + border-top: 1px solid #e5e7eb; +} + +.job-status-logs li { + display: grid; + grid-template-columns: 90px 1fr; + gap: 0.5rem; + padding: 0.45rem 0; + border-bottom: 1px solid #eef2f7; + font-size: 0.85rem; +} + +.log-time { + color: #64748b; + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; +} + +.log-message { + color: #334155; +} + #upload-status { margin-top: 1rem; text-align: center; diff --git a/web/static/js/app.js b/web/static/js/app.js index 53da0b1..8a12a3f 100644 --- a/web/static/js/app.js +++ b/web/static/js/app.js @@ -11,6 +11,11 @@ document.addEventListener('DOMContentLoaded', () => { let sourceType = 'archive'; let apiConnectPayload = null; +let collectionJob = null; +let collectionJobPollTimer = null; +let collectionJobScenario = []; +let collectionJobScenarioIndex = 0; +let collectionJobLogCounter = 0; function initSourceType() { const sourceButtons = document.querySelectorAll('.source-switch-btn'); @@ -43,23 +48,32 @@ 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']; apiForm.addEventListener('submit', (event) => { event.preventDefault(); const { isValid, payload, errors } = validateCollectForm(); renderFormErrors(errors); - renderApiConnectStatus(isValid, payload); if (!isValid) { + renderApiConnectStatus(false, null); apiConnectPayload = null; return; } apiConnectPayload = payload; + renderApiConnectStatus(true, payload); + startCollectionJob(payload); console.log('API payload prepared:', apiConnectPayload); }); + if (cancelJobButton) { + cancelJobButton.addEventListener('click', () => { + cancelCollectionJob(); + }); + } + fieldNames.forEach((fieldName) => { const field = apiForm.elements.namedItem(fieldName); if (!field) { @@ -75,10 +89,15 @@ function initApiSource() { const { errors } = validateCollectForm(); renderFormErrors(errors); clearApiConnectStatus(); + + if (collectionJob && isCollectionJobTerminal(collectionJob.status)) { + resetCollectionJobState(); + } }); }); toggleApiAuthFields(authTypeField.value); + renderCollectionJob(); } function validateCollectForm() { @@ -215,6 +234,167 @@ function clearApiConnectStatus() { status.className = 'api-connect-status'; } +function startCollectionJob(payload) { + resetCollectionJobState(); + + const totalSteps = 4; + const finalStatus = Math.random() < 0.2 ? 'Failed' : 'Success'; + const jobId = `job-${Date.now()}-${Math.floor(Math.random() * 1000)}`; + + collectionJob = { + id: jobId, + status: 'Queued', + progress: 0, + currentStep: 0, + totalSteps, + logs: [], + payload + }; + + collectionJobScenario = [ + { status: 'Running', step: 1, message: 'Соединение с BMC установлено.' }, + { status: 'Running', step: 2, message: 'Собираем базовую конфигурацию сервера.' }, + { status: 'Running', step: 3, message: 'Собираем системные журналы и события.' }, + { + status: finalStatus, + step: 4, + message: finalStatus === 'Success' + ? 'Сбор завершен. Данные готовы к следующему этапу.' + : 'Сбор завершился с ошибкой: часть данных недоступна.' + } + ]; + collectionJobScenarioIndex = 0; + + appendJobLog('Задача создана и добавлена в очередь.'); + setApiFormBlocked(true); + renderCollectionJob(); + + collectionJobPollTimer = window.setInterval(() => { + pollCollectionJobStatus(); + }, 1200); +} + +function pollCollectionJobStatus() { + if (!collectionJob || isCollectionJobTerminal(collectionJob.status)) { + clearCollectionJobPolling(); + return; + } + + const nextState = collectionJobScenario[collectionJobScenarioIndex]; + if (!nextState) { + clearCollectionJobPolling(); + return; + } + + collectionJobScenarioIndex += 1; + collectionJob.status = nextState.status; + collectionJob.currentStep = nextState.step; + collectionJob.progress = Math.round((nextState.step / collectionJob.totalSteps) * 100); + appendJobLog(nextState.message); + renderCollectionJob(); + + if (isCollectionJobTerminal(collectionJob.status)) { + clearCollectionJobPolling(); + } +} + +function cancelCollectionJob() { + if (!collectionJob || isCollectionJobTerminal(collectionJob.status)) { + return; + } + + clearCollectionJobPolling(); + collectionJob.status = 'Canceled'; + appendJobLog('Задача отменена пользователем.'); + renderCollectionJob(); +} + +function appendJobLog(message) { + if (!collectionJob) { + return; + } + + const time = new Date().toLocaleTimeString('ru-RU', { hour12: false }); + collectionJob.logs.push({ + id: ++collectionJobLogCounter, + time, + message + }); +} + +function renderCollectionJob() { + const jobStatusBlock = document.getElementById('api-job-status'); + const jobIdValue = document.getElementById('job-id-value'); + const statusValue = document.getElementById('job-status-value'); + const progressValue = document.getElementById('job-progress-value'); + const logsList = document.getElementById('job-logs-list'); + const cancelButton = document.getElementById('cancel-job-btn'); + if (!jobStatusBlock || !jobIdValue || !statusValue || !progressValue || !logsList || !cancelButton) { + return; + } + + if (!collectionJob) { + jobStatusBlock.classList.add('hidden'); + setApiFormBlocked(false); + return; + } + + jobStatusBlock.classList.remove('hidden'); + jobIdValue.textContent = collectionJob.id; + statusValue.textContent = collectionJob.status; + statusValue.className = `job-status-badge status-${collectionJob.status.toLowerCase()}`; + const isTerminal = isCollectionJobTerminal(collectionJob.status); + const terminalMessage = { + Success: 'Сбор завершен', + Failed: 'Сбор завершился ошибкой', + Canceled: 'Сбор отменен' + }[collectionJob.status]; + const progressLabel = isTerminal + ? terminalMessage + : `Шаг ${collectionJob.currentStep} из ${collectionJob.totalSteps}`; + progressValue.textContent = `${collectionJob.progress}% · ${progressLabel}`; + + logsList.innerHTML = collectionJob.logs.map((log) => ( + `
  • ${escapeHtml(log.time)}${escapeHtml(log.message)}
  • ` + )).join(''); + + cancelButton.disabled = isTerminal; + setApiFormBlocked(!isTerminal); +} + +function isCollectionJobTerminal(status) { + return ['Success', 'Failed', 'Canceled'].includes(status); +} + +function setApiFormBlocked(shouldBlock) { + const apiForm = document.getElementById('api-connect-form'); + if (!apiForm) { + return; + } + + apiForm.classList.toggle('is-disabled', shouldBlock); + Array.from(apiForm.elements).forEach((field) => { + field.disabled = shouldBlock; + }); +} + +function clearCollectionJobPolling() { + if (!collectionJobPollTimer) { + return; + } + + window.clearInterval(collectionJobPollTimer); + collectionJobPollTimer = null; +} + +function resetCollectionJobState() { + clearCollectionJobPolling(); + collectionJob = null; + collectionJobScenario = []; + collectionJobScenarioIndex = 0; + renderCollectionJob(); +} + function toggleApiAuthFields(authType) { const passwordField = document.getElementById('api-password-field'); const tokenField = document.getElementById('api-token-field'); diff --git a/web/templates/index.html b/web/templates/index.html index 9ee54d4..c4560ea 100644 --- a/web/templates/index.html +++ b/web/templates/index.html @@ -92,6 +92,25 @@
    + +