Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
80 changes: 80 additions & 0 deletions data/templates/report/report.css
Original file line number Diff line number Diff line change
Expand Up @@ -44,4 +44,84 @@ table { font-size: 8pt; }

.closebtn:hover {
color: black;
}

/* Split-pane layout for etcd page */
.etcd-panel-left {
width: 70%;
min-width: 70%;
padding-right: 12px;
}
.etcd-panel-divider {
width: 6px;
min-width: 6px;
cursor: col-resize;
background: #dee2e6;
transition: background 0.15s;
}
.etcd-panel-divider:hover {
background: #adb5bd;
}
.etcd-panel-right {
width: 30%;
min-width: 30%;
padding-left: 12px;
}
.etcd-chart-card {
background: #fff;
border: 1px solid #dee2e6;
border-radius: 6px;
padding: 16px;
margin-bottom: 16px;
}
.etcd-chart-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
}
.etcd-chart-card h6 {
margin: 0;
color: #495057;
font-size: 0.85rem;
font-weight: 600;
}
.etcd-chart-toolbar {
display: flex;
gap: 4px;
}
.etcd-chart-btn {
background: #f8f9fa;
border: 1px solid #dee2e6;
border-radius: 4px;
padding: 2px 8px;
font-size: 0.75rem;
cursor: pointer;
color: #495057;
line-height: 1.4;
}
.etcd-chart-btn:hover {
background: #e9ecef;
}
.etcd-chart-overlay {
position: fixed;
top: 0; left: 0; right: 0; bottom: 0;
background: rgba(0,0,0,0.6);
z-index: 9999;
display: flex;
align-items: center;
justify-content: center;
}
.etcd-chart-overlay-content {
background: #fff;
border-radius: 8px;
padding: 20px;
width: 90vw;
max-height: 90vh;
}
.etcd-chart-overlay-header {
display: flex;
justify-content: flex-end;
gap: 4px;
margin-bottom: 8px;
}
213 changes: 204 additions & 9 deletions data/templates/report/report.html
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,10 @@
<!-- Load required Bootstrap and BootstrapVue CSS -->
<script src="https://cdn.jsdelivr.net/npm/vue@2/dist/vue.js"></script>
<script src="https://unpkg.com/axios/dist/axios.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.9/dist/chart.umd.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/chartjs-adapter-date-fns@3.0.0/dist/chartjs-adapter-date-fns.bundle.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/hammerjs@2.0.8/hammer.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/chartjs-plugin-zoom@2.2.0/dist/chartjs-plugin-zoom.min.js"></script>
Comment on lines 11 to +16

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can we decrease the amount of dependencies or build it together, preferably with what we are using only, serving as static ?

Comment on lines +13 to +16

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

file="data/templates/report/report.html"

echo "== Snippet around scripts (lines 1-60) =="
sed -n '1,60p' "$file" | nl -ba | sed -n '1,80p'

echo "== Snippet around lines 280-360 =="
sed -n '280,360p' "$file" | nl -ba | sed -n '1,120p'

echo "== Find chart lifecycle bits =="
rg -n "chartInstances|renderEtcdCharts|destroy\(|new Chart\(|getContext\(|canvas|update\(" "$file" || true

echo "== Snippet around renderEtcdCharts (best-effort) =="
# try to locate function and print nearby
lnum="$(rg -n "renderEtcdCharts" "$file" | head -n1 | cut -d: -f1 || true)"
if [[ -n "${lnum}" ]]; then
  start=$((lnum-80))
  end=$((lnum+180))
  sed -n "${start},${end}p" "$file" | nl -ba | sed -n "1,200p"
fi

echo "== Snippet around lines 780-980 =="
sed -n '780,980p' "$file" | nl -ba | sed -n '1,220p'

Repository: redhat-openshift-ecosystem/opct

Length of output: 178


🏁 Script executed:

#!/bin/bash
set -euo pipefail

file="data/templates/report/report.html"

echo "== File exists? =="
if [[ -f "$file" ]]; then echo "FOUND: $file"; else echo "MISSING: $file"; exit 0; fi

echo "== Snippet around scripts (start of file) =="
sed -n '1,60p' "$file" | sed '=' | sed 'N; s/\n/ /'

