add and integrate job comparison plot component

This commit is contained in:
Christoph Kluge 2025-05-05 11:26:39 +02:00
parent 1c84bcae35
commit 1d13d3dccf
2 changed files with 331 additions and 2 deletions

View File

@ -19,12 +19,13 @@
queryStore,
gql,
getContextClient,
mutationStore,
// mutationStore,
} from "@urql/svelte";
import { Row, Col, Card, Spinner } from "@sveltestrap/sveltestrap";
import Comparogram from "./plots/Comparogram.svelte";
const ccconfig = getContext("cc-config"),
initialized = getContext("initialized"),
// initialized = getContext("initialized"),
globalMetrics = getContext("globalMetrics");
const equalsCheck = (a, b) => {
@ -36,6 +37,8 @@
export let metrics = ccconfig.plot_list_selectedMetrics;
let filter = [...filterBuffer];
let comparePlotData = {};
let jobIds = [];
const sorting = { field: "startTime", type: "col", order: "DESC" };
/* GQL */
@ -58,6 +61,8 @@
}
`;
/* REACTIVES */
$: compareData = queryStore({
client: client,
query: compareQuery,
@ -65,6 +70,11 @@
});
$: matchedCompareJobs = $compareData.data != null ? $compareData.data.jobsMetricStats.length : -1;
$: if ($compareData.data != null) {
jobIds = [];
comparePlotData = {}
jobs2uplot($compareData.data.jobsMetricStats, metrics)
}
/* FUNCTIONS */
// Force refresh list with existing unchanged variables (== usually would not trigger reactivity)
@ -96,6 +106,32 @@
}
}
function jobs2uplot(jobs, metrics) {
// Prep
for (let m of metrics) {
// Get Unit
const rawUnit = globalMetrics.find((gm) => gm.name == m)?.unit
const metricUnit = (rawUnit?.prefix ? rawUnit.prefix : "") + (rawUnit?.base ? rawUnit.base : "")
// Init
comparePlotData[m] = {unit: metricUnit, data: [[],[],[],[]]} // data: [X, Y1, Y2, Y3]
}
// Iterate jobs if exists
if (jobs) {
let plotIndex = 0
jobs.forEach((j) => {
jobIds.push(j.jobId)
for (let s of j.stats) {
comparePlotData[s.name].data[0].push(plotIndex)
comparePlotData[s.name].data[1].push(s.data.min)
comparePlotData[s.name].data[2].push(s.data.avg)
comparePlotData[s.name].data[3].push(s.data.max)
}
plotIndex++
})
}
}
// Adapt for Persisting Job Selections in DB later down the line
// const updateConfigurationMutation = ({ name, value }) => {
// return mutationStore({
@ -140,6 +176,18 @@
</Col>
</Row>
{:else}
{#each metrics as m}
<Comparogram
title={'Compare '+ m}
xlabel="JobIds"
xticks={jobIds}
ylabel={m}
metric={m}
yunit={comparePlotData[m].unit}
data={comparePlotData[m].data}
/>
{/each}
<hr/><hr/>
{#each $compareData.data.jobsMetricStats as job (job.jobId)}
<Row>
<Col><b><i>{job.jobId}</i></b></Col>

View File

@ -0,0 +1,281 @@
<!--
@component Main plot component, based on uPlot; metricdata values by time
Only width/height should change reactively.
Properties:
- `metric String`: The metric name
- `width Number?`: The plot width [Default: 0]
- `height Number?`: The plot height [Default: 300]
- `data [Array]`: The metric data object
- `cluster String`: Cluster name of the parent job / data
- `subCluster String`: Name of the subCluster of the parent job
-->
<script>
import uPlot from "uplot";
import { formatNumber } from "../units.js";
import { getContext, onMount, onDestroy } from "svelte";
import { Card } from "@sveltestrap/sveltestrap";
export let metric;
export let width = 0;
export let height = 300;
export let data;
export let xlabel;
export let xticks;
export let ylabel;
export let yunit;
export let title;
// export let cluster = "";
// export let subCluster = "";
// $: console.log('LABEL:', metric, yunit)
// $: console.log('DATA:', data)
const metricConfig = null // DEBUG FILLER
// const metricConfig = getContext("getMetricConfig")(cluster, subCluster, metric); // Args woher
const clusterCockpitConfig = getContext("cc-config");
const lineWidth = clusterCockpitConfig.plot_general_lineWidth / window.devicePixelRatio;
const cbmode = clusterCockpitConfig?.plot_general_colorblindMode || false;
// UPLOT PLUGIN // converts the legend into a simple tooltip
function legendAsTooltipPlugin({
className,
style = { backgroundColor: "rgba(255, 249, 196, 0.92)", color: "black" },
} = {}) {
let legendEl;
function init(u, opts) {
legendEl = u.root.querySelector(".u-legend");
legendEl.classList.remove("u-inline");
className && legendEl.classList.add(className);
uPlot.assign(legendEl.style, {
minWidth: "100px",
textAlign: "left",
pointerEvents: "none",
display: "none",
position: "absolute",
left: 0,
top: 0,
zIndex: 100,
boxShadow: "2px 2px 10px rgba(0,0,0,0.5)",
...style,
});
// hide series color markers:
const idents = legendEl.querySelectorAll(".u-marker");
for (let i = 0; i < idents.length; i++)
idents[i].style.display = "none";
const overEl = u.over;
overEl.style.overflow = "visible";
// move legend into plot bounds
overEl.appendChild(legendEl);
// show/hide tooltip on enter/exit
overEl.addEventListener("mouseenter", () => {
legendEl.style.display = null;
});
overEl.addEventListener("mouseleave", () => {
legendEl.style.display = "none";
});
// let tooltip exit plot
// overEl.style.overflow = "visible";
}
function update(u) {
const { left, top } = u.cursor;
const width = u?.over?.querySelector(".u-legend")?.offsetWidth ? u.over.querySelector(".u-legend").offsetWidth : 0;
legendEl.style.transform =
"translate(" + (left - width - 15) + "px, " + (top + 15) + "px)";
}
return {
hooks: {
init: init,
setCursor: update,
},
};
}
let maxY = null;
// TODO: Hilfreich!
// if (metricConfig !== null) {
// maxY = data[3].reduce( // Data[3] is JobMaxs
// (max, x) => Math.max(max, x),
// metricConfig.normal,
// ) || metricConfig.normal
// if (maxY >= 10 * metricConfig.peak) {
// // Hard y-range render limit if outliers in series data
// maxY = 10 * metricConfig.peak;
// }
// }
const plotSeries = [
{
label: "JobID",
value: (u, ts, sidx, didx) => {
return xticks[didx];
},
}
];
plotSeries.push({
label: "min",
scale: "y",
width: lineWidth,
stroke: cbmode ? "rgb(0,255,0)" : "red",
});
plotSeries.push({
label: "avg",
scale: "y",
width: lineWidth,
stroke: "black",
});
plotSeries.push({
label: "max",
scale: "y",
width: lineWidth,
stroke: cbmode ? "rgb(0,0,255)" : "green",
});
const plotBands = [
{ series: [3, 2], fill: cbmode ? "rgba(0,0,255,0.1)" : "rgba(0,255,0,0.1)" },
{ series: [2, 1], fill: cbmode ? "rgba(0,255,0,0.1)" : "rgba(255,0,0,0.1)" },
];
const opts = {
width,
height,
title,
plugins: [legendAsTooltipPlugin()],
series: plotSeries,
axes: [
{
scale: "x",
// space: 35,
rotate: 30,
show: true,
label: xlabel,
values(self, splits) {
return splits.map(s => xticks[s]);
}
},
{
scale: "y",
grid: { show: true },
labelFont: "sans-serif",
label: ylabel + ' (' + yunit + ')'
},
],
bands: plotBands,
padding: [5, 10, 0, 0], // 5, 10, -20, 0
hooks: {
draw: [
(u) => {
// Draw plot type label:
let textl = "Jobs min/avg/max";
let textr = "";
u.ctx.save();
u.ctx.textAlign = "start"; // 'end'
u.ctx.fillStyle = "black";
u.ctx.fillText(textl, u.bbox.left + 10, u.bbox.top + 10);
u.ctx.textAlign = "end";
u.ctx.fillStyle = "black";
u.ctx.fillText(
textr,
u.bbox.left + u.bbox.width - 10,
u.bbox.top + 10,
);
// u.ctx.fillText(text, u.bbox.left + u.bbox.width - 10, u.bbox.top + u.bbox.height - 10) // Recipe for bottom right
if (!metricConfig) {
u.ctx.restore();
return;
}
// TODO: Braucht MetricConf
let y = u.valToPos(metricConfig?.normal, "y", true);
u.ctx.save();
u.ctx.lineWidth = lineWidth;
u.ctx.strokeStyle = "#000000"; // Black
u.ctx.setLineDash([5, 5]);
u.ctx.beginPath();
u.ctx.moveTo(u.bbox.left, y);
u.ctx.lineTo(u.bbox.left + u.bbox.width, y);
u.ctx.stroke();
u.ctx.restore();
},
]
},
scales: {
x: { time: false },
y: maxY ? { min: 0, max: (maxY * 1.1) } : {auto: true}, // Add some space to upper render limit
},
legend: {
// Display legend
show: true,
live: true,
},
cursor: {
drag: { x: true, y: true },
}
};
// RENDER HANDLING
let plotWrapper = null;
let uplot = null;
let timeoutId = null;
function render(ren_width, ren_height) {
if (!uplot) {
opts.width = ren_width;
opts.height = ren_height;
uplot = new uPlot(opts, data, plotWrapper); // Data is uplot formatted [[X][Ymin][Yavg][Ymax]]
} else {
uplot.setSize({ width: ren_width, height: ren_height });
}
}
function onSizeChange(chg_width, chg_height) {
if (!uplot) return;
if (timeoutId != null) clearTimeout(timeoutId);
timeoutId = setTimeout(() => {
timeoutId = null;
render(chg_width, chg_height);
}, 200);
}
onMount(() => {
if (plotWrapper) {
render(width, height);
}
});
onDestroy(() => {
if (timeoutId != null) clearTimeout(timeoutId);
if (uplot) uplot.destroy();
});
// This updates plot on all size changes if wrapper (== data) exists
$: if (plotWrapper) {
onSizeChange(width, height);
}
</script>
<!-- Define $width Wrapper and NoData Card -->
{#if data && data[0].length > 0}
<div bind:this={plotWrapper} bind:clientWidth={width}
style="background-color: rgba(255, 255, 255, 1.0);" class="rounded"
/>
{:else}
<Card body color="warning" class="mx-4"
>Cannot render plot: No series data returned for <code>{metric}</code></Card
>
{/if}