diff --git a/internal/api/job.go b/internal/api/job.go index 4c8ca76..7c27a86 100644 --- a/internal/api/job.go +++ b/internal/api/job.go @@ -112,6 +112,7 @@ func (api *RestApi) getJobs(rw http.ResponseWriter, r *http.Request) { for key, vals := range r.URL.Query() { switch key { + // TODO: add project filter case "state": for _, s := range vals { state := schema.JobState(s) @@ -124,7 +125,7 @@ func (api *RestApi) getJobs(rw http.ResponseWriter, r *http.Request) { } case "cluster": filter.Cluster = &model.StringInput{Eq: &vals[0]} - case "start-time": + case "start-time": // ?startTime=1753707480-1754053139 st := strings.Split(vals[0], "-") if len(st) != 2 { handleError(fmt.Errorf("invalid query parameter value: startTime"), diff --git a/internal/auth/auth.go b/internal/auth/auth.go index ad78397..333efc0 100644 --- a/internal/auth/auth.go +++ b/internal/auth/auth.go @@ -381,7 +381,7 @@ func (auth *Authentication) AuthUserApi( return } case len(user.Roles) >= 2: - if user.HasRole(schema.RoleApi) && user.HasAnyRole([]schema.Role{schema.RoleUser, schema.RoleManager, schema.RoleAdmin}) { + if user.HasRole(schema.RoleApi) && user.HasAnyRole([]schema.Role{schema.RoleUser, schema.RoleManager, schema.RoleSupport, schema.RoleAdmin}) { ctx := context.WithValue(r.Context(), repository.ContextUserKey, user) onsuccess.ServeHTTP(rw, r.WithContext(ctx)) return @@ -473,6 +473,7 @@ func securedCheck(user *schema.User, r *http.Request) error { IPAddress = r.RemoteAddr } + // FIXME: IPV6 not handled if strings.Contains(IPAddress, ":") { IPAddress = strings.Split(IPAddress, ":")[0] } diff --git a/web/frontend/src/generic/plots/NewBubbleRoofline.svelte b/web/frontend/src/generic/plots/NewBubbleRoofline.svelte index bf25347..3a0e332 100644 --- a/web/frontend/src/generic/plots/NewBubbleRoofline.svelte +++ b/web/frontend/src/generic/plots/NewBubbleRoofline.svelte @@ -35,6 +35,7 @@ cluster = null, subCluster = null, allowSizeChange = false, + useColors = true, width = 600, height = 380, } = $props(); @@ -243,7 +244,7 @@ // Dot Renderer const makeDrawPoints = (opts) => { let {/*size, disp,*/ transparentFill, each = () => {}} = opts; - const sizeBase = 5 * pxRatio; + const sizeBase = 6 * 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) => { @@ -266,26 +267,33 @@ let filtTop = u.posToVal(-maxSize / 2, scaleY.key); for (let i = 0; i < d[0].length; i++) { - // Jobs: Color based on Duration - if (jobsData) { - u.ctx.strokeStyle = getRGB(u.data[2][i]); - u.ctx.fillStyle = getRGB(u.data[2][i], transparentFill); - // Nodes: Color based on Idle vs. Allocated - } else if (nodesData) { - // console.log('In Plot Handler NodesData', nodesData) - if (nodesData[i]?.nodeState == "idle") { - u.ctx.strokeStyle = "rgb(0, 0, 255)"; - u.ctx.fillStyle = "rgba(0, 0, 255, 0.5)"; - } else if (nodesData[i]?.nodeState == "allocated") { - u.ctx.strokeStyle = "rgb(0, 255, 0)"; - u.ctx.fillStyle = "rgba(0, 255, 0, 0.5)"; - } else if (nodesData[i]?.nodeState == "notindb") { - u.ctx.strokeStyle = "rgb(0, 0, 0)"; - u.ctx.fillStyle = "rgba(0, 0, 0, 0.5)"; - } else { // Fallback: All other DEFINED states - u.ctx.strokeStyle = "rgb(255, 0, 0)"; - u.ctx.fillStyle = "rgba(255, 0, 0, 0.5)"; + if (useColors) { + u.ctx.strokeStyle = "rgb(0, 0, 0)"; + // Jobs: Color based on Duration + if (jobsData) { + //u.ctx.strokeStyle = getRGB(u.data[2][i]); + u.ctx.fillStyle = getRGB(u.data[2][i], transparentFill); + // Nodes: Color based on Idle vs. Allocated + } else if (nodesData) { + // console.log('In Plot Handler NodesData', nodesData) + if (nodesData[i]?.nodeState == "idle") { + //u.ctx.strokeStyle = "rgb(0, 0, 255)"; + u.ctx.fillStyle = "rgba(0, 0, 255, 0.5)"; + } else if (nodesData[i]?.nodeState == "allocated") { + //u.ctx.strokeStyle = "rgb(0, 255, 0)"; + u.ctx.fillStyle = "rgba(0, 255, 0, 0.5)"; + } else if (nodesData[i]?.nodeState == "notindb") { + //u.ctx.strokeStyle = "rgb(0, 0, 0)"; + u.ctx.fillStyle = "rgba(0, 0, 0, 0.5)"; + } else { // Fallback: All other DEFINED states + //u.ctx.strokeStyle = "rgb(255, 0, 0)"; + u.ctx.fillStyle = "rgba(255, 0, 0, 0.5)"; + } } + } else { + // No Colors: Use Black + u.ctx.strokeStyle = "rgb(0, 0, 0)"; + u.ctx.fillStyle = "rgba(0, 0, 0, 0.5)"; } // Get Values @@ -297,10 +305,15 @@ // Jobs: Size based on Resourcecount if (jobsData) { - size = sizeBase + (jobsData[i]?.numAcc ? jobsData[i].numAcc / 2 : jobsData[i].numNodes) + const scaling = jobsData[i].numNodes > 12 + ? 24 // Capped Dot Size + : jobsData[i].numNodes > 1 + ? jobsData[i].numNodes * 2 // MultiNode Scaling + : jobsData[i]?.numAcc ? jobsData[i].numAcc : jobsData[i].numNodes * 2 // Single Node or Scale by Accs + size = sizeBase + scaling // Nodes: Size based on Jobcount } else if (nodesData) { - size = sizeBase + nodesData[i]?.numJobs + size = sizeBase + (nodesData[i]?.numJobs * 1.5) // Max Jobs Scale: 8 * 1.5 = 12 }; if (xVal >= filtLft && xVal <= filtRgt && yVal >= filtBtm && yVal <= filtTop) { @@ -377,7 +390,7 @@ tooltip.style.fontSize = "10pt"; tooltip.style.position = "absolute"; tooltip.style.background = "#fcfcfc"; - tooltip.style.display = "nonde"; + tooltip.style.display = "none"; tooltip.style.border = "2px solid black"; tooltip.style.padding = "4px"; tooltip.style.pointerEvents = "none"; @@ -417,33 +430,42 @@ tooltip.style.top = (tooltipTopOffset + top + shiftX) + "px"; tooltip.style.left = (tooltipLeftOffset + lft + shiftY) + "px"; - - // Jobs: Color based on Duration - if (jobsData) { - tooltip.style.borderColor = getRGB(u.data[2][i]); - // Nodes: Color based on Idle vs. Allocated - } else if (nodesData) { - if (nodesData[i]?.nodeState == "idle") { - tooltip.style.borderColor = "rgb(0, 0, 255)"; - } else if (nodesData[i]?.nodeState == "allocated") { - tooltip.style.borderColor = "rgb(0, 255, 0)"; - } else if (nodesData[i]?.nodeState == "notindb") { // Missing from DB table - tooltip.style.borderColor = "rgb(0, 0, 0)"; - } else { // Fallback: All other DEFINED states - tooltip.style.borderColor = "rgb(255, 0, 0)"; + if (useColors) { + // Jobs: Color based on Duration + if (jobsData) { + tooltip.style.borderColor = getRGB(u.data[2][i]); + // Nodes: Color based on Idle vs. Allocated + } else if (nodesData) { + if (nodesData[i]?.nodeState == "idle") { + tooltip.style.borderColor = "rgb(0, 0, 255)"; + } else if (nodesData[i]?.nodeState == "allocated") { + tooltip.style.borderColor = "rgb(0, 255, 0)"; + } else if (nodesData[i]?.nodeState == "notindb") { // Missing from DB table + tooltip.style.borderColor = "rgb(0, 0, 0)"; + } else { // Fallback: All other DEFINED states + tooltip.style.borderColor = "rgb(255, 0, 0)"; + } } + } else { + // No Colors: Use Black + tooltip.style.borderColor = "rgb(0, 0, 0)"; } if (jobsData) { tooltip.textContent = ( // Tooltip Content as String for Job - `Job ID: ${getLegendData(u, i).jobId}\nNodes: ${getLegendData(u, i).numNodes}${getLegendData(u, i)?.numAcc?`\nAccelerators: ${getLegendData(u, i).numAcc}`:''}` + `Job ID: ${getLegendData(u, i).jobId}\nRuntime: ${getLegendData(u, i).duration}\nNodes: ${getLegendData(u, i).numNodes}${getLegendData(u, i)?.numAcc?`\nAccelerators: ${getLegendData(u, i).numAcc}`:''}` ); - } else if (nodesData) { + } else if (nodesData && useColors) { tooltip.textContent = ( // Tooltip Content as String for Node `Host: ${getLegendData(u, i).nodeName}\nState: ${getLegendData(u, i).nodeState}\nJobs: ${getLegendData(u, i).numJobs}` ); + } else if (nodesData && !useColors) { + tooltip.textContent = ( + // Tooltip Content as String for Node + `Host: ${getLegendData(u, i).nodeName}\nJobs: ${getLegendData(u, i).numJobs}` + ); } } @@ -570,7 +592,7 @@ // return prox; // }, // }, - drag: { // Activates Zoom + drag: { // Activates Zoom: Only one Dimension; YX Breaks Zoom Reset (Reason TBD) x: true, y: false }, @@ -725,63 +747,67 @@ u.ctx.lineWidth = 0.15; } - // Jobs: The Color Scale For Time Information - if (jobsData) { - 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() + /* Render Scales */ + if (useColors) { + // Jobs: The Color Scale For Time Information + if (jobsData) { + const posX = u.valToPos(0.1, "x", true) + const posXLimit = u.valToPos(100, "x", true) + const posY = u.valToPos(17500.0, "y", true) + u.ctx.fillStyle = 'black' + u.ctx.fillText('0 Hours', 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('24 Hours', posXLimit + 55, posY) } - u.ctx.fillStyle = 'black' - u.ctx.fillText('Long', posXLimit + 23, posY) - } - // Nodes: The Colors Of NodeStates (Just 3) - if (nodesData) { - const posY = u.valToPos(14000.0, "y", true) + // Nodes: The Colors Of NodeStates + if (nodesData) { + const posY = u.valToPos(17500.0, "y", true) - const posAllocDot = u.valToPos(0.1, "x", true) - const posAllocText = posAllocDot + 60 - u.ctx.fillStyle = "rgb(0, 255, 0)" - u.ctx.beginPath() - u.ctx.arc(posAllocDot, posY, 3, 0, Math.PI * 2, false) - u.ctx.fill() - u.ctx.fillStyle = 'black' - u.ctx.fillText('Allocated', posAllocText, posY) + const posAllocDot = u.valToPos(0.03, "x", true) + const posAllocText = posAllocDot + 60 + const posIdleDot = u.valToPos(0.3, "x", true) + const posIdleText = posIdleDot + 30 + const posOtherDot = u.valToPos(3, "x", true) + const posOtherText = posOtherDot + 40 + const posMissingDot = u.valToPos(30, "x", true) + const posMissingText = posMissingDot + 80 - const posIdleDot = posAllocDot + 150 - const posIdleText = posAllocText + 120 - u.ctx.fillStyle = "rgb(0, 0, 255)" - u.ctx.beginPath() - u.ctx.arc(posIdleDot, posY, 3, 0, Math.PI * 2, false) - u.ctx.fill() - u.ctx.fillStyle = 'black' - u.ctx.fillText('Idle', posIdleText, posY) + u.ctx.fillStyle = "rgb(0, 255, 0)" + u.ctx.beginPath() + u.ctx.arc(posAllocDot, posY, 3, 0, Math.PI * 2, false) + u.ctx.fill() + u.ctx.fillStyle = 'black' + u.ctx.fillText('Allocated', posAllocText, posY) - const posOtherDot = posIdleDot + 150 - const posOtherText = posIdleText + 160 - u.ctx.fillStyle = "rgb(255, 0, 0)" - u.ctx.beginPath() - u.ctx.arc(posOtherDot, posY, 3, 0, Math.PI * 2, false) - u.ctx.fill() - u.ctx.fillStyle = 'black' - u.ctx.fillText('Other', posOtherText, posY) + u.ctx.fillStyle = "rgb(0, 0, 255)" + u.ctx.beginPath() + u.ctx.arc(posIdleDot, posY, 3, 0, Math.PI * 2, false) + u.ctx.fill() + u.ctx.fillStyle = 'black' + u.ctx.fillText('Idle', posIdleText, posY) - const posMissingDot = posOtherDot + 150 - const posMissingText = posOtherText + 190 - u.ctx.fillStyle = 'black' - u.ctx.beginPath() - u.ctx.arc(posMissingDot, posY, 3, 0, Math.PI * 2, false) - u.ctx.fill() - u.ctx.fillText('Missing in DB', posMissingText, posY) + u.ctx.fillStyle = "rgb(255, 0, 0)" + u.ctx.beginPath() + u.ctx.arc(posOtherDot, posY, 3, 0, Math.PI * 2, false) + u.ctx.fill() + u.ctx.fillStyle = 'black' + u.ctx.fillText('Other', posOtherText, posY) + + u.ctx.fillStyle = 'black' + u.ctx.beginPath() + u.ctx.arc(posMissingDot, posY, 3, 0, Math.PI * 2, false) + u.ctx.fill() + u.ctx.fillText('Missing in DB', posMissingText, posY) + } } }, ], diff --git a/web/frontend/src/status/DevelDash.svelte b/web/frontend/src/status/DevelDash.svelte index 8cd4627..a4ee42c 100644 --- a/web/frontend/src/status/DevelDash.svelte +++ b/web/frontend/src/status/DevelDash.svelte @@ -23,6 +23,7 @@ //import Roofline from "../generic/plots/Roofline.svelte"; import NewBubbleRoofline from "../generic/plots/NewBubbleRoofline.svelte"; import Pie, { colors } from "../generic/plots/Pie.svelte"; + import { formatTime } from "../generic/units.js"; /* Svelte 5 Props */ let { @@ -131,7 +132,7 @@ // Load for jobcount per node only -- might me required for total running jobs anyways in parent component! // Also, think about extra query with only TotalJobCount and Items [Resources, ...some meta infos], not including metric data - const paging = { itemsPerPage: 1500, page: 1 }; + const paging = { itemsPerPage: -1, page: 1 }; const sorting = { field: "startTime", type: "col", order: "DESC" }; const filter = [ { cluster: { eq: cluster } }, @@ -281,7 +282,7 @@ 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} }) + return subclusterData.map((sc) => { return {id: sc.id, jobId: sc.jobId, numNodes: sc.numNodes, numAcc: sc?.numAccelerators? sc.numAccelerators : 0, duration: formatTime(sc.duration)} }) } else { console.warn("transformJobsStatsToInfo: jobInfo missing!") return [] diff --git a/web/frontend/src/status/StatusDash.svelte b/web/frontend/src/status/StatusDash.svelte index a1196e5..102026c 100644 --- a/web/frontend/src/status/StatusDash.svelte +++ b/web/frontend/src/status/StatusDash.svelte @@ -15,7 +15,7 @@ CardBody, Table, Progress, - Icon, + // Icon, } from "@sveltestrap/sveltestrap"; import { queryStore, @@ -24,11 +24,11 @@ } from "@urql/svelte"; import { init, - transformPerNodeDataForRoofline, + // transformPerNodeDataForRoofline, } from "../generic/utils.js"; - import { scaleNumbers } from "../generic/units.js"; - import Roofline from "../generic/plots/Roofline.svelte"; + import { scaleNumbers, formatTime } from "../generic/units.js"; + import NewBubbleRoofline from "../generic/plots/NewBubbleRoofline.svelte"; /* Svelte 5 Props */ let { @@ -68,9 +68,12 @@ $metrics: [String!] $from: Time! $to: Time! - $filter: [JobFilter!]! + $jobFilter: [JobFilter!]! + $nodeFilter: [NodeFilter!]! $paging: PageRequest! + $sorting: OrderByInput! ) { + # Node 5 Minute Averages for Roofline nodeMetrics( cluster: $cluster metrics: $metrics @@ -81,27 +84,58 @@ subCluster metrics { name - scope metric { - timestep - unit { - base - prefix - } series { - data + statistics { + avg + } } } } } + # Running Job Metric Average for Rooflines + jobsMetricStats(filter: $jobFilter, metrics: $metrics) { + id + jobId + duration + numNodes + numAccelerators + subCluster + stats { + name + data { + avg + } + } + } + # Get Jobs for Per-Node Counts + jobs(filter: $jobFilter, order: $sorting, page: $paging) { + items { + jobId + resources { + hostname + } + } + count + } # Only counts shared nodes once allocatedNodes(cluster: $cluster) { name count } + # Get States for Node Roofline; $sorting unused in backend: Use placeholder + nodes(filter: $nodeFilter, order: $sorting) { + count + items { + hostname + cluster + subCluster + nodeState + } + } # totalNodes includes multiples if shared jobs jobsStatistics( - filter: $filter + filter: $jobFilter page: $paging sortBy: TOTALJOBS groupBy: SUBCLUSTER @@ -118,8 +152,10 @@ metrics: ["flops_any", "mem_bw"], // Fixed names for roofline and status bars from: from.toISOString(), to: to.toISOString(), - filter: [{ state: ["running"] }, { cluster: { eq: cluster } }], + jobFilter: [{ state: ["running"] }, { cluster: { eq: cluster } }], + nodeFilter: { cluster: { eq: cluster }}, paging: { itemsPerPage: -1, page: 1 }, // Get all: -1 + sorting: { field: "startTime", type: "col", order: "DESC" } }, })); @@ -170,6 +206,7 @@ }); /* Const Functions */ + // New: Sum Up Node Averages const sumUp = (data, subcluster, metric) => data.reduce( (sum, node) => @@ -177,20 +214,132 @@ ? sum + (node.metrics .find((m) => m.name == metric) - ?.metric.series.reduce( - (sum, series) => sum + series.data[series.data.length - 1], - 0, - ) || 0) + ?.metric?.series[0]?.statistics?.avg || 0 + ) : sum, 0, ); + // Old: SumUp Metric Time Data + // const sumUp = (data, subcluster, metric) => + // data.reduce( + // (sum, node) => + // node.subCluster == subcluster + // ? sum + + // (node.metrics + // .find((m) => m.name == metric) + // ?.metric.series.reduce( + // (sum, series) => sum + series.data[series.data.length - 1], + // 0, + // ) || 0) + // : sum, + // 0, + // ); + + /* Functions */ + 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("transformJobsStatsToData: 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 transformNodesStatsToData(subclusterData) { + let data = null + const x = [], y = [] + + if (subclusterData) { + for (let i = 0; i < subclusterData.length; i++) { + const flopsData = subclusterData[i].metrics.find((s) => s.name == "flops_any") + const memBwData = subclusterData[i].metrics.find((s) => s.name == "mem_bw") + + const f = flopsData.metric.series[0].statistics.avg + const m = memBwData.metric.series[0].statistics.avg + + let intensity = f / m + if (Number.isNaN(intensity) || !Number.isFinite(intensity)) { + // continue // Old: Introduces mismatch between Data and Info Arrays + intensity = 0.0 // New: Set to Float Zero: Will not show in Log-Plot (Always below render limit) + } + + x.push(intensity) + y.push(f) + } + } else { + // console.warn("transformNodesStatsToData: metrics for 'mem_bw' and/or 'flops_any' missing!") + } + + if (x.length > 0 && y.length > 0) { + data = [null, [x, y]] // 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, duration: formatTime(sc.duration)} }) + } else { + console.warn("transformJobsStatsToInfo: jobInfo missing!") + return [] + } + } + + function transformNodesStatsToInfo(subClusterData) { + let result = []; + if (subClusterData) { // && $nodesState?.data) { + // Use Nodes as Returned from CCMS, *NOT* as saved in DB via SlurmState-API! + for (let j = 0; j < subClusterData.length; j++) { + // nodesCounts[subClusterData[i].subCluster] = $nodesState.data.nodes.count; // Probably better as own derived! + + const nodeName = subClusterData[j]?.host ? subClusterData[j].host : "unknown" + const nodeMatch = $statusQuery?.data?.nodes?.items?.find((n) => n.hostname == nodeName && n.subCluster == subClusterData[j].subCluster); + const nodeState = nodeMatch?.nodeState ? nodeMatch.nodeState : "notindb" + let numJobs = 0 + + if ($statusQuery?.data) { + const nodeJobs = $statusQuery?.data?.jobs?.items?.filter((job) => job.resources.find((res) => res.hostname == nodeName)) + numJobs = nodeJobs?.length ? nodeJobs.length : 0 + } + + result.push({nodeName: nodeName, nodeState: nodeState, numJobs: numJobs}) + }; + }; + return result + } + {#if $initq.data && $statusQuery.data} {#each $initq.data.clusters.find((c) => c.name == cluster).subClusters as subCluster, i} - + @@ -204,6 +353,25 @@ {activeUsers[subCluster.name]} Active Users
+ + + Flop Rate (Any) + + + Memory BW Rate + + + + + {flopRate[subCluster.name]} + {flopRateUnitPrefix[subCluster.name]}{flopRateUnitBase[subCluster.name]} + + + {memBwRate[subCluster.name]} + {memBwRateUnitPrefix[subCluster.name]}{memBwRateUnitBase[subCluster.name]} + + +
Allocated Nodes {/if} - +
+
- {#key $statusQuery.data.nodeMetrics} - data.subCluster == subCluster.name, + ) + )} + nodesData={transformNodesStatsToInfo($statusQuery?.data?.nodeMetrics.filter( + (data) => data.subCluster == subCluster.name, + ) + )} + /> + {/key} +
+ + +
+ {#key $statusQuery?.data?.jobsMetricStats} + data.subCluster == subCluster.name, - ), + ) + )} + jobsData={transformJobsStatsToInfo($statusQuery?.data?.jobsMetricStats.filter( + (data) => data.subCluster == subCluster.name, + ) )} /> {/key}