echo "== Snippet around lines 280-360 =="
sed -n '280,360p' "$file" | sed '=' | sed 'N; s/\n/ /'

echo "== Search chart lifecycle markers =="
rg -n "chartInstances|renderEtcdCharts|destroy\(|new Chart\(" "$file" || true

echo "== Snippet around renderEtcdCharts =="
lnum="$(rg -n "renderEtcdCharts" "$file" | head -n1 | cut -d: -f1 || true)"
if [[ -n "${lnum}" ]]; then
  start=$((lnum-80))
  end=$((lnum+200))
  sed -n "${start},${end}p" "$file" | sed '=' | sed 'N; s/\n/ /'
fi

echo "== Snippet around ~lines 780-980 (ETCD chart area best-effort) =="
sed -n '780,980p' "$file" | sed '=' | sed 'N; s/\n/ /'

Repository: redhat-openshift-ecosystem/opct

Length of output: 32703


🏁 Script executed:

#!/bin/bash
set -euo pipefail
file="data/templates/report/report.html"

echo "== Calls to changeMenuCleanup() =="
rg -n "changeMenuCleanup\(" "$file" || true

echo "== Any destroy() usage on chartInstances/base charts =="
rg -n "chartInstances\]|chartInstances|\.destroy\(\)|destroy\(\)" "$file" || true

echo "== Any loop over chartInstances with destroy =="
rg -n "for .*chartInstances|Object\.values\(this\.chartInstances\)|Object\.keys\(this\.chartInstances\)" "$file" || true

Repository: redhat-openshift-ecosystem/opct

Length of output: 806


