Add pluggable live collectors and simplify API connect form

This commit is contained in:
Mikhail Chusavitin
2026-02-04 19:00:03 +03:00
parent 60c52b18b1
commit c89ee0118f
15 changed files with 939 additions and 212 deletions

View File

@@ -13,8 +13,6 @@ let sourceType = 'archive';
let apiConnectPayload = null;
let collectionJob = null;
let collectionJobPollTimer = null;
let collectionJobScenario = [];
let collectionJobScenarioIndex = 0;
let collectionJobLogCounter = 0;
let apiPortTouchedByUser = false;
let isAutoUpdatingApiPort = false;
@@ -49,9 +47,8 @@ function initApiSource() {
return;
}
const authTypeField = document.getElementById('api-auth-type');
const cancelJobButton = document.getElementById('cancel-job-btn');
const fieldNames = ['host', 'protocol', 'port', 'username', 'auth_type', 'tls_mode', 'password', 'token'];
const fieldNames = ['host', 'port', 'username', 'password'];
apiForm.addEventListener('submit', (event) => {
event.preventDefault();
@@ -83,12 +80,6 @@ function initApiSource() {
const eventName = field.tagName.toLowerCase() === 'select' ? 'change' : 'input';
field.addEventListener(eventName, () => {
if (fieldName === 'auth_type') {
toggleApiAuthFields(authTypeField.value);
}
if (fieldName === 'protocol') {
applyProtocolDefaultPort(field.value);
}
if (fieldName === 'port') {
handleApiPortInput(field.value);
}
@@ -103,20 +94,15 @@ function initApiSource() {
});
});
applyProtocolDefaultPort(getApiValue('protocol'));
toggleApiAuthFields(authTypeField.value);
applyRedfishDefaultPort();
renderCollectionJob();
}
function validateCollectForm() {
const host = getApiValue('host');
const protocol = getApiValue('protocol');
const portRaw = getApiValue('port');
const username = getApiValue('username');
const authType = getApiValue('auth_type');
const tlsMode = getApiValue('tls_mode');
const password = getApiValue('password');
const token = getApiValue('token');
const errors = {};
@@ -124,10 +110,6 @@ function validateCollectForm() {
errors.host = 'Укажите host.';
}
if (!['redfish', 'ipmi'].includes(protocol)) {
errors.protocol = 'Выберите протокол.';
}
const port = Number(portRaw);
const isPortInteger = Number.isInteger(port);
if (!portRaw) {
@@ -140,40 +122,25 @@ function validateCollectForm() {
errors.username = 'Укажите username.';
}
if (!['password', 'token'].includes(authType)) {
errors.auth_type = 'Выберите тип авторизации.';
}
if (!['strict', 'insecure'].includes(tlsMode)) {
errors.tls_mode = 'Выберите TLS режим.';
}
if (authType === 'password' && !password) {
if (!password) {
errors.password = 'Введите пароль.';
}
if (authType === 'token' && !token) {
errors.token = 'Введите токен.';
}
if (Object.keys(errors).length > 0) {
return { isValid: false, errors, payload: null };
}
// TODO: UI для выбора протокола вернем, когда откроем IPMI коннектор.
const payload = {
host,
protocol,
protocol: 'redfish',
port,
username,
auth_type: authType,
tls_mode: tlsMode
auth_type: 'password',
tls_mode: 'insecure',
password
};
if (authType === 'password') {
payload.password = password;
} else {
payload.token = token;
}
return { isValid: true, errors: {}, payload };
}
@@ -184,7 +151,7 @@ function renderFormErrors(errors) {
return;
}
const errorFields = ['host', 'protocol', 'port', 'username', 'auth_type', 'tls_mode', 'password', 'token'];
const errorFields = ['host', 'port', 'username', 'password'];
errorFields.forEach((fieldName) => {
const errorNode = apiForm.querySelector(`[data-error-for="${fieldName}"]`);
if (!errorNode) {
@@ -246,42 +213,43 @@ function clearApiConnectStatus() {
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);
fetch('/api/collect', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
})
.then(async (response) => {
const body = await response.json().catch(() => ({}));
if (!response.ok) {
throw new Error(body.error || 'Не удалось запустить задачу');
}
collectionJob = {
id: body.job_id,
status: normalizeJobStatus(body.status || 'queued'),
progress: 0,
logs: [],
payload
};
appendJobLog(body.message || 'Задача поставлена в очередь');
renderCollectionJob();
collectionJobPollTimer = window.setInterval(() => {
pollCollectionJobStatus();
}, 1200);
})
.catch((err) => {
setApiFormBlocked(false);
clearApiConnectStatus();
renderApiConnectStatus(false, null);
const status = document.getElementById('api-connect-status');
if (status) {
status.textContent = err.message || 'Ошибка запуска задачи';
status.className = 'api-connect-status error';
}
});
}
function pollCollectionJobStatus() {
@@ -290,33 +258,63 @@ function pollCollectionJobStatus() {
return;
}
const nextState = collectionJobScenario[collectionJobScenarioIndex];
if (!nextState) {
clearCollectionJobPolling();
return;
}
fetch(`/api/collect/${encodeURIComponent(collectionJob.id)}`)
.then(async (response) => {
const body = await response.json().catch(() => ({}));
if (!response.ok) {
throw new Error(body.error || 'Не удалось получить статус задачи');
}
collectionJobScenarioIndex += 1;
collectionJob.status = nextState.status;
collectionJob.currentStep = nextState.step;
collectionJob.progress = Math.round((nextState.step / collectionJob.totalSteps) * 100);
appendJobLog(nextState.message);
renderCollectionJob();
const prevStatus = collectionJob.status;
collectionJob.status = normalizeJobStatus(body.status || collectionJob.status);
collectionJob.progress = Number.isFinite(body.progress) ? body.progress : collectionJob.progress;
collectionJob.error = body.error || '';
syncServerLogs(body.logs);
renderCollectionJob();
if (isCollectionJobTerminal(collectionJob.status)) {
clearCollectionJobPolling();
}
if (isCollectionJobTerminal(collectionJob.status)) {
clearCollectionJobPolling();
if (collectionJob.status === 'success') {
loadDataFromStatus();
} else if (collectionJob.status === 'failed' && collectionJob.error) {
appendJobLog(`Ошибка: ${collectionJob.error}`);
renderCollectionJob();
}
} else if (prevStatus !== collectionJob.status && collectionJob.status === 'running') {
appendJobLog('Сбор выполняется...');
renderCollectionJob();
}
})
.catch((err) => {
appendJobLog(`Ошибка статуса: ${err.message}`);
renderCollectionJob();
clearCollectionJobPolling();
setApiFormBlocked(false);
});
}
function cancelCollectionJob() {
if (!collectionJob || isCollectionJobTerminal(collectionJob.status)) {
return;
}
clearCollectionJobPolling();
collectionJob.status = 'Canceled';
appendJobLog('Задача отменена пользователем.');
renderCollectionJob();
fetch(`/api/collect/${encodeURIComponent(collectionJob.id)}/cancel`, {
method: 'POST'
})
.then(async (response) => {
const body = await response.json().catch(() => ({}));
if (!response.ok) {
throw new Error(body.error || 'Не удалось отменить задачу');
}
collectionJob.status = normalizeJobStatus(body.status || 'canceled');
collectionJob.progress = Number.isFinite(body.progress) ? body.progress : collectionJob.progress;
syncServerLogs(body.logs);
clearCollectionJobPolling();
renderCollectionJob();
})
.catch((err) => {
appendJobLog(`Ошибка отмены: ${err.message}`);
renderCollectionJob();
});
}
function appendJobLog(message) {
@@ -355,13 +353,13 @@ function renderCollectionJob() {
statusValue.className = `job-status-badge status-${collectionJob.status.toLowerCase()}`;
const isTerminal = isCollectionJobTerminal(collectionJob.status);
const terminalMessage = {
Success: 'Сбор завершен',
Failed: 'Сбор завершился ошибкой',
Canceled: 'Сбор отменен'
success: 'Сбор завершен',
failed: 'Сбор завершился ошибкой',
canceled: 'Сбор отменен'
}[collectionJob.status];
const progressLabel = isTerminal
? terminalMessage
: `Шаг ${collectionJob.currentStep} из ${collectionJob.totalSteps}`;
: 'Сбор данных...';
progressValue.textContent = `${collectionJob.progress}% · ${progressLabel}`;
logsList.innerHTML = collectionJob.logs.map((log) => (
@@ -373,7 +371,7 @@ function renderCollectionJob() {
}
function isCollectionJobTerminal(status) {
return ['Success', 'Failed', 'Canceled'].includes(status);
return ['success', 'failed', 'canceled'].includes(normalizeJobStatus(status));
}
function setApiFormBlocked(shouldBlock) {
@@ -400,34 +398,41 @@ function clearCollectionJobPolling() {
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');
if (!passwordField || !tokenField) {
function syncServerLogs(logs) {
if (!collectionJob || !Array.isArray(logs)) {
return;
}
if (logs.length <= collectionJob.logs.length) {
return;
}
const useToken = authType === 'token';
passwordField.classList.toggle('hidden', useToken);
tokenField.classList.toggle('hidden', !useToken);
const from = collectionJob.logs.length;
for (let i = from; i < logs.length; i += 1) {
appendJobLog(logs[i]);
}
}
function applyProtocolDefaultPort(protocol) {
const defaults = {
redfish: '443',
ipmi: '623'
};
const defaultPort = defaults[protocol];
if (!defaultPort) {
return;
}
function normalizeJobStatus(status) {
return String(status || '').trim().toLowerCase();
}
async function loadDataFromStatus() {
try {
const response = await fetch('/api/status');
const payload = await response.json();
if (!payload.loaded) {
return;
}
await loadData(payload.vendor || '', payload.filename || '');
} catch (err) {
console.error('Failed to load data after collect:', err);
}
}
function applyRedfishDefaultPort() {
const apiForm = document.getElementById('api-connect-form');
if (!apiForm) {
return;
@@ -444,7 +449,7 @@ function applyProtocolDefaultPort(protocol) {
}
isAutoUpdatingApiPort = true;
portField.value = defaultPort;
portField.value = '443';
isAutoUpdatingApiPort = false;
}