diff --git a/web/server/main.go b/web/server/main.go
index e6798bd..7f7ee73 100644
--- a/web/server/main.go
+++ b/web/server/main.go
@@ -32,9 +32,11 @@ type Config struct {
}
type AnalyzeResponse struct {
- Markdown string `json:"markdown"`
- Console string `json:"console"`
- Error string `json:"error,omitempty"`
+ Markdown string `json:"markdown"`
+ Console string `json:"console"`
+ ClusterName string `json:"clusterName,omitempty"`
+ AnalysisTimestamp string `json:"analysisTimestamp,omitempty"`
+ Error string `json:"error,omitempty"`
}
type VersionResponse struct {
@@ -183,6 +185,8 @@ func handleAnalyzeBoth(cfg *Config) http.HandlerFunc {
if err != nil {
response.Error = fmt.Sprintf("Analysis failed: %v", err)
} else {
+ response.ClusterName = report.ClusterName
+ response.AnalysisTimestamp = report.Timestamp.Format(time.RFC3339)
response.Console = reporter.GenerateConsole(report)
markdown, mdErr := reporter.GenerateMarkdown(report, cfg.TemplatePath)
if mdErr != nil {
diff --git a/web/static/index.html b/web/static/index.html
index 4516f07..53c6d1a 100644
--- a/web/static/index.html
+++ b/web/static/index.html
@@ -156,42 +156,6 @@
display: block;
}
- .tabs {
- display: flex;
- border-bottom: 2px solid #ecf0f1;
- margin-bottom: 20px;
- }
-
- .tab {
- padding: 12px 24px;
- cursor: pointer;
- border: none;
- background: none;
- color: #7f8c8d;
- font-size: 16px;
- transition: color 0.3s;
- margin: 0;
- border-radius: 0;
- }
-
- .tab:hover {
- color: #3498db;
- }
-
- .tab.active {
- color: #3498db;
- border-bottom: 2px solid #3498db;
- margin-bottom: -2px;
- }
-
- .tab-content {
- display: none;
- }
-
- .tab-content.active {
- display: block;
- }
-
.markdown-rendered {
background: #ffffff;
color: #2c3e50;
@@ -227,6 +191,23 @@
overflow-x: auto;
}
+ .report-actions {
+ display: flex;
+ gap: 10px;
+ margin-bottom: 14px;
+ }
+
+ .report-download-btn {
+ margin-top: 0;
+ padding: 10px 16px;
+ font-size: 14px;
+ background: #16a085;
+ }
+
+ .report-download-btn:hover:not(:disabled) {
+ background: #138d75;
+ }
+
pre {
background: #2c3e50;
color: #ecf0f1;
@@ -275,7 +256,7 @@
Sensor Metrics Analyzer
Privacy: Uploaded files are analyzed and not retained. Temporary files are deleted immediately after processing, and metric data is not stored.
- Disclaimer: This project is AI-generated and only a small fraction of the code and metric rules were verified by a human. Analysis results may be inaccurate and, in extreme cases, totally wrong.
+ Disclaimer: The code of this project is AI-generated and only a fraction of the code and metric rules were verified by a human. Analysis results may be inaccurate and - in extreme cases - totally wrong.
@@ -293,17 +274,11 @@
Sensor Metrics Analyzer
-
- Console Output
- Markdown Report
-
-
-
-
-
+
+ Download Markdown
+ Download HTML
+
@@ -316,14 +291,15 @@ Sensor Metrics Analyzer
const errorDiv = document.getElementById('error');
const loadingDiv = document.getElementById('loading');
const resultsDiv = document.getElementById('results');
- const consoleOutput = document.getElementById('consoleOutput');
- const markdownOutput = document.getElementById('markdownOutput');
const markdownRendered = document.getElementById('markdownRendered');
- const tabs = document.querySelectorAll('.tab');
- const tabContents = document.querySelectorAll('.tab-content');
+ const downloadMdBtn = document.getElementById('downloadMdBtn');
+ const downloadHtmlBtn = document.getElementById('downloadHtmlBtn');
const versionValue = document.getElementById('versionValue');
const lastUpdateValue = document.getElementById('lastUpdateValue');
const releaseLink = document.getElementById('releaseLink');
+ let currentMarkdownReport = '';
+ let currentRenderedReportHtml = '';
+ let currentReportBaseName = 'sensor-metrics-report';
const releasesUrl = 'https://github.com/stackrox/sensor-metrics-analyzer/releases';
releaseLink.href = releasesUrl;
@@ -406,6 +382,10 @@ Sensor Metrics Analyzer
resultsDiv.classList.remove('show');
loadingDiv.style.display = 'block';
analyzeBtn.disabled = true;
+ setDownloadButtonsEnabled(false);
+ currentMarkdownReport = '';
+ currentRenderedReportHtml = '';
+ currentReportBaseName = 'sensor-metrics-report';
const formData = new FormData();
formData.append('file', file);
@@ -421,28 +401,20 @@ Sensor Metrics Analyzer
loadingDiv.style.display = 'none';
analyzeBtn.disabled = false;
- if (data.error && !data.console && !data.markdown) {
+ if (data.error && !data.markdown) {
showError(data.error);
return;
}
- // Show results
- if (data.console) {
- consoleOutput.textContent = data.console;
- } else {
- consoleOutput.textContent = 'No console output available';
- }
-
if (data.markdown) {
- if (window.marked && typeof window.marked.parse === 'function') {
- markdownRendered.innerHTML = window.marked.parse(data.markdown);
- } else {
- markdownRendered.textContent = data.markdown;
- }
- markdownOutput.textContent = data.markdown;
+ currentMarkdownReport = data.markdown;
+ currentRenderedReportHtml = renderMarkdownToHtml(data.markdown);
+ markdownRendered.innerHTML = currentRenderedReportHtml;
+ currentReportBaseName = buildReportBaseName(data.clusterName, data.analysisTimestamp);
+ setDownloadButtonsEnabled(true);
} else {
markdownRendered.textContent = 'No markdown output available';
- markdownOutput.textContent = 'No markdown output available';
+ setDownloadButtonsEnabled(false);
}
if (data.error) {
@@ -457,29 +429,140 @@ Sensor Metrics Analyzer
}
});
- // Tab switching
- tabs.forEach(tab => {
- tab.addEventListener('click', () => {
- const targetTab = tab.dataset.tab;
-
- // Update active tab
- tabs.forEach(t => t.classList.remove('active'));
- tab.classList.add('active');
+ downloadMdBtn.addEventListener('click', () => {
+ if (!currentMarkdownReport) {
+ return;
+ }
+ downloadTextFile(currentMarkdownReport, `${currentReportBaseName}.md`, 'text/markdown;charset=utf-8');
+ });
- // Update active content
- tabContents.forEach(content => {
- content.classList.remove('active');
- if (content.id === targetTab + 'Tab') {
- content.classList.add('active');
- }
- });
- });
+ downloadHtmlBtn.addEventListener('click', () => {
+ if (!currentRenderedReportHtml) {
+ return;
+ }
+ const htmlDocument = buildHtmlReportDocument(currentRenderedReportHtml, currentReportBaseName);
+ downloadTextFile(htmlDocument, `${currentReportBaseName}.html`, 'text/html;charset=utf-8');
});
function showError(message) {
errorDiv.textContent = message;
errorDiv.style.display = 'block';
}
+
+ function setDownloadButtonsEnabled(isEnabled) {
+ downloadMdBtn.disabled = !isEnabled;
+ downloadHtmlBtn.disabled = !isEnabled;
+ }
+
+ function renderMarkdownToHtml(markdown) {
+ if (window.marked && typeof window.marked.parse === 'function') {
+ return window.marked.parse(markdown);
+ }
+ return `${escapeHtml(markdown)} `;
+ }
+
+ function buildReportBaseName(clusterName, analysisTimestamp) {
+ const sanitizedCluster = sanitizeFilePart(clusterName || 'cluster');
+ const formattedDate = formatDateForFilename(analysisTimestamp);
+ return `sensor-metrics-${sanitizedCluster}-${formattedDate}`;
+ }
+
+ function formatDateForFilename(rawTimestamp) {
+ const date = rawTimestamp ? new Date(rawTimestamp) : new Date();
+ if (Number.isNaN(date.getTime())) {
+ return 'unknown-date';
+ }
+ const year = date.getUTCFullYear();
+ const month = String(date.getUTCMonth() + 1).padStart(2, '0');
+ const day = String(date.getUTCDate()).padStart(2, '0');
+ const hours = String(date.getUTCHours()).padStart(2, '0');
+ const minutes = String(date.getUTCMinutes()).padStart(2, '0');
+ const seconds = String(date.getUTCSeconds()).padStart(2, '0');
+ return `${year}${month}${day}-${hours}${minutes}${seconds}`;
+ }
+
+ function sanitizeFilePart(value) {
+ return String(value)
+ .toLowerCase()
+ .replace(/[^a-z0-9-]+/g, '-')
+ .replace(/^-+|-+$/g, '')
+ .slice(0, 64) || 'cluster';
+ }
+
+ function downloadTextFile(content, filename, mimeType) {
+ const blob = new Blob([content], { type: mimeType });
+ const url = URL.createObjectURL(blob);
+ const link = document.createElement('a');
+ link.href = url;
+ link.download = filename;
+ document.body.appendChild(link);
+ link.click();
+ document.body.removeChild(link);
+ URL.revokeObjectURL(url);
+ }
+
+ function buildHtmlReportDocument(renderedHtml, reportBaseName) {
+ const escapedTitle = escapeHtml(reportBaseName);
+ return `
+
+
+
+
+ ${escapedTitle}
+
+
+
+
+${renderedHtml}
+
+
+`;
+ }
+
+ function escapeHtml(text) {
+ return String(text)
+ .replace(/&/g, '&')
+ .replace(//g, '>')
+ .replace(/"/g, '"')
+ .replace(/'/g, ''');
+ }