UNPKG

@hashgraph/solo

Version:

An opinionated CLI tool to deploy and manage private Hedera Networks.

670 lines 33.1 kB
// SPDX-License-Identifier: Apache-2.0 import AdmZip from 'adm-zip'; import fs from 'node:fs'; import path from 'node:path'; import chalk from 'chalk'; import * as constants from '../../core/constants.js'; import { PathEx } from '../../business/utils/path-ex.js'; const { green, yellow } = chalk; /** * DiagnosticsAnalyzer scans a previously-collected diagnostics output directory * (produced by `deployment diagnostics logs`) and identifies common failure * signatures without requiring a live cluster connection. * * ## Input sources * * ### 1. Solo CLI log (`solo.log`) * The Solo CLI's own Pino log file (`~/.solo/logs/solo.log` by default, or * `solo.log` found recursively under `customOutputDirectory`). Lines * matching `] ERROR:` are captured as `app-error` findings. ANSI escape * codes and `[traceId="..."]` suffixes are stripped before matching. * * ### 2. Pod describe files (`*.describe.txt`) * Written by `downloadHieroComponentLogs()` for every pod across all clusters. * These are the output of `kubectl describe pod <name> -n <namespace>` and * contain the pod's status, container states, events, and resource usage. * * Detectable errors: * * | Category | Detected keywords / conditions | * |-----------------|----------------------------------------------------------------------------------------| * | `image-pull` | `ErrImagePull`, `ImagePullBackOff`, `Back-off pulling image`, | * | | `failed to pull and unpack image`, `unexpected EOF` (truncated layer), | * | | `toomanyrequests`, `rate limit exceeded`, `429 Too Many Requests` | * | `oom` | `OOMKilled`, `out of memory`, `reason: OOMKilled` | * | `pod-readiness` | Pod `Status` field is not `Running`, or `Ready: False` is present in container status; | * | | supporting `Reason:` / `Message:` lines are captured as evidence | * * ### 2. Consensus node log archives (`*-log-config.zip`) * Written by `getNodeLogsAndConfigs()` under `~/.solo/logs/<namespace>/`. * Each zip contains the node's log and config snapshot. Only two log files * inside the archive are inspected: * * - `output/swirlds.log` — Hashgraph platform log * - `output/hgcaa.log` — Hedera application log * * Detectable errors: * * | Category | Detected keywords / conditions | * |--------------------|-------------------------------------------------------------------------------------| * | `consensus-active` | `swirlds.log` never contains the word `ACTIVE` — the node stalled during | * | | startup (e.g. stuck in `STARTING_UP`, `OBSERVING`, or `REPLAYING_EVENTS`); | * | | status-transition lines are captured as evidence | * | `log-exception` | Any line in `swirlds.log` or `hgcaa.log` matching `Exception`, `Error`, | * | | or `Caused by:` — the first matching stack-trace block (up to 14 lines) is | * | | captured as evidence | * * ## Output * All findings are written to `diagnostics-analysis.txt` inside the input * directory. Up to 10 findings are also printed to the terminal in severity * order. Duplicate findings (same category + title + source) are suppressed. */ export class DiagnosticsAnalyzer { logger; static CONSENSUS_LOG_DEFINITIONS = [ { entrySuffix: 'output/swirlds.log', displayName: 'swirlds.log', checkConsensusActive: true }, { entrySuffix: 'output/hgcaa.log', displayName: 'hgcaa.log', checkConsensusActive: false }, ]; constructor(logger) { this.logger = logger; } /** * Run the full analysis against `customOutputDirectory` (or the default * `~/.solo/logs/hiero-components-logs` when empty). * * Consensus node zip archives are looked up under * `~/.solo/logs/<namespaceName>/` when `namespaceName` is provided, or * directly under `~/.solo/logs/` otherwise. */ analyze(customOutputDirectory, namespaceName) { const hieroOutputDirectory = customOutputDirectory ? path.resolve(customOutputDirectory) : PathEx.join(constants.SOLO_LOGS_DIR, 'hiero-components-logs'); const findings = []; this.logger.showUser(`Scanning directory: ${hieroOutputDirectory}`); if (fs.existsSync(hieroOutputDirectory)) { this.analyzeDescribeFiles(hieroOutputDirectory, findings); } else { this.logger.showUser(yellow(` Pod describe directory not found, skipping: ${hieroOutputDirectory}`)); } let consensusArchiveDirectory = constants.SOLO_LOGS_DIR; if (customOutputDirectory) { consensusArchiveDirectory = path.resolve(customOutputDirectory); } else if (namespaceName) { consensusArchiveDirectory = PathEx.join(constants.SOLO_LOGS_DIR, namespaceName); } if (fs.existsSync(consensusArchiveDirectory)) { this.analyzeConsensusNodeArchives(consensusArchiveDirectory, findings); } else { this.logger.showUser(yellow(` Consensus archive directory not found, skipping: ${consensusArchiveDirectory}`)); } if (fs.existsSync(hieroOutputDirectory)) { this.analyzePodLogFiles(hieroOutputDirectory, findings); } if (fs.existsSync(hieroOutputDirectory)) { this.analyzeSoloLogFiles(hieroOutputDirectory, customOutputDirectory, findings); } else { this.logger.showUser(yellow(` Diagnostics output directory not found, skipping: ${hieroOutputDirectory}`)); } if (!fs.existsSync(hieroOutputDirectory)) { fs.mkdirSync(hieroOutputDirectory, { recursive: true }); } const reportPath = PathEx.join(hieroOutputDirectory, 'diagnostics-analysis.txt'); this.logger.showUser(`Writing report to: ${reportPath}`); const reportText = this.renderDiagnosticsFindings(findings); fs.writeFileSync(reportPath, reportText, 'utf8'); if (findings.length > 0) { this.logger.showUser(yellow(`Detected ${findings.length} potential issue(s) from diagnostics logs. Summary written to ${reportPath}`)); for (const [index, finding] of findings.slice(0, 10).entries()) { this.logger.showUser(`${index + 1}. ${finding.title} [${finding.source}]`); if (finding.evidence.length > 0) { const maxEvidenceLines = finding.category === 'log-exception' ? 8 : 4; for (const evidenceLine of finding.evidence.slice(0, maxEvidenceLines)) { this.logger.showUser(` - ${evidenceLine}`); } if (finding.evidence.length > maxEvidenceLines) { this.logger.showUser(` ... and ${finding.evidence.length - maxEvidenceLines} more evidence line(s) in diagnostics-analysis.txt`); } } } if (findings.length > 10) { this.logger.showUser(`... and ${findings.length - 10} more. See diagnostics-analysis.txt for details.`); } } else { this.logger.showUser(green(`No common failure signatures detected. Report: ${reportPath}`)); } } /** * Recursively scans `rootDirectory` for `*.describe.txt` files (one per pod) * and checks each for image-pull failures, OOM kills, and pod-readiness * problems. * * Detected errors: * - `image-pull` ErrImagePull / ImagePullBackOff / rate-limit / unexpected EOF * - `oom` OOMKilled / out of memory * - `pod-readiness` Status != Running OR Ready: False */ analyzeDescribeFiles(rootDirectory, findings) { const describeFiles = this.collectFilesRecursively(rootDirectory, (filePath) => filePath.endsWith('.describe.txt')); // Matches any image-pull error surfaced in `kubectl describe pod` output. // Covers: // - ErrImagePull / ImagePullBackOff (standard Kubernetes pull errors) // - "Back-off pulling image" (CRI back-off message in Events) // - "failed to pull and unpack image" (containerd error) // - "unexpected EOF" (truncated layer download) // - toomanyrequests / rate limit exceeded / 429 Too Many Requests // (Docker Hub and other registries throttle anonymous pulls) const imagePullPattern = /ErrImagePull|ImagePullBackOff|Back-off pulling image|failed to pull and unpack image|unexpected EOF|toomanyrequests|rate limit exceeded|429 Too Many Requests/i; // Matches out-of-memory kills. // "OOMKilled" appears in the container's LastTerminationState and in Events. // "reason: OOMKilled" is the structured field in the container status JSON. const oomPattern = /OOMKilled|out of memory|reason:\s*OOMKilled/i; this.logger.showUser(` Found ${describeFiles.length} pod describe file(s)`); for (const describeFile of describeFiles) { const relatedPath = path.relative(rootDirectory, describeFile); this.logger.showUser(` Reading: ${relatedPath}`); let content; try { content = fs.readFileSync(describeFile, 'utf8'); } catch (error) { this.logger.showUser(yellow(` Unable to read describe file ${relatedPath}: ${error.message}`)); continue; } const podName = path.basename(describeFile, '.describe.txt'); const source = path.relative(rootDirectory, describeFile); if (imagePullPattern.test(content)) { this.addDiagnosticsFinding(findings, { category: 'image-pull', title: `Image pull failure detected for pod ${podName}`, source, evidence: this.extractMatchSnippets(content, imagePullPattern, 8), }); } if (oomPattern.test(content)) { this.addDiagnosticsFinding(findings, { category: 'oom', title: `OOM-related failure detected for pod ${podName}`, source, evidence: this.extractMatchSnippets(content, oomPattern, 6), }); } // A pod is unhealthy if its top-level status is anything other than // "Running" or if any container is not ready. // // Two file formats are possible depending on how the describe file was // collected: // - Text format (kubectl describe pod): "Status: Pending" // "Ready: False" // - YAML format (kubectl get pod -o yaml): "phase: Pending" // "ready: false" // // Both are matched so the check is format-agnostic. // Reason: / Message: / reason: / message: lines (case-insensitive) are // captured for additional context. const statusMatch = content.match(/^\s*(?:Status|phase):\s+([^\n]+)/m); const status = statusMatch?.[1]?.trim().replaceAll(/^"|"$/g, '') ?? ''; const readyFalse = /^\s*[Rr]eady:\s+[Ff]alse\b/m.test(content); if ((status && status !== constants.POD_PHASE_RUNNING) || readyFalse) { const evidence = []; if (status) { evidence.push(`Status: ${status}`); } if (readyFalse) { evidence.push('Ready: False'); } evidence.push(...this.extractMatchSnippetsJoiningContinuations(content, /^\s*(Reason|Message):\s+/i, 8)); this.addDiagnosticsFinding(findings, { category: 'pod-readiness', title: `Pod not ready/running: ${podName}`, source, evidence, }); } } } /** * Recursively scans `rootDirectory` for `*.log` pod log files and checks each * for application-level ERROR lines (category: `app-error`). * * These are the raw container logs downloaded by `downloadHieroComponentLogs()` * alongside the `*.describe.txt` files. Each file is scanned for lines * containing `ERROR` and the first matching block (up to 8 lines) is captured. */ analyzePodLogFiles(rootDirectory, findings) { // Only scan logs for non-consensus components. Consensus node logs are // handled separately via the *-log-config.zip archives (which include // swirlds.log and hgcaa.log). Broad *.log would match those files too // and produce duplicate / noisy findings. const componentLogPattern = /[\\/](?:mirror|block|relay|explorer|solo-shared)[^/\\]*\.log$/i; const logFiles = this.collectFilesRecursively(rootDirectory, (filePath) => componentLogPattern.test(filePath)); // Strip Docker/containerd timestamp prefix (e.g. "2026-04-06T03:24:32.470558065Z ") before matching. const errorPattern = /\b(?:ERROR|FATAL)\b/i; this.logger.showUser(` Found ${logFiles.length} pod log file(s)`); for (const logFile of logFiles) { const relativePath = path.relative(rootDirectory, logFile); this.logger.showUser(` Reading: ${relativePath}`); let content; try { content = fs.readFileSync(logFile, 'utf8'); } catch (error) { this.logger.showUser(yellow(` Unable to read log file ${relativePath}: ${error.message}`)); continue; } // Strip leading container-runtime timestamps so the pattern matches the application log line. const strippedContent = content.replaceAll(/^\d{4}-\d{2}-\d{2}T[\d:.]+Z\s+/gm, ''); if (!errorPattern.test(strippedContent)) { continue; } const podName = path.basename(logFile, '.log'); const evidence = this.extractMatchSnippets(strippedContent, errorPattern, 8); this.addDiagnosticsFinding(findings, { category: 'app-error', title: `Application ERROR detected in pod log: ${podName}`, source: relativePath, evidence, }); } } /** * Searches for `solo.log` in `hieroOutputDirectory` (recursively) and, when * no custom output directory was specified, also checks the standard * `~/.solo/logs/solo.log` location. ERROR lines are extracted and reported * as `app-error` findings. * */ analyzeSoloLogFiles(hieroOutputDirectory, customOutputDirectory, findings) { const soloLogFiles = this.collectFilesRecursively(hieroOutputDirectory, (filePath) => path.basename(filePath) === 'solo.log'); // When using the default output path, the solo.log lives one level up at // ~/.solo/logs/solo.log — outside hieroOutputDirectory, so check it separately. if (!customOutputDirectory) { const defaultSoloLog = PathEx.join(constants.SOLO_LOGS_DIR, 'solo.log'); if (fs.existsSync(defaultSoloLog) && !soloLogFiles.includes(defaultSoloLog)) { soloLogFiles.push(defaultSoloLog); } } this.logger.showUser(` Found ${soloLogFiles.length} solo log file(s)`); const errorPattern = /\]\s+ERROR:/; // eslint-disable-next-line no-control-regex const ansiPattern = new RegExp('\u001B\\[[0-9;]*m', 'g'); const traceIdPattern = /\s+\[traceId="[^"]*"\]/g; for (const soloLogFile of soloLogFiles) { const relativePath = path.relative(hieroOutputDirectory, soloLogFile); const sourceLabel = relativePath || path.basename(soloLogFile); this.logger.showUser(` Reading: ${sourceLabel}`); let content; try { content = fs.readFileSync(soloLogFile, 'utf8'); } catch (error) { this.logger.showUser(yellow(` Unable to read solo log ${sourceLabel}: ${error.message}`)); continue; } const cleanedContent = content.replaceAll(ansiPattern, '').replaceAll(traceIdPattern, ''); if (!errorPattern.test(cleanedContent)) { continue; } const evidence = this.extractSoloLogErrorBlocks(cleanedContent, 3, 14); this.addDiagnosticsFinding(findings, { category: 'app-error', title: 'ERROR detected in solo.log', source: sourceLabel, evidence, }); } } /** * Recursively scans `archiveRootDirectory` for `*-log-config.zip` archives * produced by `getNodeLogsAndConfigs()` and inspects two log files inside * each archive: * * - `output/swirlds.log` — checked for absence of the `ACTIVE` platform * status marker (category: `consensus-active`) and for exception blocks * (category: `log-exception`). * - `output/hgcaa.log` — checked for exception blocks only * (category: `log-exception`). * * Only the first exception block per log file is captured (up to 14 lines) * to keep the report readable. */ analyzeConsensusNodeArchives(archiveRootDirectory, findings) { const archiveFiles = this.collectFilesRecursively(archiveRootDirectory, (filePath) => filePath.endsWith('-log-config.zip')); this.logger.showUser(` Found ${archiveFiles.length} consensus log archive(s)`); for (const archiveFile of archiveFiles) { const archiveName = path.basename(archiveFile); this.logger.showUser(` Unzipping: ${archiveName}`); let archive; try { archive = new AdmZip(archiveFile, { readEntries: true }); } catch (error) { this.logger.showUser(yellow(` Unable to read archive ${archiveName}: ${error.message}`)); continue; } for (const entry of archive.getEntries()) { const logDefinition = this.findConsensusLogDefinition(entry.entryName); if (!logDefinition) { continue; } this.analyzeConsensusLogEntry(archiveName, entry, logDefinition, findings); } } } findConsensusLogDefinition(entryName) { return DiagnosticsAnalyzer.CONSENSUS_LOG_DEFINITIONS.find((logDefinition) => entryName.endsWith(logDefinition.entrySuffix)); } analyzeConsensusLogEntry(archiveName, entry, logDefinition, findings) { this.logger.showUser(` Reading entry: ${entry.entryName}`); const source = `${archiveName}:${entry.entryName}`; const content = entry.getData().toString('utf8'); if (logDefinition.checkConsensusActive) { this.analyzeConsensusActiveStatus(content, source, findings); } this.analyzeExceptionBlocks(logDefinition.displayName, content, source, findings); } /** * A healthy consensus node transitions through STARTING_UP → OBSERVING → * REPLAYING_EVENTS → ACTIVE. If `ACTIVE` never appears in swirlds.log, * the node likely stalled before becoming ready for transactions. */ analyzeConsensusActiveStatus(content, source, findings) { if (/\bACTIVE\b/.test(content)) { return; } const evidence = this.extractMatchSnippets(content, /PlatformStatus|status|STARTING_UP|OBSERVING|REPLAYING_EVENTS|FREEZING|ACTIVE/i, 8); if (evidence.length === 0) { evidence.push('No ACTIVE status marker found in swirlds.log'); } this.addDiagnosticsFinding(findings, { category: 'consensus-active', title: 'Consensus node may not have reached ACTIVE status', source, evidence, }); } /** * Captures the first exception/stack-trace block from a consensus log file. */ analyzeExceptionBlocks(logDisplayName, content, source, findings) { const exceptionBlocks = this.extractExceptionBlocks(content, 1, 14); if (exceptionBlocks.length === 0) { return; } this.addDiagnosticsFinding(findings, { category: 'log-exception', title: `Exception detected in ${logDisplayName}`, source, evidence: exceptionBlocks[0].split('\n').filter((line) => line.trim().length > 0), }); } /** * Adds `finding` to `findings` unless an identical entry (same category, * title, and source) already exists. Evidence lines are deduplicated and * capped at 14 entries to keep the report compact. */ addDiagnosticsFinding(findings, finding) { const key = `${finding.category}|${finding.title}|${finding.source}`; const existingKeys = new Set(findings.map((item) => `${item.category}|${item.title}|${item.source}`)); if (existingKeys.has(key)) { return; } findings.push({ ...finding, evidence: [...new Set(finding.evidence)].filter((line) => line.trim().length > 0).slice(0, 14), }); } /** * Walks `rootDirectory` recursively and returns all file paths for which * `matcher` returns `true`. */ collectFilesRecursively(rootDirectory, matcher) { const files = []; const visit = (directory) => { const entries = fs.readdirSync(directory, { withFileTypes: true }); for (const entry of entries) { const entryPath = path.join(directory, entry.name); if (entry.isDirectory()) { visit(entryPath); continue; } if (entry.isFile() && matcher(entryPath)) { files.push(entryPath); } } }; visit(rootDirectory); return files; } /** * Extracts up to `maxBlocks` ERROR blocks from a solo.log file. * * Each block starts on a line matching `] ERROR:` and continues while * subsequent lines are indented (part of the Pino `err:` object dump). * A new log entry — any line starting with `[HH:MM:SS` — terminates the * current block. Each block is capped at `maxLinesPerBlock` lines. * * Evidence lines are returned flat (one string per line) in * `"line <N>: <content>"` format so they render consistently with other * findings. */ extractSoloLogErrorBlocks(content, maxBlocks, maxLinesPerBlock) { const lines = content.split(/\r?\n/); const errorPattern = /\]\s+ERROR:/; // New Pino log entries start with a bracketed timestamp, e.g. "[17:25:23.788]" const newEntryPattern = /^\[\d{2}:\d{2}:\d{2}\.\d{3}]/; const evidence = []; let blocksCollected = 0; for (let index = 0; index < lines.length && blocksCollected < maxBlocks; index++) { if (!errorPattern.test(lines[index])) { continue; } const blockLines = [`line ${index + 1}: ${lines[index].trim()}`]; let next = index + 1; while (next < lines.length && blockLines.length < maxLinesPerBlock) { const nextLine = lines[next]; // Stop at the next log entry or a blank line that precedes one if (newEntryPattern.test(nextLine)) { break; } if (nextLine.trim().length > 0) { blockLines.push(`line ${next + 1}: ${nextLine.trim()}`); } next++; } evidence.push(...blockLines); blocksCollected++; index = next - 1; } return evidence; } /** * Returns up to `maxMatches` lines from `content` that match `pattern`, * formatted as `"line <N>: <trimmed line>"`. * * The global (`g`) flag is stripped before matching so the RegExp lastIndex * does not interfere with repeated calls against the same pattern instance. */ extractMatchSnippets(content, pattern, maxMatches) { const snippets = []; const lines = content.split(/\r?\n/); const normalizedFlags = pattern.flags.includes('g') ? pattern.flags.replaceAll('g', '') : pattern.flags; const matcher = new RegExp(pattern.source, normalizedFlags); for (const [index, line] of lines.entries()) { if (matcher.test(line)) { snippets.push(`line ${index + 1}: ${line.trim()}`); if (snippets.length >= maxMatches) { break; } } } return snippets; } /** * Like {@link extractMatchSnippets} but joins indented continuation lines * (YAML/kubectl-describe multi-line values) into a single evidence entry. * * When a matching key line is found, any immediately following lines whose * leading whitespace is strictly greater than the key line's indentation are * appended (space-separated) before the snippet is recorded. This collapses * a multi-line `message:` value into one readable line instead of surfacing * only the truncated first line. */ extractMatchSnippetsJoiningContinuations(content, pattern, maxMatches) { const snippets = []; const lines = content.split(/\r?\n/); const normalizedFlags = pattern.flags.includes('g') ? pattern.flags.replaceAll('g', '') : pattern.flags; const matcher = new RegExp(pattern.source, normalizedFlags); for (let index = 0; index < lines.length && snippets.length < maxMatches; index++) { const line = lines[index]; if (!matcher.test(line)) { continue; } const keyIndent = (line.match(/^(\s*)/)?.[1] ?? '').length; let joined = line.trim(); // Absorb continuation lines that are indented more than the key line. let next = index + 1; while (next < lines.length) { const nextLine = lines[next]; if (nextLine.trim().length === 0) { break; } const nextIndent = (nextLine.match(/^(\s*)/)?.[1] ?? '').length; if (nextIndent <= keyIndent) { break; } joined += ' ' + nextLine.trim(); next++; } snippets.push(`line ${index + 1}: ${joined}`); } return snippets; } /** * Extracts up to `maxBlocks` exception/stack-trace blocks from `content`. * * A block starts on any line matching `Exception`, `Error`, or `Caused by:` * and continues as long as subsequent lines are stack frames (`at …`), * chained causes (`Caused by:`), or truncation markers (`… N more`). * Each block is capped at `maxLinesPerBlock` lines. */ extractExceptionBlocks(content, maxBlocks, maxLinesPerBlock) { const lines = content.split(/\r?\n/); const blocks = []; const timestampPattern = /^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}:\d{2}/; const exceptionTypeLinePattern = /^\s*(?:[a-z_][A-Za-z0-9_$]*\.)*[A-Z][A-Za-z0-9_$]*(?:Exception|Error|Throwable)(?::|\b)/; const startPattern = new RegExp(String.raw `${exceptionTypeLinePattern.source}|\b(?:Exception|Error)\b|^\s*Caused by:`); // Matches only the severity levels that indicate a real error. const errorLevelPattern = /\b(?:ERROR|FATAL|SEVERE)\b/i; for (let index = 0; index < lines.length && blocks.length < maxBlocks; index++) { if (!startPattern.test(lines[index])) { continue; } // Look back up to 5 lines to find the nearest timestamped log line and // determine its severity. Stack traces following a WARN/INFO/DEBUG line // are expected (e.g. FileAlreadyExistsException on a WARN archive attempt) // and must not be reported as findings. let precedingIsError = false; let precedingLogLine = ''; for (let scan = index - 1; scan >= 0 && scan >= index - 5; scan--) { if (timestampPattern.test(lines[scan])) { precedingLogLine = lines[scan]; precedingIsError = errorLevelPattern.test(lines[scan]); break; } } // If the nearest timestamped line exists and is not an error level, skip. if (precedingLogLine && !precedingIsError) { continue; } const blockLines = [lines[index]]; // In swirlds/hgcaa logs, the actual throwable class line can follow a // timestamped ERROR marker line. Include that marker line as context. if (index > 0 && blockLines.length < maxLinesPerBlock && (/\bERROR\s+EXCEPTION\b/i.test(lines[index - 1]) || (timestampPattern.test(lines[index - 1]) && errorLevelPattern.test(lines[index - 1]))) && !blockLines.includes(lines[index - 1])) { blockLines.unshift(lines[index - 1]); } let next = index + 1; while (next < lines.length && blockLines.length < maxLinesPerBlock) { const line = lines[next]; if (line.trim().length === 0 || timestampPattern.test(line)) { break; } if (/^\s+at\s+/.test(line) || /^\s*Caused by:/.test(line) || /^\s*Suppressed:/.test(line) || /^\s*\.\.\.\s+\d+\s+more/.test(line) || exceptionTypeLinePattern.test(line)) { blockLines.push(line); next++; continue; } break; } blocks.push(blockLines.join('\n')); index = next - 1; } return blocks; } /** * Renders all findings into a human-readable plain-text report, sorted by * severity (image-pull → oom → pod-readiness → consensus-active → * log-exception). Returns the report as a string ready to be written to * `diagnostics-analysis.txt`. */ renderDiagnosticsFindings(findings) { const severityOrder = { 'image-pull': 1, oom: 2, 'pod-readiness': 3, 'consensus-active': 4, 'log-exception': 5, 'app-error': 6, }; const categoryLabel = { 'image-pull': 'Image Pull', oom: 'Out Of Memory', 'pod-readiness': 'Pod Readiness', 'consensus-active': 'Consensus Active State', 'log-exception': 'Exception Stack', 'app-error': 'Application Error', }; const lines = ['Solo Diagnostics Analysis Report', `Generated: ${new Date().toISOString()}`, '']; if (findings.length === 0) { lines.push('No common failure signatures were detected.'); return lines.join('\n'); } const orderedFindings = []; for (const finding of findings) { let insertionIndex = orderedFindings.length; for (const [index, existingFinding] of orderedFindings.entries()) { if (severityOrder[finding.category] < severityOrder[existingFinding.category]) { insertionIndex = index; break; } } orderedFindings.splice(insertionIndex, 0, finding); } lines.push(`Detected ${orderedFindings.length} potential issue(s):`, ''); for (const [index, finding] of orderedFindings.entries()) { lines.push(`${index + 1}. [${categoryLabel[finding.category]}] ${finding.title}`, ` Source: ${finding.source}`); if (finding.evidence.length > 0) { lines.push(' Evidence:'); for (const evidenceLine of finding.evidence) { lines.push(` - ${evidenceLine}`); } } lines.push(''); } return lines.join('\n'); } } //# sourceMappingURL=diagnostics-analyzer.js.map