-
Notifications
You must be signed in to change notification settings - Fork 15
OPCT-TBD: Add interactive etcd metric charts to web UI report #218
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
a24d6af
58b6842
98f5816
5373b57
3e5abd8
6d0133d
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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
+13
to
+16
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 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" || trueRepository: redhat-openshift-ecosystem/opct Length of output: 806 Fix supply-chain risk and Chart.js lifecycle issues in the report template
🤖 Prompt for AI Agents |
||
| <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" /> | ||
|
|
||
|
|
@@ -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 --> | ||
|
|
@@ -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> | ||
|
|
@@ -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: { | ||
|
|
@@ -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 = ''; } | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
| }, | ||
| createTableRevHTML(table=[]) { | ||
| htmlTable = `<table class="table table-sm table-striped font-monospace"><tbody>` | ||
|
|
@@ -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" | ||
|
|
@@ -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 | ||
|
|
@@ -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">⛶</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">×</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>` | ||
|
|
@@ -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> | ||
|
|
||
There was a problem hiding this comment.
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 ?