Fix supply-chain risk and Chart.js lifecycle issues in the report template

  • data/templates/report/report.html: loads Chart.js/adapter/zoom/hammer from public CDNs without integrity/crossorigin; add SRI + crossorigin or bundle locally.
  • renderEtcdCharts() (~877+): resets this.chartInstances = {} but never calls destroy() on existing Chart.js instances; repeated/late async axios responses can recreate charts on the same canvases, leaking memory and causing duplicated charts—destroy prior instances before clearing and ignore stale responses (e.g., render token).
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@data/templates/report/report.html` around lines 13 - 16, The template loads
Chart.js, adapter-date-fns, hammerjs and chartjs-plugin-zoom from public CDNs
without integrity/crossorigin—either add Subresource Integrity (integrity
attributes) plus crossorigin="anonymous" to those script tags in report.html or
switch to locally bundled copies to mitigate supply-chain risk; additionally, in
renderEtcdCharts() where this.chartInstances is reset, first iterate existing
Chart instances in this.chartInstances and call their destroy() before clearing
the map, and add a per-call render token (or cancelation/sequence check) so
async axios responses verify the token/sequence before creating/updating charts
to ignore stale responses and avoid leaked/duplicated charts.

<link type="text/css" rel="stylesheet" href="https://unpkg.com/bootstrap/dist/css/bootstrap.min.css" />
<link type="text/css" rel="stylesheet" href="https://unpkg.com/bootstrap-vue@latest/dist/bootstrap-vue.min.css" />

Expand All @@ -21,12 +25,11 @@
<!-- Tab manager -->
<div class="tab">
<button class="tablinks" onclick="openTab(event, 'OPCT')" id="tabDefault">OPCT</button>
<button class="tablinks" onclick="openTab(event, 'CAMGI')">CAMGI</button>
<button class="tablinks" onclick="window.open('./metrics/metrics.html', '_blank')">Dashboard</button>
<button class="tablinks" onclick="openTab(event, 'Tests')">Tests</button>
<button class="tablinks" onclick="openTab(event, 'MGEvents')">Events</button>
<!-- <button class="tablinks" onclick="openExternalTab(event, '/must-gather/event-filter.html')">Events</button> -->
<!-- <button class="tablinks" onclick="openTab(event, 'Metrics')">Metrics</button> -->
<button class="tablinks" onclick="openExternalTab(event, '/metrics')">Metrics</button>
<button class="tablinks" onclick="openTab(event, 'CAMGI')">CAMGI</button>
</div>

<!-- Tab content: OPCT -->
Expand Down Expand Up @@ -98,8 +101,19 @@
<div v-if="isLoading">
<div>Loading... ({{ loadingMessage }})</div>
</div>
<div id="main-content" class="overflow-auto"><span v-html="menuTitle"></span></div>
<div id="main-content" class="overflow-auto"><span v-html="menuBody"></span></div>
<div class="overflow-auto"><span v-html="menuTitle"></span></div>
<div v-if="menuBodyRight" id="split-container" class="d-flex" style="height: calc(100vh - 120px)">
<div id="panel-left" class="etcd-panel-left overflow-auto">
<span v-html="menuBody"></span>
</div>
<div id="panel-divider" class="etcd-panel-divider"></div>
<div id="panel-right" class="etcd-panel-right overflow-auto">
<span v-html="menuBodyRight"></span>
</div>
</div>
<div v-else class="overflow-auto">
<span v-html="menuBody"></span>
</div>
</div>
</div>
</div>
Expand Down Expand Up @@ -201,6 +215,13 @@
isLoading: true,
loadingMessage: '',
showMetrics: false,
menuBodyRight: '',
chartColors: ['#4e79a7', '#f28e2b', '#e15759', '#76b7b2', '#59a14f', '#edc948', '#b07aa1', '#ff9da7'],
chartInstances: {},
etcdCharts: [
{ id: "etcd-chart-0", path: "./metrics/query_range-etcd-disk-fsync-wal-duration-p99.json.gz.json", title: "etcd fsync WAL p99", unit: "ms", convertToMs: true },
{ id: "etcd-chart-1", path: "./metrics/query_range-etcd-disk-fsync-db-duration-p99.json.gz.json", title: "etcd fsync DB p99", unit: "ms", convertToMs: true },
],
};
},
methods: {
Expand Down Expand Up @@ -288,6 +309,15 @@
changeMenuCleanup() {
this.menuTitle = '';
this.menuBody = '';
this.menuBodyRight = '';
// Destroy existing Chart.js instances to prevent memory leaks
Object.values(this.chartInstances).forEach(chart => chart.destroy());
this.chartInstances = {};
this._chartRenderToken = (this._chartRenderToken || 0) + 1;
var left = document.getElementById('panel-left');
if (left) { left.style.width = ''; left.style.minWidth = ''; }
var right = document.getElementById('panel-right');
if (right) { right.style.width = ''; right.style.minWidth = ''; }
Comment thread
coderabbitai[bot] marked this conversation as resolved.
},
createTableRevHTML(table=[]) {
htmlTable = `<table class="table table-sm table-striped font-monospace"><tbody>`
Expand Down Expand Up @@ -760,10 +790,8 @@
changeMenuETCD() {
this.menuTitle = `<h1>etcd information</h1>`
this.menuBody = this.pageHeadline
this.menuBody += "<p>Information extracted from etcd logs.</>"
this.menuBody += "<p>Information extracted from etcd logs.</p>"

// TODO#1 create checks / summary in the top of page
// TODO#2 implement checks rules
if (this.report.provider.mustGatherInfo.ErrorEtcdLogs.ErrorCounters !== undefined) {
table = this.extractErrorCountersToTable(this.report.provider.mustGatherInfo.ErrorEtcdLogs.ErrorCounters)
table.header = "Counters for error pattern in etcd logs"
Expand All @@ -775,7 +803,6 @@
"Aggregated pod logs 'apply request took too long'",
this.report.provider.mustGatherInfo.ErrorEtcdLogs)
}
// by pod
for (let i in this.report.provider.mustGatherInfo.NamespaceErrors) {
if (this.report.provider.mustGatherInfo.NamespaceErrors[i].ErrorEtcdLogs !== undefined) {
podName = this.report.provider.mustGatherInfo.NamespaceErrors[i].Pod
Expand All @@ -785,6 +812,144 @@
this.report.provider.mustGatherInfo.NamespaceErrors[i].ErrorEtcdLogs)
}
}

if (this.report.summary.features.hasMetricsData) {
this.menuBodyRight = '<div class="alert alert-info" style="margin-bottom: 20px;">' +
'<strong>📊 More Metrics:</strong> ' +
'<a href="./metrics/metrics.html" target="_blank" style="text-decoration: underline;">Open full dashboard</a> ' +
'to view all 6 charts in an interactive grid dashboard with click-to-expand functionality.' +
'</div>'
for (let chart of this.etcdCharts) {
this.menuBodyRight += `<div class="etcd-chart-card" id="card-` + chart.id + `">` +
`<div class="etcd-chart-header"><h6>` + chart.title + `</h6>` +
`<div class="etcd-chart-toolbar">` +
`<button class="etcd-chart-btn" onclick="app_opct.resetChartZoom('` + chart.id + `')" title="Reset zoom">Reset</button>` +
`<button class="etcd-chart-btn" onclick="app_opct.expandChart('` + chart.id + `')" title="Expand chart">&#x26F6;</button>` +
`</div></div>` +
`<canvas id="` + chart.id + `"></canvas></div>`
}
this.$nextTick(() => { this.renderEtcdCharts(); });
}
},
resetChartZoom(chartId) {
let instance = this.chartInstances[chartId];
if (instance) instance.resetZoom();
},
expandChart(chartId) {
let instance = this.chartInstances[chartId];
if (!instance) return;
let overlay = document.createElement('div');
overlay.className = 'etcd-chart-overlay';
overlay.innerHTML = `<div class="etcd-chart-overlay-content">` +
`<div class="etcd-chart-overlay-header">` +
`<button class="etcd-chart-btn" id="overlay-reset" title="Reset zoom">Reset</button>` +
`<button class="etcd-chart-btn" id="overlay-close" title="Close">&times;</button>` +
`</div><canvas id="overlay-canvas"></canvas></div>`;
document.body.appendChild(overlay);
let canvas = document.getElementById('overlay-canvas');
let expanded = new Chart(canvas, {
type: 'line',
data: JSON.parse(JSON.stringify(instance.data)),
options: Object.assign({}, JSON.parse(JSON.stringify(instance.options)), {
aspectRatio: 1.8,
plugins: {
legend: { position: 'bottom', labels: { boxWidth: 12, font: { size: 12 } } },
tooltip: { mode: 'index', intersect: false },
zoom: {
zoom: { drag: { enabled: true, backgroundColor: 'rgba(78,121,167,0.15)', borderColor: '#4e79a7', borderWidth: 1 }, mode: 'x' },
pan: { enabled: true, mode: 'x' },
},
},
}),
});
// re-parse date objects lost in JSON serialization
expanded.data.datasets.forEach((ds, i) => {
ds.data = instance.data.datasets[i].data.map(p => ({ x: new Date(p.x), y: p.y }));
ds.borderColor = instance.data.datasets[i].borderColor;
ds.backgroundColor = instance.data.datasets[i].backgroundColor;
});
expanded.update();
document.getElementById('overlay-close').onclick = function() { expanded.destroy(); document.body.removeChild(overlay); };
document.getElementById('overlay-reset').onclick = function() { expanded.resetZoom(); };
overlay.onclick = function(e) { if (e.target === overlay) { expanded.destroy(); document.body.removeChild(overlay); } };
},
renderEtcdCharts() {
// Generate render token to ignore stale axios responses
const renderToken = (this._chartRenderToken || 0) + 1;
this._chartRenderToken = renderToken;
// Destroy existing Chart.js instances before creating new ones
Object.values(this.chartInstances).forEach(chart => chart.destroy());
this.chartInstances = {};
for (let chart of this.etcdCharts) {
axios.get(chart.path)
.then(resp => {
// Ignore stale responses from previous render calls
if (renderToken !== this._chartRenderToken) return;
// Parse Prometheus JSON format (query_range API response)
let promResponse = resp.data;
if (!promResponse.data || !promResponse.data.result) {
throw new Error('Invalid Prometheus response format');
}

let datasets = promResponse.data.result.map((result, i) => {
// Extract label from metric (first non-__name__ key)
let labelKey = Object.keys(result.metric).find(k => k !== '__name__') || 'instance';
let labelValue = result.metric[labelKey] || 'unknown';

// Convert Prometheus values [timestamp, "value"] to Chart.js format
let dataPoints = result.values.map(v => ({
x: new Date(v[0] * 1000), // Unix timestamp to JS Date
y: chart.convertToMs ? parseFloat(v[1]) * 1000 : parseFloat(v[1]) // Convert seconds to ms if needed
}));

return {
label: labelValue,
data: dataPoints,
borderColor: this.chartColors[i % this.chartColors.length],
backgroundColor: 'transparent',
borderWidth: 1.5,
pointRadius: 0,
tension: 0.3,
};
});
let canvas = document.getElementById(chart.id);
if (!canvas) return;
this.chartInstances[chart.id] = new Chart(canvas, {
type: 'line',
data: { datasets: datasets },
options: {
responsive: true,
maintainAspectRatio: true,
aspectRatio: 2.2,
interaction: { mode: 'index', intersect: false },
plugins: {
legend: { position: 'bottom', labels: { boxWidth: 12, font: { size: 11 } } },
tooltip: { mode: 'index', intersect: false },
zoom: {
zoom: { drag: { enabled: true, backgroundColor: 'rgba(78,121,167,0.15)', borderColor: '#4e79a7', borderWidth: 1 }, mode: 'x' },
pan: { enabled: true, mode: 'x' },
},
},
scales: {
x: { type: 'time', time: { tooltipFormat: 'yyyy-MM-dd HH:mm' }, ticks: { maxTicksAllowed: 8, font: { size: 10 } } },
y: {
beginAtZero: false,
ticks: { font: { size: 10 } },
title: {
display: true,
text: chart.unit,
font: { size: 11, weight: 'bold' }
}
},
},
},
});
})
.catch(() => {
let el = document.getElementById(chart.id);
if (el) el.parentElement.innerHTML = '<p class="text-muted"><i>Chart not available</i></p>';
});
}
},
changeMenuNetwork() {
this.menuTitle = `<h1>Network</h1>`
Expand Down Expand Up @@ -1165,6 +1330,36 @@
/* main */
document.getElementById("tabDefault").click();
app_opct.changeMenu('summary')

/* Resizable split-pane divider */
document.addEventListener('mousedown', function(e) {
if (e.target.id !== 'panel-divider') return;
e.preventDefault();
var container = document.getElementById('split-container');
var left = document.getElementById('panel-left');
var right = document.getElementById('panel-right');
var divider = document.getElementById('panel-divider');
var startX = e.clientX;
var startLeftW = left.offsetWidth;
var totalW = container.offsetWidth - divider.offsetWidth;

function onMove(e) {
var dx = e.clientX - startX;
var newLeftW = Math.max(200, Math.min(totalW - 200, startLeftW + dx));
var leftPct = (newLeftW / container.offsetWidth) * 100;
var rightPct = ((totalW - newLeftW) / container.offsetWidth) * 100;
left.style.width = leftPct + '%';
left.style.minWidth = leftPct + '%';
right.style.width = rightPct + '%';
right.style.minWidth = rightPct + '%';
}
function onUp() {
document.removeEventListener('mousemove', onMove);
document.removeEventListener('mouseup', onUp);
}
document.addEventListener('mousemove', onMove);
document.addEventListener('mouseup', onUp);
});
</script>

</body>
Expand Down
1 change: 0 additions & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,6 @@ require (
require (
github.com/aws/aws-sdk-go v1.55.6
github.com/evanphx/json-patch v4.12.0+incompatible
github.com/go-echarts/go-echarts/v2 v2.5.1
github.com/google/go-cmp v0.7.0
github.com/hashicorp/go-retryablehttp v0.7.7
github.com/jedib0t/go-pretty/v6 v6.6.7
Expand Down
2 changes: 0 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,6 @@ github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nos
github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM=
github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM=
github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
github.com/go-echarts/go-echarts/v2 v2.5.1 h1:kFVNaS3IsszKOQmUyCi95D2IhipE5twfvaBhFLOfPrs=
github.com/go-echarts/go-echarts/v2 v2.5.1/go.mod h1:56YlvzhW/a+du15f3S2qUGNDfKnFOeJSThBIrVFHDtI=
github.com/go-logr/logr v0.1.0/go.mod h1:ixOQHD9gLJUVQQ2ZOR7zLEifBX6tGkNJF4QyIY7sIas=
github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
Expand Down
Loading
Loading