mirror of
https://github.com/ClusterCockpit/cc-backend
synced 2025-05-15 05:21:43 +02:00
add and integrate job comparison plot component
This commit is contained in:
parent
1c84bcae35
commit
1d13d3dccf
@ -19,12 +19,13 @@
|
|||||||
queryStore,
|
queryStore,
|
||||||
gql,
|
gql,
|
||||||
getContextClient,
|
getContextClient,
|
||||||
mutationStore,
|
// mutationStore,
|
||||||
} from "@urql/svelte";
|
} from "@urql/svelte";
|
||||||
import { Row, Col, Card, Spinner } from "@sveltestrap/sveltestrap";
|
import { Row, Col, Card, Spinner } from "@sveltestrap/sveltestrap";
|
||||||
|
import Comparogram from "./plots/Comparogram.svelte";
|
||||||
|
|
||||||
const ccconfig = getContext("cc-config"),
|
const ccconfig = getContext("cc-config"),
|
||||||
initialized = getContext("initialized"),
|
// initialized = getContext("initialized"),
|
||||||
globalMetrics = getContext("globalMetrics");
|
globalMetrics = getContext("globalMetrics");
|
||||||
|
|
||||||
const equalsCheck = (a, b) => {
|
const equalsCheck = (a, b) => {
|
||||||
@ -36,6 +37,8 @@
|
|||||||
export let metrics = ccconfig.plot_list_selectedMetrics;
|
export let metrics = ccconfig.plot_list_selectedMetrics;
|
||||||
|
|
||||||
let filter = [...filterBuffer];
|
let filter = [...filterBuffer];
|
||||||
|
let comparePlotData = {};
|
||||||
|
let jobIds = [];
|
||||||
const sorting = { field: "startTime", type: "col", order: "DESC" };
|
const sorting = { field: "startTime", type: "col", order: "DESC" };
|
||||||
|
|
||||||
/* GQL */
|
/* GQL */
|
||||||
@ -58,6 +61,8 @@
|
|||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
/* REACTIVES */
|
||||||
|
|
||||||
$: compareData = queryStore({
|
$: compareData = queryStore({
|
||||||
client: client,
|
client: client,
|
||||||
query: compareQuery,
|
query: compareQuery,
|
||||||
@ -65,6 +70,11 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
$: matchedCompareJobs = $compareData.data != null ? $compareData.data.jobsMetricStats.length : -1;
|
$: matchedCompareJobs = $compareData.data != null ? $compareData.data.jobsMetricStats.length : -1;
|
||||||
|
$: if ($compareData.data != null) {
|
||||||
|
jobIds = [];
|
||||||
|
comparePlotData = {}
|
||||||
|
jobs2uplot($compareData.data.jobsMetricStats, metrics)
|
||||||
|
}
|
||||||
|
|
||||||
/* FUNCTIONS */
|
/* FUNCTIONS */
|
||||||
// Force refresh list with existing unchanged variables (== usually would not trigger reactivity)
|
// 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
|
// Adapt for Persisting Job Selections in DB later down the line
|
||||||
// const updateConfigurationMutation = ({ name, value }) => {
|
// const updateConfigurationMutation = ({ name, value }) => {
|
||||||
// return mutationStore({
|
// return mutationStore({
|
||||||
@ -140,6 +176,18 @@
|
|||||||
</Col>
|
</Col>
|
||||||
</Row>
|
</Row>
|
||||||
{:else}
|
{: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)}
|
{#each $compareData.data.jobsMetricStats as job (job.jobId)}
|
||||||
<Row>
|
<Row>
|
||||||
<Col><b><i>{job.jobId}</i></b></Col>
|
<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