adds new roofline component for job average based data

- clickable, resource sized and duration colored bubbles
This commit is contained in:
Christoph Kluge
2025-07-14 18:12:34 +02:00
parent b036c3903c
commit ed5290be86
3 changed files with 911 additions and 1 deletions

View File

@@ -20,6 +20,7 @@
import UsageDash from "./status/UsageDash.svelte";
import NodeDash from "./status/NodeDash.svelte";
import StatisticsDash from "./status/StatisticsDash.svelte";
import DevelDash from "./status/DevelDash.svelte";
/* Svelte 5 Props */
let {
@@ -68,7 +69,13 @@
<Card class="overflow-auto" style="height: auto;">
<TabContent>
<TabPane tabId="status-dash" tab="Status" active>
<TabPane tabId="devel-dash" tab="Devel" active>
<CardBody>
<DevelDash {cluster}></DevelDash>
</CardBody>
</TabPane>
<TabPane tabId="status-dash" tab="Status">
<CardBody>
<StatusDash {cluster}></StatusDash>
</CardBody>

View File

@@ -0,0 +1,739 @@
<!--
@component Roofline Model Plot based on uPlot
Properties:
- `data [null, [], []]`: Roofline Data Structure, see below for details [Default: null]
- `allowSizeChange Bool?`: If dimensions of rendered plot can change [Default: false]
- `subCluster GraphQL.SubCluster?`: SubCluster Object; contains required topology information [Default: null]
- `width Number?`: Plot width (reactively adaptive) [Default: 600]
- `height Number?`: Plot height (reactively adaptive) [Default: 380]
Data Format:
- `data = [null, [], []]`
- Index 0: null-axis required for scatter
- Index 1: Array of XY-Arrays for Scatter
- Index 2: Optional Time Info
- `data[1][0] = [100, 200, 500, ...]`
- X Axis: Intensity (Vals up to clusters' flopRateScalar value)
- `data[1][1] = [1000, 2000, 1500, ...]`
- Y Axis: Performance (Vals up to clusters' flopRateSimd value)
- `data[2] = [0.1, 0.15, 0.2, ...]`
- Color Code: Time Information (Floats from 0 to 1) (Optional)
-->
<script>
import uPlot from "uplot";
import { formatNumber } from "../units.js";
import { onMount, onDestroy } from "svelte";
import { Card } from "@sveltestrap/sveltestrap";
import { roundTwoDigits } from "../units.js";
/* Svelte 5 Props */
let {
roofData = null,
jobsData = null,
allowSizeChange = false,
subCluster = null,
width = 600,
height = 380,
} = $props();
$inspect(jobsData)
/* Const Init */
const lineWidth = clusterCockpitConfig?.plot_general_lineWidth || 2;
const cbmode = clusterCockpitConfig?.plot_general_colorblindMode || false;
/* Var Init */
let timeoutId = null;
/* State Init */
let plotWrapper = $state(null);
let uplot = $state(null);
/* Effect */
$effect(() => {
if (allowSizeChange) sizeChanged(width, height);
});
// Copied Example Vars for Uplot Bubble
// https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D/isPointInPath
let qt;
let pxRatio;
function setPxRatio() {
pxRatio = uPlot.pxRatio;
}
setPxRatio();
window.addEventListener('dppxchange', setPxRatio);
// let minSize = 6;
let maxSize = 60;
let maxArea = Math.PI * (maxSize / 2) ** 2;
// let minArea = Math.PI * (minSize / 2) ** 2;
/* Functions */
// Helper
function pointWithin(px, py, rlft, rtop, rrgt, rbtm) {
return px >= rlft && px <= rrgt && py >= rtop && py <= rbtm;
}
function getGradientR(x) {
if (x < 0.5) return 0;
if (x > 0.75) return 255;
x = (x - 0.5) * 4.0;
return Math.floor(x * 255.0);
}
function getGradientG(x) {
if (x > 0.25 && x < 0.75) return 255;
if (x < 0.25) x = x * 4.0;
else x = 1.0 - (x - 0.75) * 4.0;
return Math.floor(x * 255.0);
}
function getGradientB(x) {
if (x < 0.25) return 255;
if (x > 0.5) return 0;
x = 1.0 - (x - 0.25) * 4.0;
return Math.floor(x * 255.0);
}
function getRGB(c, makeTransparent = false) {
if (makeTransparent) return `rgb(${cbmode ? '0' : getGradientR(c)}, ${getGradientG(c)}, ${getGradientB(c)}, 0.33)`;
else return `rgb(${cbmode ? '0' : getGradientR(c)}, ${getGradientG(c)}, ${getGradientB(c)})`;
}
function nearestThousand(num) {
return Math.ceil(num / 1000) * 1000;
}
function lineIntersect(x1, y1, x2, y2, x3, y3, x4, y4) {
let l = (y4 - y3) * (x2 - x1) - (x4 - x3) * (y2 - y1);
let a = ((x4 - x3) * (y1 - y3) - (y4 - y3) * (x1 - x3)) / l;
return {
x: x1 + a * (x2 - x1),
y: y1 + a * (y2 - y1),
};
}
// quadratic scaling (px area)
function getSize(value, minValue, maxValue) {
let pct = value / maxValue;
// clamp to min area
//let area = Math.max(maxArea * pct, minArea);
let area = maxArea * pct;
return Math.sqrt(area / Math.PI) * 2;
}
function getSizeMinMax(u) {
let minValue = Infinity;
let maxValue = -Infinity;
for (let i = 1; i < u.series.length; i++) {
let sizeData = u.data[i][2];
for (let j = 0; j < sizeData.length; j++) {
minValue = Math.min(minValue, sizeData[j]);
maxValue = Math.max(maxValue, sizeData[j]);
}
}
return [minValue, maxValue];
}
// Quadtree Object (How to import?)
class Quadtree {
constructor (x, y, w, h, l) {
let t = this;
t.x = x;
t.y = y;
t.w = w;
t.h = h;
t.l = l || 0;
t.o = [];
t.q = null;
t.MAX_OBJECTS = 10;
t.MAX_LEVELS = 4;
};
get quadtree() {
return "Implement me!";
}
split() {
let t = this,
x = t.x,
y = t.y,
w = t.w / 2,
h = t.h / 2,
l = t.l + 1;
t.q = [
// top right
new Quadtree(x + w, y, w, h, l),
// top left
new Quadtree(x, y, w, h, l),
// bottom left
new Quadtree(x, y + h, w, h, l),
// bottom right
new Quadtree(x + w, y + h, w, h, l),
];
};
quads(x, y, w, h, cb) {
let t = this,
q = t.q,
hzMid = t.x + t.w / 2,
vtMid = t.y + t.h / 2,
startIsNorth = y < vtMid,
startIsWest = x < hzMid,
endIsEast = x + w > hzMid,
endIsSouth = y + h > vtMid;
// top-right quad
startIsNorth && endIsEast && cb(q[0]);
// top-left quad
startIsWest && startIsNorth && cb(q[1]);
// bottom-left quad
startIsWest && endIsSouth && cb(q[2]);
// bottom-right quad
endIsEast && endIsSouth && cb(q[3]);
};
add(o) {
let t = this;
if (t.q != null) {
t.quads(o.x, o.y, o.w, o.h, q => {
q.add(o);
});
}
else {
let os = t.o;
os.push(o);
if (os.length > t.MAX_OBJECTS && t.l < t.MAX_LEVELS) {
t.split();
for (let i = 0; i < os.length; i++) {
let oi = os[i];
t.quads(oi.x, oi.y, oi.w, oi.h, q => {
q.add(oi);
});
}
t.o.length = 0;
}
}
};
get(x, y, w, h, cb) {
let t = this;
let os = t.o;
for (let i = 0; i < os.length; i++)
cb(os[i]);
if (t.q != null) {
t.quads(x, y, w, h, q => {
q.get(x, y, w, h, cb);
});
}
}
clear() {
this.o.length = 0;
this.q = null;
}
}
// Dot Renderers
const makeDrawPoints = (opts) => {
let {/*size,*/ disp, each = () => {}} = opts;
const sizeBase = 5 * pxRatio;
return (u, seriesIdx, idx0, idx1) => {
uPlot.orient(u, seriesIdx, (series, dataX, dataY, scaleX, scaleY, valToPosX, valToPosY, xOff, yOff, xDim, yDim, moveTo, lineTo, rect, arc) => {
let d = u.data[seriesIdx];
let strokeWidth = 2;
u.ctx.save();
u.ctx.rect(u.bbox.left, u.bbox.top, u.bbox.width, u.bbox.height);
u.ctx.clip();
// u.ctx.fillStyle = series.fill();
// u.ctx.strokeStyle = series.stroke();
u.ctx.lineWidth = strokeWidth;
let deg360 = 2 * Math.PI;
// console.time("points");
// let cir = new Path2D();
// cir.moveTo(0, 0);
// arc(cir, 0, 0, 3, 0, deg360);
// Create transformation matrix that moves 200 points to the right
// let m = document.createElementNS('http://www.w3.org/2000/svg', 'svg').createSVGMatrix();
// m.a = 1; m.b = 0;
// m.c = 0; m.d = 1;
// m.e = 200; m.f = 0;
// compute bubble dims
// let sizes = disp.size.values(u, seriesIdx, idx0, idx1);
// todo: this depends on direction & orientation
// todo: calc once per redraw, not per path
let filtLft = u.posToVal(-maxSize / 2, scaleX.key);
let filtRgt = u.posToVal(u.bbox.width / pxRatio + maxSize / 2, scaleX.key);
let filtBtm = u.posToVal(u.bbox.height / pxRatio + maxSize / 2, scaleY.key);
let filtTop = u.posToVal(-maxSize / 2, scaleY.key);
for (let i = 0; i < d[0].length; i++) {
// Import from Roofline
u.ctx.strokeStyle = getRGB(u.data[2][i]);
u.ctx.fillStyle = getRGB(u.data[2][i], true);
// End
let xVal = d[0][i];
let yVal = d[1][i];
const size = sizeBase + (jobsData[i]?.numAcc ? jobsData[i].numAcc / 2 : jobsData[i].numNodes);
// let size = sizes[i] * pxRatio;
if (xVal >= filtLft && xVal <= filtRgt && yVal >= filtBtm && yVal <= filtTop) {
let cx = valToPosX(xVal, scaleX, xDim, xOff);
let cy = valToPosY(yVal, scaleY, yDim, yOff);
u.ctx.moveTo(cx + size/2, cy);
u.ctx.beginPath();
u.ctx.arc(cx, cy, size/2, 0, deg360);
u.ctx.fill();
u.ctx.stroke();
each(u, seriesIdx, i,
cx - size/2 - strokeWidth/2,
cy - size/2 - strokeWidth/2,
size + strokeWidth,
size + strokeWidth
);
}
}
// console.timeEnd("points");
u.ctx.restore();
});
return null;
};
};
let drawPoints = makeDrawPoints({
disp: {
size: {
unit: 3, // raw CSS pixels
// discr: true,
values: (u, seriesIdx, idx0, idx1) => {
// TODO: only run once per setData() call
let [minValue, maxValue] = getSizeMinMax(u);
return u.data[seriesIdx][2].map(v => getSize(v, minValue, maxValue));
},
},
},
each: (u, seriesIdx, dataIdx, lft, top, wid, hgt) => {
// we get back raw canvas coords (included axes & padding). translate to the plotting area origin
lft -= u.bbox.left;
top -= u.bbox.top;
qt.add({x: lft, y: top, w: wid, h: hgt, sidx: seriesIdx, didx: dataIdx});
},
});
const legendValues = (u, seriesIdx, dataIdx) => {
// when data null, it's initial schema probe (also u.status == 0)
if (u.data == null || dataIdx == null || hRect == null || hRect.sidx != seriesIdx) {
return {
"Intensity [FLOPS/Byte]": '-',
"":'',
"Performace [GFLOPS]": '-'
};
}
return {
"Intensity [FLOPS/Byte]": roundTwoDigits(u.data[seriesIdx][0][dataIdx]),
"":'',
"Performace [GFLOPS]": roundTwoDigits(u.data[seriesIdx][1][dataIdx]),
};
};
// Tooltip Plugin
function tooltipPlugin({onclick, getJobData, shiftX = 10, shiftY = 10}) {
let tooltipLeftOffset = 0;
let tooltipTopOffset = 0;
const tooltip = document.createElement("div");
// Build Manual Class By Styles
tooltip.style.fontSize = "10pt";
tooltip.style.position = "absolute";
tooltip.style.background = "#fcfcfc";
tooltip.style.display = "nonde";
tooltip.style.border = "2px solid black";
tooltip.style.padding = "4px";
tooltip.style.pointerEvents = "none";
tooltip.style.zIndex = "100";
tooltip.style.whiteSpace = "pre";
tooltip.style.fontFamily = "monospace";
const tipSeriesIdx = 1; // Scatter: Series IDX is always 1
let tipDataIdx = null;
// const fmtDate = uPlot.fmtDate("{M}/{D}/{YY} {h}:{mm}:{ss} {AA}");
let over;
let tooltipVisible = false;
function showTooltip() {
if (!tooltipVisible) {
tooltip.style.display = "block";
over.style.cursor = "pointer";
tooltipVisible = true;
}
}
function hideTooltip() {
if (tooltipVisible) {
tooltip.style.display = "none";
over.style.cursor = null;
tooltipVisible = false;
}
}
function setTooltip(u, i) {
showTooltip();
let top = u.valToPos(u.data[tipSeriesIdx][1][i], 'y');
let lft = u.valToPos(u.data[tipSeriesIdx][0][i], 'x');
tooltip.style.top = (tooltipTopOffset + top + shiftX) + "px";
tooltip.style.left = (tooltipLeftOffset + lft + shiftY) + "px";
tooltip.style.borderColor = getRGB(u.data[2][i]);
tooltip.textContent = (
// Tooltip Content as String
`Job ID: ${getJobData(u, i).jobId}\nNodes: ${getJobData(u, i).numNodes}${getJobData(u, i)?.numAcc?`\nAccelerators: ${getJobData(u, i).numAcc}`:''}`
);
}
return {
hooks: {
ready: [
u => {
over = u.over;
tooltipLeftOffset = parseFloat(over.style.left);
tooltipTopOffset = parseFloat(over.style.top);
u.root.querySelector(".u-wrap").appendChild(tooltip);
let clientX;
let clientY;
over.addEventListener("mousedown", e => {
clientX = e.clientX;
clientY = e.clientY;
});
over.addEventListener("mouseup", e => {
// clicked in-place
if (e.clientX == clientX && e.clientY == clientY) {
if (tipDataIdx != null) {
onclick(u, tipDataIdx);
}
}
});
}
],
setCursor: [
u => {
let i = u.legend.idxs[1];
if (i != null) {
tipDataIdx = i;
setTooltip(u, i);
} else {
tipDataIdx = null;
hideTooltip();
}
}
]
}
};
}
// Main Functions
function sizeChanged() {
if (timeoutId != null) clearTimeout(timeoutId);
timeoutId = setTimeout(() => {
timeoutId = null;
if (uplot) uplot.destroy();
render(roofData, jobsData);
}, 200);
}
let hRect;
function render(roofdata, jobsData) {
if (roofdata) {
const opts = {
title: "Job Average Roofline Diagram (Bubble)",
mode: 2,
width: width,
height: height,
legend: {
// show: true,
},
cursor: {
drag: { x: true, y: false }, // Activate zoom
dataIdx: (u, seriesIdx) => {
if (seriesIdx == 1) {
hRect = null;
let dist = Infinity;
let area = Infinity;
let cx = u.cursor.left * pxRatio;
let cy = u.cursor.top * pxRatio;
qt.get(cx, cy, 1, 1, o => {
if (pointWithin(cx, cy, o.x, o.y, o.x + o.w, o.y + o.h)) {
let ocx = o.x + o.w / 2;
let ocy = o.y + o.h / 2;
let dx = ocx - cx;
let dy = ocy - cy;
let d = Math.sqrt(dx ** 2 + dy ** 2);
// test against radius for actual hover
if (d <= o.w / 2) {
let a = o.w * o.h;
// prefer smallest
if (a < area) {
area = a;
dist = d;
hRect = o;
}
// only hover bbox with closest distance
else if (a == area && d <= dist) {
dist = d;
hRect = o;
}
}
}
});
}
return hRect && seriesIdx == hRect.sidx ? hRect.didx : null;
},
points: {
size: (u, seriesIdx) => {
return hRect && seriesIdx == hRect.sidx ? hRect.w / pxRatio : 0;
}
},
focus: {
prox: 1e3,
alpha: 0.3,
dist: (u, seriesIdx) => {
let prox = (hRect?.sidx === seriesIdx ? 0 : Infinity);
return prox;
},
}
},
axes: [
{
label: "Intensity [FLOPS/Byte]",
values: (u, vals) => vals.map((v) => formatNumber(v)),
},
{
label: "Performace [GFLOPS]",
values: (u, vals) => vals.map((v) => formatNumber(v)),
},
],
scales: {
x: {
time: false,
range: [0.01, 1000],
distr: 3, // Render as log
log: 10, // log exp
},
y: {
range: [
0.01,
subCluster?.flopRateSimd?.value
? nearestThousand(subCluster.flopRateSimd.value)
: 10000,
],
distr: 3, // Render as log
log: 10, // log exp
},
},
series: [
null,
{
facets: [
{
scale: 'x',
auto: true,
},
{
scale: 'y',
auto: true,
}
],
paths: drawPoints,
values: legendValues
}
],
hooks: {
// setSeries: [ (u, seriesIdx) => console.log('setSeries', seriesIdx) ],
// setLegend: [ u => console.log('setLegend', u.legend.idxs) ],
drawClear: [
(u) => {
qt = qt || new Quadtree(0, 0, u.bbox.width, u.bbox.height);
qt.clear();
u.series.forEach((s, i) => {
if (i > 0) s._paths = null;
});
},
],
draw: [
(u) => {
// draw roofs when subCluster set
if (subCluster != null) {
const padding = u._padding; // [top, right, bottom, left]
u.ctx.strokeStyle = "black";
u.ctx.lineWidth = lineWidth;
u.ctx.beginPath();
const ycut = 0.01 * subCluster.memoryBandwidth.value;
const scalarKnee =
(subCluster.flopRateScalar.value - ycut) /
subCluster.memoryBandwidth.value;
const simdKnee =
(subCluster.flopRateSimd.value - ycut) /
subCluster.memoryBandwidth.value;
const scalarKneeX = u.valToPos(scalarKnee, "x", true), // Value, axis, toCanvasPixels
simdKneeX = u.valToPos(simdKnee, "x", true),
flopRateScalarY = u.valToPos(
subCluster.flopRateScalar.value,
"y",
true,
),
flopRateSimdY = u.valToPos(
subCluster.flopRateSimd.value,
"y",
true,
);
if (
scalarKneeX <
width * window.devicePixelRatio -
padding[1] * window.devicePixelRatio
) {
// Lower horizontal roofline
u.ctx.moveTo(scalarKneeX, flopRateScalarY);
u.ctx.lineTo(
width * window.devicePixelRatio -
padding[1] * window.devicePixelRatio,
flopRateScalarY,
);
}
if (
simdKneeX <
width * window.devicePixelRatio -
padding[1] * window.devicePixelRatio
) {
// Top horitontal roofline
u.ctx.moveTo(simdKneeX, flopRateSimdY);
u.ctx.lineTo(
width * window.devicePixelRatio -
padding[1] * window.devicePixelRatio,
flopRateSimdY,
);
}
let x1 = u.valToPos(0.01, "x", true),
y1 = u.valToPos(ycut, "y", true);
let x2 = u.valToPos(simdKnee, "x", true),
y2 = flopRateSimdY;
let xAxisIntersect = lineIntersect(
x1,
y1,
x2,
y2,
u.valToPos(0.01, "x", true),
u.valToPos(1.0, "y", true), // X-Axis Start Coords
u.valToPos(1000, "x", true),
u.valToPos(1.0, "y", true), // X-Axis End Coords
);
if (xAxisIntersect.x > x1) {
x1 = xAxisIntersect.x;
y1 = xAxisIntersect.y;
}
// Diagonal
u.ctx.moveTo(x1, y1);
u.ctx.lineTo(x2, y2);
u.ctx.stroke();
// Reset grid lineWidth
u.ctx.lineWidth = 0.15;
}
// The Color Scale For Time Information
const posX = u.valToPos(0.1, "x", true)
const posXLimit = u.valToPos(100, "x", true)
const posY = u.valToPos(14000.0, "y", true)
u.ctx.fillStyle = 'black'
u.ctx.fillText('Short', posX, posY)
const start = posX + 10
for (let x = start; x < posXLimit; x += 10) {
let c = (x - start) / (posXLimit - start)
u.ctx.fillStyle = getRGB(c)
u.ctx.beginPath()
u.ctx.arc(x, posY, 3, 0, Math.PI * 2, false)
u.ctx.fill()
}
u.ctx.fillStyle = 'black'
u.ctx.fillText('Long', posXLimit + 23, posY)
},
],
},
plugins: [
tooltipPlugin({
onclick(u, dataIdx) {
window.open(`/monitoring/job/${jobsData[dataIdx].id}`);
},
getJobData: (u, dataIdx) => { return jobsData[dataIdx] }
}),
],
};
uplot = new uPlot(opts, roofdata, plotWrapper);
} else {
// console.log("No data for roofline!");
}
}
/* On Mount */
onMount(() => {
render(roofData, jobsData);
});
/* On Destroy */
onDestroy(() => {
if (uplot) uplot.destroy();
if (timeoutId != null) clearTimeout(timeoutId);
});
</script>
{#if roofData != null}
<div bind:this={plotWrapper} class="p-2"></div>
{:else}
<Card class="mx-4" body color="warning">Cannot render roofline: No data!</Card
>
{/if}

View File

@@ -0,0 +1,164 @@
<!--
@component Main cluster status view component; renders current system-usage information
Properties:
- `cluster String`: The cluster to show status information for
-->
<script>
import {
Row,
Col,
} from "@sveltestrap/sveltestrap";
import {
queryStore,
gql,
getContextClient,
} from "@urql/svelte";
import {
init,
} from "../generic/utils.js";
import Roofline from "../generic/plots/Roofline.svelte";
import NewBubbleRoofline from "../generic/plots/NewBubbleRoofline.svelte";
/* Svelte 5 Props */
let {
cluster
} = $props();
/* Const Init */
const { query: initq } = init();
const client = getContextClient();
/* State Init */
// let from = $state(new Date(Date.now() - 5 * 60 * 1000));
// let to = $state(new Date(Date.now()));
let plotWidths = $state([]);
/* Derived */
// Note: nodeMetrics are requested on configured $timestep resolution
// Result: The latest 5 minutes (datapoints) for each node independent of job
const jobRoofQuery = $derived(queryStore({
client: client,
query: gql`
query ($filter: [JobFilter!]!, $metrics: [String!]!) {
jobsMetricStats(filter: $filter, metrics: $metrics) {
id
jobId
duration
numNodes
numAccelerators
subCluster
stats {
name
data {
avg
}
}
}
}
`,
variables: {
filter: [{ state: ["running"] }, { cluster: { eq: cluster } }],
metrics: ["flops_any", "mem_bw"], // Fixed names for job roofline
},
}));
/* Function */
function transformJobsStatsToData(subclusterData) {
/* c will contain values from 0 to 1 representing the duration */
let data = null
const x = [], y = [], c = [], day = 86400.0
if (subclusterData) {
for (let i = 0; i < subclusterData.length; i++) {
const flopsData = subclusterData[i].stats.find((s) => s.name == "flops_any")
const memBwData = subclusterData[i].stats.find((s) => s.name == "mem_bw")
const f = flopsData.data.avg
const m = memBwData.data.avg
const d = subclusterData[i].duration / day
const intensity = f / m
if (Number.isNaN(intensity) || !Number.isFinite(intensity))
continue
x.push(intensity)
y.push(f)
// Long Jobs > 1 Day: Use max Color
if (d > 1.0) c.push(1.0)
else c.push(d)
}
} else {
console.warn("transformData: metrics for 'mem_bw' and/or 'flops_any' missing!")
}
if (x.length > 0 && y.length > 0 && c.length > 0) {
data = [null, [x, y], c] // for dataformat see roofline.svelte
}
return data
}
function transformJobsStatsToInfo(subclusterData) {
if (subclusterData) {
return subclusterData.map((sc) => { return {id: sc.id, jobId: sc.jobId, numNodes: sc.numNodes, numAcc: sc?.numAccelerators? sc.numAccelerators : 0} })
} else {
console.warn("transformData: jobInfo missing!")
return []
}
}
</script>
<!-- Gauges & Roofline per Subcluster-->
{#if $initq.data && $jobRoofQuery.data}
{#each $initq.data.clusters.find((c) => c.name == cluster).subClusters as subCluster, i}
<Row cols={{ lg: 2, md: 2 , sm: 1}} class="mb-3 justify-content-center">
<Col class="px-3 mt-2 mt-lg-0">
<b>Classic</b>
<div bind:clientWidth={plotWidths[i]}>
{#key $jobRoofQuery.data.jobsMetricStats}
<b>{subCluster.name} Total: {$jobRoofQuery.data.jobsMetricStats.filter(
(data) => data.subCluster == subCluster.name,
).length} Jobs</b>
<Roofline
allowSizeChange
renderTime
width={plotWidths[i] - 10}
height={300}
subCluster={subCluster}
data={transformJobsStatsToData($jobRoofQuery?.data?.jobsMetricStats.filter(
(data) => data.subCluster == subCluster.name,
)
)}
/>
{/key}
</div>
</Col>
<Col class="px-3 mt-2 mt-lg-0">
<b>Bubble</b>
<div bind:clientWidth={plotWidths[i]}>
{#key $jobRoofQuery.data.jobsMetricStats}
<b>{subCluster.name} Total: {$jobRoofQuery.data.jobsMetricStats.filter(
(data) => data.subCluster == subCluster.name,
).length} Jobs</b>
<NewBubbleRoofline
allowSizeChange
width={plotWidths[i] - 10}
height={300}
subCluster={subCluster}
roofData={transformJobsStatsToData($jobRoofQuery?.data?.jobsMetricStats.filter(
(data) => data.subCluster == subCluster.name,
)
)}
jobsData={transformJobsStatsToInfo($jobRoofQuery?.data?.jobsMetricStats.filter(
(data) => data.subCluster == subCluster.name,
)
)}
/>
{/key}
</div>
</Col>
</Row>
{/each}
{/if}