diff --git a/app/cdash/tests/CMakeLists.txt b/app/cdash/tests/CMakeLists.txt index a8c612ffea..f67e63ad34 100644 --- a/app/cdash/tests/CMakeLists.txt +++ b/app/cdash/tests/CMakeLists.txt @@ -419,6 +419,8 @@ add_browser_test(/Browser/Pages/BuildNotesPageTest) add_browser_test(/Browser/Pages/BuildTargetsPageTest) +add_browser_test(/Browser/Pages/BuildInstrumentationPageTest) + add_browser_test(/Browser/Pages/BuildTestsPageTest) add_browser_test(/Browser/Pages/BuildConfigurePageTest) diff --git a/resources/js/vue/components/BuildInstrumentationPage.vue b/resources/js/vue/components/BuildInstrumentationPage.vue index 1cfb51e094..894e4ccb87 100644 --- a/resources/js/vue/components/BuildInstrumentationPage.vue +++ b/resources/js/vue/components/BuildInstrumentationPage.vue @@ -49,6 +49,7 @@ import FilterBuilder from './shared/FilterBuilder.vue'; import CommandFlameChart from './shared/CommandFlameChart.vue'; import { DateTime, Duration } from 'luxon'; import LineChart from './shared/Charts/LineChart.vue'; +import Utils from './shared/Utils'; export default { name: 'BuildInstrumentationPage', @@ -104,6 +105,7 @@ export default { edges { node { name + type value } } @@ -133,6 +135,7 @@ export default { edges { node { name + type value } } @@ -249,6 +252,7 @@ export default { language: edge.node.language, config: edge.node.config, disabled: !this.visibleCommandIds.has(edge.node.id), + measurements: Utils.numericMeasurements(edge.node.measurements.edges.map(({ node }) => node)), }; }); }, diff --git a/resources/js/vue/components/BuildTestsPage.vue b/resources/js/vue/components/BuildTestsPage.vue index 48b5c6c7c7..c1b2a9280b 100644 --- a/resources/js/vue/components/BuildTestsPage.vue +++ b/resources/js/vue/components/BuildTestsPage.vue @@ -90,6 +90,7 @@ import LoadingIndicator from './shared/LoadingIndicator.vue'; import BuildSummaryCard from './shared/BuildSummaryCard.vue'; import BuildSidebar from './shared/BuildSidebar.vue'; import TestFlameChart from './shared/TestFlameChart.vue'; +import Utils from './shared/Utils'; import { DateTime, Duration } from 'luxon'; import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'; import { faChartGantt } from '@fortawesome/free-solid-svg-icons'; @@ -342,24 +343,16 @@ export default { return []; } - return this.executedTests.filter((test) => test.node.startTime).map((test) => { - const numericMeasurements = test.node.testMeasurements - .filter((measurement) => measurement.type.startsWith('numeric')) - .map((measurement) => ({ name: measurement.name, value: parseFloat(measurement.value) })); - const maxRssMeasurement = numericMeasurements.find((measurement) => measurement.name === 'MaxRSS'); - - return { - id: test.node.id, - name: test.node.name, - startTime: DateTime.fromISO(test.node.startTime), - duration: Duration.fromObject({ seconds: test.node.runningTime }), - status: test.node.status, - subProject: test.subProject, - disabled: !this.visibleTestIds.has(test.node.id), - maxRss: maxRssMeasurement ? maxRssMeasurement.value : null, - numericMeasurements, - }; - }); + return this.executedTests.filter((test) => test.node.startTime).map((test) => ({ + id: test.node.id, + name: test.node.name, + startTime: DateTime.fromISO(test.node.startTime), + duration: Duration.fromObject({ seconds: test.node.runningTime }), + status: test.node.status, + subProject: test.subProject, + disabled: !this.visibleTestIds.has(test.node.id), + measurements: Utils.numericMeasurements(test.node.testMeasurements), + })); }, pinnedMeasurementColumns() { diff --git a/resources/js/vue/components/shared/Charts/FlameChart.vue b/resources/js/vue/components/shared/Charts/FlameChart.vue index e9ea11c231..ac83336534 100644 --- a/resources/js/vue/components/shared/Charts/FlameChart.vue +++ b/resources/js/vue/components/shared/Charts/FlameChart.vue @@ -1,11 +1,47 @@ diff --git a/resources/js/vue/components/shared/Charts/FlameChartLayout.js b/resources/js/vue/components/shared/Charts/FlameChartLayout.js new file mode 100644 index 0000000000..38364669cc --- /dev/null +++ b/resources/js/vue/components/shared/Charts/FlameChartLayout.js @@ -0,0 +1,73 @@ +/** The position of each field in a laid out item's ECharts `value` array. */ +export const Dimension = Object.freeze({ + TRACK: 0, + START_TIME: 1, + END_TIME: 2, + HEATMAP_VALUE: 3, + ITEM_INDEX: 4, +}); + +export default { + /** + * Lays out timeline items for the FlameChart. Each item is placed on the first track + * which is free at its start time, so concurrently running items are drawn on separate rows. + * + * @param {Array} items Items with Luxon DateTime `startTime` and Duration `duration` values, + * and optional numeric `measurements` ({ name, value } objects). + * @param {string} heatmapMeasurement The name of the measurement used to color items in heatmap mode. + * @returns {{ + * data: Array<{ value: Array }>, + * trackCount: number, + * overallStartTime: number, + * overallEndTime: number, + * heatmapRange: ?{ min: number, max: number }, + * }} ECharts series data, indexed by Dimension, and the extent of the times and heatmap values. + */ + layout(items, heatmapMeasurement) { + const trackEndTimes = []; + let overallStartTime = 0; + let overallEndTime = 0; + let heatmapRange = null; + + const data = items + .map((item, index) => { + const startTime = item.startTime.toMillis(); + const heatmapValue = item.measurements?.find((measurement) => measurement.name === heatmapMeasurement)?.value; + return { + startTime, + endTime: startTime + item.duration.toMillis(), + heatmapValue: Number.isFinite(heatmapValue) ? heatmapValue : null, + index, + }; + }) + .sort((a, b) => a.startTime - b.startTime) + .map(({ startTime, endTime, heatmapValue, index }, sortedIndex) => { + let track = trackEndTimes.findIndex((trackEndTime) => startTime >= trackEndTime); + if (track === -1) { + track = trackEndTimes.length; + } + trackEndTimes[track] = endTime; + + overallStartTime = sortedIndex === 0 ? startTime : Math.min(overallStartTime, startTime); + overallEndTime = sortedIndex === 0 ? endTime : Math.max(overallEndTime, endTime); + if (heatmapValue !== null) { + heatmapRange = { + min: Math.min(heatmapRange?.min ?? heatmapValue, heatmapValue), + max: Math.max(heatmapRange?.max ?? heatmapValue, heatmapValue), + }; + } + + return { + value: [track, startTime, endTime, heatmapValue, index], + }; + }); + + return { + data, + trackCount: trackEndTimes.length, + overallStartTime, + overallEndTime, + heatmapRange, + }; + }, +}; diff --git a/resources/js/vue/components/shared/CommandFlameChart.vue b/resources/js/vue/components/shared/CommandFlameChart.vue index 3725c60c42..c8fc9f8e28 100644 --- a/resources/js/vue/components/shared/CommandFlameChart.vue +++ b/resources/js/vue/components/shared/CommandFlameChart.vue @@ -1,35 +1,16 @@ @@ -61,6 +42,7 @@ export default { * command: String, * language: String, * config: String, + * measurements: Array, // Optional numeric measurements: { name: String, value: Number } * } */ commands: { @@ -80,199 +62,33 @@ export default { CMAKE_INSTALL: '#CC79A7', INSTALL: '#56B4E9', }, - commandBarHeight: 15, - commandBarSpacing: 5, selectedCommandId: null, }; }, computed: { - processedChartData() { - if (!this.commands || this.commands.length === 0) { - return { - data: [], - tracks: [], - overallStartTime: 0, - overallEndTime: 0, - }; - } - - const rawCommandData = this.commands.map((cmd, index) => { - const startTime = cmd.startTime.toMillis(); - const endTime = startTime + cmd.duration.toMillis(); - return { - ...cmd, - startTime, - endTime, - originalIndex: index, - }; - }); - - const overallStartTime = rawCommandData.reduce((start, cmd) => Math.min(start, cmd.startTime), Infinity); - const overallEndTime = rawCommandData.reduce((end, cmd) => Math.max(end, cmd.endTime), -Infinity); - - const trackEndTimes = []; - const processedData = []; - const sortedData = rawCommandData.slice().sort((a, b) => a.startTime - b.startTime); - - sortedData.forEach((command) => { - let placed = false; - let trackIndex = -1; - for (let i = 0; i < trackEndTimes.length; i++) { - if (command.startTime >= trackEndTimes[i]) { - trackEndTimes[i] = command.endTime; - trackIndex = i; - placed = true; - break; - } - } - if (!placed) { - trackIndex = trackEndTimes.length; - trackEndTimes.push(command.endTime); - } - - const processedCommand = { - value: [ - trackIndex, - command.startTime, - command.endTime, - command.duration.toMillis(), - command.originalIndex, - command.type, - command.disabled, - command.targetName, - command.source, - command.language, - command.config, - command.id, - ], - }; - processedData.push(processedCommand); - }); - - return { - data: processedData, - tracks: trackEndTimes.map((_, i) => `Thread ${i + 1}`), - overallStartTime, - overallEndTime, - }; - }, - - totalChartHeight() { - const numtracks = this.processedChartData.tracks.length; - return ((this.commandBarHeight + this.commandBarSpacing) * numtracks) + 60; + chartItems() { + return this.commands.map((command) => ({ + ...command, + category: command.type, + })); }, }, methods: { - getTooltipElement(params) { - if (!params.data.value || !Array.isArray(params.data.value)) { - return ''; - } - const data = params.data.value; - const type = data[5]; - const duration = Utils.formatDuration(data[3]); - const targetName = data[7]; - const source = data[8]; - const language = data[9]; - const config = data[10]; - - const container = document.createElement('div'); - - const appendLine = (label, value, isBold = false, isCode = false) => { - if (value) { - if (container.childNodes.length > 0) { - container.appendChild(document.createElement('br')); - } - const labelNode = document.createTextNode(`${label}: `); - container.appendChild(labelNode); - - let valueNode; - if (isCode) { - valueNode = document.createElement('div'); - valueNode.className = 'tw-font-mono tw-bg-gray-100 tw-p-1 tw-rounded tw-whitespace-pre-wrap tw-break-all !tw-mt-0'; - valueNode.textContent = value; - } else if (isBold) { - valueNode = document.createElement('b'); - valueNode.textContent = value; - } else { - valueNode = document.createTextNode(value); - } - container.appendChild(valueNode); - } - }; - - appendLine('Target', targetName, true); - appendLine('Type', type); - appendLine('Duration', duration); - appendLine('Language', language); - appendLine('Config', config); - appendLine('Source', source, false, true); - - return container; - }, - - handleResize() { - // No-op, vue-echarts handles resize via autoresize prop - }, - - onCellClick(params) { - if (!params.data.value || !Array.isArray(params.data.value)) { - return; - } - - const clickedCommandId = parseInt(params.data.value[11]); - if (this.selectedCommandId === clickedCommandId) { - this.selectedCommandId = null; - } else { - this.selectedCommandId = clickedCommandId; - } + tooltipFields(command) { + return [ + { label: 'Target', value: command.targetName, isBold: true }, + { label: 'Type', value: command.type }, + { label: 'Duration', value: Utils.formatDuration(command.duration.toMillis()) }, + { label: 'Language', value: command.language }, + { label: 'Config', value: command.config }, + { label: 'Source', value: command.source, isCode: true }, + ]; }, - renderItem(params, api) { - const trackIndex = api.value(0); - const start = api.coord([api.value(1), trackIndex]); - const end = api.coord([api.value(2), trackIndex]); - if (!start || !end) { - return; - } - - const height = this.commandBarHeight; - const itemType = api.value(5); - const isDisabled = api.value(6); - const commandId = api.value(11); - - const style = { - fill: this.colors[itemType], - opacity: 0.85, - }; - - if (isDisabled) { - Object.assign(style, { - fill: '#d1d5db', - stroke: '#d1d5db', - lineWidth: 0.5, - opacity: 0.4, - }); - } - - if (commandId === this.selectedCommandId) { - Object.assign(style, { - stroke: '#000000', - lineWidth: 2, - }); - } - - return { - type: 'rect', - shape: { - x: start[0], - y: start[1] - (height / 2), - width: end[0] - start[0], - height: height, - }, - style: style, - }; + onItemClick(command) { + this.selectedCommandId = this.selectedCommandId === command.id ? null : command.id; }, }, }; diff --git a/resources/js/vue/components/shared/CommandInfoCard.vue b/resources/js/vue/components/shared/CommandInfoCard.vue index b17ba0da5e..ba6cd7f7a2 100644 --- a/resources/js/vue/components/shared/CommandInfoCard.vue +++ b/resources/js/vue/components/shared/CommandInfoCard.vue @@ -86,7 +86,7 @@ {{ measurement.name }} - {{ addUnitsToSpecialMeasurements(measurement.name, measurement.value) }} + {{ formatMeasurement(measurement) }} @@ -151,6 +151,7 @@ export default { node { id name + type value } } @@ -194,12 +195,10 @@ export default { return Utils.formatBytes(inputInBytes); }, - addUnitsToSpecialMeasurements(measurementName, measurementValue) { - if (['BeforeHostMemoryUsed', 'AfterHostMemoryUsed'].includes(measurementName)) { - return Utils.formatBytesFromKib(measurementValue); - } - - return measurementValue; + /** Formats numeric measurements with units, and shows any other measurement's value as-is. */ + formatMeasurement(measurement) { + const [numericMeasurement] = Utils.numericMeasurements([measurement]); + return numericMeasurement ? Utils.formatMeasurement(numericMeasurement.name, numericMeasurement.value) : measurement.value; }, }, }; diff --git a/resources/js/vue/components/shared/TestFlameChart.vue b/resources/js/vue/components/shared/TestFlameChart.vue index 7ed456bba2..c524855341 100644 --- a/resources/js/vue/components/shared/TestFlameChart.vue +++ b/resources/js/vue/components/shared/TestFlameChart.vue @@ -1,54 +1,13 @@