mirror of
https://github.com/ClusterCockpit/cc-backend
synced 2025-05-13 20:51:43 +02:00
add and integrate job comparison plot component
This commit is contained in:
parent
1c84bcae35
commit
1d13d3dccf
@ -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>
|
||||
|
281
web/frontend/src/generic/plots/Comparogram.svelte
Normal file
281
web/frontend/src/generic/plots/Comparogram.svelte
Normal 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}
|
Loading…
x
Reference in New Issue
Block a user