refactor: unified ingest pipeline + modular Redfish profile framework

Implement the full architectural plan: unified ingest.Service entry point
for archive and Redfish payloads, modular redfishprofile package with
composable profiles (generic, ami-family, msi, supermicro, dell,
hgx-topology), score-based profile matching with fallback expansion mode,
and profile-driven acquisition/analysis plans.

Vendor-specific logic moved out of common executors and into profile hooks.
GPU chassis lookup strategies and known storage recovery collections
(IntelVROC/HA-RAID/MRVL) now live in ResolvedAnalysisPlan, populated by
profiles at analysis time. Replay helpers read from the plan; no hardcoded
path lists remain in generic code.

Also splits redfish_replay.go into domain modules (gpu, storage, inventory,
fru, profiles) and adds full fixture/matcher/directive test coverage
including Dell, AMI, unknown-vendor fallback, and deterministic ordering.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Mikhail Chusavitin
2026-03-18 08:48:58 +03:00
parent d8d3d8c524
commit d650a6ba1c
45 changed files with 5231 additions and 1011 deletions

View File

@@ -422,7 +422,12 @@ function startCollectionJob(payload) {
id: body.job_id,
status: normalizeJobStatus(body.status || 'queued'),
progress: 0,
currentPhase: '',
etaSeconds: null,
logs: [],
activeModules: [],
moduleScores: [],
debugInfo: null,
payload
};
appendJobLog(body.message || 'Задача поставлена в очередь');
@@ -460,7 +465,12 @@ function pollCollectionJobStatus() {
const prevStatus = collectionJob.status;
collectionJob.status = normalizeJobStatus(body.status || collectionJob.status);
collectionJob.progress = Number.isFinite(body.progress) ? body.progress : collectionJob.progress;
collectionJob.currentPhase = body.current_phase || collectionJob.currentPhase || '';
collectionJob.etaSeconds = Number.isFinite(body.eta_seconds) ? body.eta_seconds : collectionJob.etaSeconds;
collectionJob.error = body.error || '';
collectionJob.activeModules = Array.isArray(body.active_modules) ? body.active_modules : collectionJob.activeModules;
collectionJob.moduleScores = Array.isArray(body.module_scores) ? body.module_scores : collectionJob.moduleScores;
collectionJob.debugInfo = body.debug_info || collectionJob.debugInfo || null;
syncServerLogs(body.logs);
renderCollectionJob();
@@ -528,9 +538,14 @@ function renderCollectionJob() {
const progressValue = document.getElementById('job-progress-value');
const etaValue = document.getElementById('job-eta-value');
const progressBar = document.getElementById('job-progress-bar');
const activeModulesBlock = document.getElementById('job-active-modules');
const activeModulesList = document.getElementById('job-active-modules-list');
const debugInfoBlock = document.getElementById('job-debug-info');
const debugSummary = document.getElementById('job-debug-summary');
const phaseTelemetryNode = document.getElementById('job-phase-telemetry');
const logsList = document.getElementById('job-logs-list');
const cancelButton = document.getElementById('cancel-job-btn');
if (!jobStatusBlock || !jobIdValue || !statusValue || !progressValue || !etaValue || !progressBar || !logsList || !cancelButton) {
if (!jobStatusBlock || !jobIdValue || !statusValue || !progressValue || !etaValue || !progressBar || !activeModulesBlock || !activeModulesList || !debugInfoBlock || !debugSummary || !phaseTelemetryNode || !logsList || !cancelButton) {
return;
}
@@ -558,6 +573,8 @@ function renderCollectionJob() {
etaValue.textContent = eta;
progressBar.style.width = `${progressPercent}%`;
progressBar.textContent = `${progressPercent}%`;
renderJobActiveModules(activeModulesBlock, activeModulesList);
renderJobDebugInfo(debugInfoBlock, debugSummary, phaseTelemetryNode);
logsList.innerHTML = [...collectionJob.logs].reverse().map((log) => (
`<li><span class="log-time">${escapeHtml(log.time)}</span><span class="log-message">${escapeHtml(log.message)}</span></li>`
@@ -568,6 +585,9 @@ function renderCollectionJob() {
}
function latestCollectionActivityMessage() {
if (collectionJob && collectionJob.currentPhase) {
return humanizeCollectionPhase(collectionJob.currentPhase);
}
if (!collectionJob || !Array.isArray(collectionJob.logs) || collectionJob.logs.length === 0) {
return 'Сбор данных...';
}
@@ -584,6 +604,9 @@ function latestCollectionActivityMessage() {
}
function latestCollectionETA() {
if (collectionJob && Number.isFinite(collectionJob.etaSeconds) && collectionJob.etaSeconds > 0) {
return formatDurationSeconds(collectionJob.etaSeconds);
}
if (!collectionJob || !Array.isArray(collectionJob.logs) || collectionJob.logs.length === 0) {
return '-';
}
@@ -649,6 +672,94 @@ function normalizeJobStatus(status) {
return String(status || '').trim().toLowerCase();
}
function humanizeCollectionPhase(phase) {
const value = String(phase || '').trim().toLowerCase();
return {
discovery: 'Discovery',
snapshot: 'Snapshot',
snapshot_postprobe_nvme: 'Snapshot NVMe post-probe',
snapshot_postprobe_collections: 'Snapshot collection post-probe',
prefetch: 'Prefetch critical endpoints',
critical_plan_b: 'Critical plan-B',
profile_plan_b: 'Profile plan-B'
}[value] || value || 'Сбор данных...';
}
function formatDurationSeconds(totalSeconds) {
const seconds = Math.max(0, Math.round(Number(totalSeconds) || 0));
if (seconds <= 0) {
return '-';
}
const minutes = Math.floor(seconds / 60);
const remainingSeconds = seconds % 60;
if (minutes === 0) {
return `${remainingSeconds}s`;
}
if (remainingSeconds === 0) {
return `${minutes}m`;
}
return `${minutes}m ${remainingSeconds}s`;
}
function renderJobActiveModules(activeModulesBlock, activeModulesList) {
const activeModules = collectionJob && Array.isArray(collectionJob.activeModules) ? collectionJob.activeModules : [];
if (activeModules.length === 0) {
activeModulesBlock.classList.add('hidden');
activeModulesList.innerHTML = '';
return;
}
activeModulesBlock.classList.remove('hidden');
activeModulesList.innerHTML = activeModules.map((module) => {
const score = Number.isFinite(module.score) ? module.score : 0;
return `<span class="job-module-chip" title="${escapeHtml(moduleTitle(module))}">
<span class="job-module-chip-name">${escapeHtml(module.name || '-')}</span>
<span class="job-module-chip-score">${escapeHtml(String(score))}</span>
</span>`;
}).join('');
}
function renderJobDebugInfo(debugInfoBlock, debugSummary, phaseTelemetryNode) {
const debug = collectionJob && collectionJob.debugInfo ? collectionJob.debugInfo : null;
if (!debug) {
debugInfoBlock.classList.add('hidden');
debugSummary.innerHTML = '';
phaseTelemetryNode.innerHTML = '';
return;
}
debugInfoBlock.classList.remove('hidden');
const throttled = debug.adaptive_throttled ? 'on' : 'off';
const prefetchEnabled = typeof debug.prefetch_enabled === 'boolean' ? String(debug.prefetch_enabled) : 'auto';
debugSummary.innerHTML = `adaptive_throttling=<strong>${escapeHtml(throttled)}</strong>, snapshot_workers=<strong>${escapeHtml(String(debug.snapshot_workers || 0))}</strong>, prefetch_workers=<strong>${escapeHtml(String(debug.prefetch_workers || 0))}</strong>, prefetch_enabled=<strong>${escapeHtml(prefetchEnabled)}</strong>`;
const phases = Array.isArray(debug.phase_telemetry) ? debug.phase_telemetry : [];
if (phases.length === 0) {
phaseTelemetryNode.innerHTML = '';
return;
}
phaseTelemetryNode.innerHTML = phases.map((item) => (
`<div class="job-phase-row">
<span class="job-phase-name">${escapeHtml(humanizeCollectionPhase(item.phase || ''))}</span>
<span class="job-phase-metric">req=${escapeHtml(String(item.requests || 0))}</span>
<span class="job-phase-metric">err=${escapeHtml(String(item.errors || 0))}</span>
<span class="job-phase-metric">avg=${escapeHtml(String(item.avg_ms || 0))}ms</span>
<span class="job-phase-metric">p95=${escapeHtml(String(item.p95_ms || 0))}ms</span>
</div>`
)).join('');
}
function moduleTitle(activeModule) {
const name = String(activeModule && activeModule.name || '').trim();
const scores = collectionJob && Array.isArray(collectionJob.moduleScores) ? collectionJob.moduleScores : [];
const full = scores.find((item) => String(item && item.name || '').trim() === name);
if (!full) {
return name;
}
const state = full.active ? 'active' : 'inactive';
return `${name}: score=${Number.isFinite(full.score) ? full.score : 0}, priority=${Number.isFinite(full.priority) ? full.priority : 0}, ${state}`;
}
async function loadDataFromStatus() {
try {
const response = await fetch('/api/status');