feat: add configurability to frontend plot zoom

This commit is contained in:
Christoph Kluge 2024-09-24 11:13:39 +02:00
parent f1893c596e
commit 21e4870e4c
19 changed files with 165 additions and 88 deletions

View File

@ -8,6 +8,7 @@ import (
"context" "context"
"errors" "errors"
"fmt" "fmt"
"slices"
"strconv" "strconv"
"strings" "strings"
"time" "time"
@ -225,8 +226,8 @@ func (r *queryResolver) Job(ctx context.Context, id string) (*schema.Job, error)
// JobMetrics is the resolver for the jobMetrics field. // JobMetrics is the resolver for the jobMetrics field.
func (r *queryResolver) JobMetrics(ctx context.Context, id string, metrics []string, scopes []schema.MetricScope, resolution *int) ([]*model.JobMetricWithName, error) { func (r *queryResolver) JobMetrics(ctx context.Context, id string, metrics []string, scopes []schema.MetricScope, resolution *int) ([]*model.JobMetricWithName, error) {
defaultRes := 600 if resolution == nil && config.Keys.EnableResampling != nil {
if resolution == nil { defaultRes := slices.Max(config.Keys.EnableResampling.Resolutions)
resolution = &defaultRes resolution = &defaultRes
} }
@ -445,11 +446,9 @@ func (r *Resolver) Query() generated.QueryResolver { return &queryResolver{r} }
// SubCluster returns generated.SubClusterResolver implementation. // SubCluster returns generated.SubClusterResolver implementation.
func (r *Resolver) SubCluster() generated.SubClusterResolver { return &subClusterResolver{r} } func (r *Resolver) SubCluster() generated.SubClusterResolver { return &subClusterResolver{r} }
type ( type clusterResolver struct{ *Resolver }
clusterResolver struct{ *Resolver } type jobResolver struct{ *Resolver }
jobResolver struct{ *Resolver } type metricValueResolver struct{ *Resolver }
metricValueResolver struct{ *Resolver } type mutationResolver struct{ *Resolver }
mutationResolver struct{ *Resolver } type queryResolver struct{ *Resolver }
queryResolver struct{ *Resolver } type subClusterResolver struct{ *Resolver }
subClusterResolver struct{ *Resolver }
)

View File

@ -13,6 +13,7 @@ import (
"strings" "strings"
"time" "time"
"github.com/ClusterCockpit/cc-backend/internal/config"
"github.com/ClusterCockpit/cc-backend/internal/graph/model" "github.com/ClusterCockpit/cc-backend/internal/graph/model"
"github.com/ClusterCockpit/cc-backend/internal/repository" "github.com/ClusterCockpit/cc-backend/internal/repository"
"github.com/ClusterCockpit/cc-backend/internal/util" "github.com/ClusterCockpit/cc-backend/internal/util"
@ -277,6 +278,7 @@ func SetupRoutes(router *mux.Router, buildInfo web.Build) {
Roles: availableRoles, Roles: availableRoles,
Build: buildInfo, Build: buildInfo,
Config: conf, Config: conf,
Resampling: config.Keys.EnableResampling,
Infos: infos, Infos: infos,
} }

View File

@ -76,6 +76,13 @@ type Retention struct {
IncludeDB bool `json:"includeDB"` IncludeDB bool `json:"includeDB"`
} }
type ResampleConfig struct {
// Trigger next zoom level at less than this many visible datapoints
Trigger int `json:"trigger"`
// Array of resampling target resolutions, in seconds; Example: [600,300,60]
Resolutions []int `json:"resolutions"`
}
// Format of the configuration (file). See below for the defaults. // Format of the configuration (file). See below for the defaults.
type ProgramConfig struct { type ProgramConfig struct {
// Address where the http (or https) server will listen on (for example: 'localhost:80'). // Address where the http (or https) server will listen on (for example: 'localhost:80').
@ -133,6 +140,9 @@ type ProgramConfig struct {
// be provided! Most options here can be overwritten by the user. // be provided! Most options here can be overwritten by the user.
UiDefaults map[string]interface{} `json:"ui-defaults"` UiDefaults map[string]interface{} `json:"ui-defaults"`
// If exists, will enable dynamic zoom in frontend metric plots using the configured values
EnableResampling *ResampleConfig `json:"enable-resampling"`
// Where to store MachineState files // Where to store MachineState files
MachineStateDir string `json:"machine-state-dir"` MachineStateDir string `json:"machine-state-dir"`

View File

@ -424,6 +424,27 @@
"plot_general_colorscheme", "plot_general_colorscheme",
"plot_list_selectedMetrics" "plot_list_selectedMetrics"
] ]
},
"enable-resampling": {
"description": "Enable dynamic zoom in frontend metric plots.",
"type": "object",
"properties": {
"trigger": {
"description": "Trigger next zoom level at less than this many visible datapoints.",
"type": "integer"
},
"resolutions": {
"description": "Array of resampling target resolutions, in seconds.",
"type": "array",
"items": {
"type": "integer"
}
}
},
"required": [
"trigger",
"resolutions"
]
} }
}, },
"required": [ "required": [

View File

@ -9,6 +9,7 @@ new Config({
username: username username: username
}, },
context: new Map([ context: new Map([
['cc-config', clusterCockpitConfig] ['cc-config', clusterCockpitConfig],
['resampling', resampleConfig]
]) ])
}) })

View File

@ -51,7 +51,5 @@
<Col> <Col>
<EditProject on:reload={getUserList} /> <EditProject on:reload={getUserList} />
</Col> </Col>
<Col>
<Options /> <Options />
</Col>
</Row> </Row>

View File

@ -3,11 +3,13 @@
--> -->
<script> <script>
import { onMount } from "svelte"; import { getContext, onMount } from "svelte";
import { Card, CardBody, CardTitle } from "@sveltestrap/sveltestrap"; import { Col, Card, CardBody, CardTitle } from "@sveltestrap/sveltestrap";
let scrambled; let scrambled;
const resampleConfig = getContext("resampling");
onMount(() => { onMount(() => {
scrambled = window.localStorage.getItem("cc-scramble-names") != null; scrambled = window.localStorage.getItem("cc-scramble-names") != null;
}); });
@ -23,7 +25,8 @@
} }
</script> </script>
<Card class="h-100"> <Col>
<Card class="h-100">
<CardBody> <CardBody>
<CardTitle class="mb-3">Scramble Names / Presentation Mode</CardTitle> <CardTitle class="mb-3">Scramble Names / Presentation Mode</CardTitle>
<input <input
@ -35,4 +38,17 @@
/> />
Active? Active?
</CardBody> </CardBody>
</Card> </Card>
</Col>
{#if resampleConfig}
<Col>
<Card class="h-100">
<CardBody>
<CardTitle class="mb-3">Metric Plot Resampling</CardTitle>
<p>Triggered at {resampleConfig.trigger} datapoints.</p>
<p>Configured resolutions: {resampleConfig.resolutions}</p>
</CardBody>
</Card>
</Col>
{/if}

View File

@ -26,13 +26,16 @@
export let showFootprint; export let showFootprint;
export let triggerMetricRefresh = false; export let triggerMetricRefresh = false;
const resampleConfig = getContext("resampling") || null;
const resampleDefault = resampleConfig ? Math.max(...resampleConfig.resolutions) : 0;
let { id } = job; let { id } = job;
let scopes = job.numNodes == 1 let scopes = job.numNodes == 1
? job.numAcc >= 1 ? job.numAcc >= 1
? ["core", "accelerator"] ? ["core", "accelerator"]
: ["core"] : ["core"]
: ["node"]; : ["node"];
let selectedResolution = 600; let selectedResolution = resampleDefault;
let zoomStates = {}; let zoomStates = {};
const cluster = getContext("clusters").find((c) => c.name == job.cluster); const cluster = getContext("clusters").find((c) => c.name == job.cluster);
@ -69,7 +72,7 @@
`; `;
function handleZoom(detail, metric) { function handleZoom(detail, metric) {
if ( if ( // States have to differ, causes deathloop if just set
(zoomStates[metric]?.x?.min !== detail?.lastZoomState?.x?.min) && (zoomStates[metric]?.x?.min !== detail?.lastZoomState?.x?.min) &&
(zoomStates[metric]?.y?.max !== detail?.lastZoomState?.y?.max) (zoomStates[metric]?.y?.max !== detail?.lastZoomState?.y?.max)
) { ) {
@ -187,7 +190,7 @@
isShared={job.exclusive != 1} isShared={job.exclusive != 1}
numhwthreads={job.numHWThreads} numhwthreads={job.numHWThreads}
numaccs={job.numAcc} numaccs={job.numAcc}
zoomState={zoomStates[metric.data.name]} zoomState={zoomStates[metric.data.name] || null}
/> />
{:else if metric.disabled == true && metric.data} {:else if metric.disabled == true && metric.data}
<Card body color="info" <Card body color="info"

View File

@ -18,6 +18,7 @@
- `forNode Bool?`: If this plot is used for node data display; will ren[data, err := metricdata.LoadNodeData(cluster, metrics, nodes, scopes, from, to, ctx)](https://github.com/ClusterCockpit/cc-backend/blob/9fe7cdca9215220a19930779a60c8afc910276a3/internal/graph/schema.resolvers.go#L391-L392)der x-axis as negative time with $now as maximum [Default: false] - `forNode Bool?`: If this plot is used for node data display; will ren[data, err := metricdata.LoadNodeData(cluster, metrics, nodes, scopes, from, to, ctx)](https://github.com/ClusterCockpit/cc-backend/blob/9fe7cdca9215220a19930779a60c8afc910276a3/internal/graph/schema.resolvers.go#L391-L392)der x-axis as negative time with $now as maximum [Default: false]
- `numhwthreads Number?`: Number of job HWThreads [Default: 0] - `numhwthreads Number?`: Number of job HWThreads [Default: 0]
- `numaccs Number?`: Number of job Accelerators [Default: 0] - `numaccs Number?`: Number of job Accelerators [Default: 0]
- `zoomState Object?`: The last zoom state to preserve on user zoom [Default: null]
--> -->
<script context="module"> <script context="module">
@ -39,7 +40,7 @@
function timeIncrs(timestep, maxX, forNode) { function timeIncrs(timestep, maxX, forNode) {
if (forNode === true) { if (forNode === true) {
return [60, 300, 900, 1800, 3600, 7200, 14400, 21600]; // forNode fixed increments return [60, 120, 240, 300, 360, 480, 600, 900, 1800, 3600, 7200, 14400, 21600]; // forNode fixed increments
} else { } else {
let incrs = []; let incrs = [];
for (let t = timestep; t < maxX; t *= 10) for (let t = timestep; t < maxX; t *= 10)
@ -131,8 +132,6 @@
export let numaccs = 0; export let numaccs = 0;
export let zoomState = null; export let zoomState = null;
// $: console.log('Changed ZoomState for', metric, zoomState)
if (useStatsSeries == null) useStatsSeries = statisticsSeries != null; if (useStatsSeries == null) useStatsSeries = statisticsSeries != null;
if (useStatsSeries == false && series == null) useStatsSeries = true; if (useStatsSeries == false && series == null) useStatsSeries = true;
@ -160,6 +159,17 @@
numaccs numaccs
); );
const resampleConfig = getContext("resampling");
let resampleTrigger;
let resampleResolutions;
let resampleMinimum;
if (resampleConfig) {
resampleTrigger = Number(resampleConfig.trigger)
resampleResolutions = [...resampleConfig.resolutions];
resampleMinimum = Math.min(...resampleConfig.resolutions);
}
// converts the legend into a simple tooltip // converts the legend into a simple tooltip
function legendAsTooltipPlugin({ function legendAsTooltipPlugin({
className, className,
@ -298,7 +308,6 @@
}, },
]; ];
const plotData = [new Array(longestSeries)]; const plotData = [new Array(longestSeries)];
if (forNode === true) { if (forNode === true) {
// Negative Timestamp Buildup // Negative Timestamp Buildup
for (let i = 0; i <= longestSeries; i++) { for (let i = 0; i <= longestSeries; i++) {
@ -319,15 +328,15 @@
plotData.push(statisticsSeries.min); plotData.push(statisticsSeries.min);
plotData.push(statisticsSeries.max); plotData.push(statisticsSeries.max);
plotData.push(statisticsSeries.median); plotData.push(statisticsSeries.median);
// plotData.push(statisticsSeries.mean);
if (forNode === true) { /* deprecated: sparse data handled by uplot */
// timestamp 0 with null value for reversed time axis // if (forNode === true) {
if (plotData[1].length != 0) plotData[1].push(null); // if (plotData[1][-1] != null && plotData[2][-1] != null && plotData[3][-1] != null) {
if (plotData[2].length != 0) plotData[2].push(null); // if (plotData[1].length != 0) plotData[1].push(null);
if (plotData[3].length != 0) plotData[3].push(null); // if (plotData[2].length != 0) plotData[2].push(null);
// if (plotData[4].length != 0) plotData[4].push(null); // if (plotData[3].length != 0) plotData[3].push(null);
} // }
// }
plotSeries.push({ plotSeries.push({
label: "min", label: "min",
@ -347,12 +356,6 @@
width: lineWidth, width: lineWidth,
stroke: "black", stroke: "black",
}); });
// plotSeries.push({
// label: "mean",
// scale: "y",
// width: lineWidth,
// stroke: "blue",
// });
plotBands = [ plotBands = [
{ series: [2, 3], fill: "rgba(0,255,0,0.1)" }, { series: [2, 3], fill: "rgba(0,255,0,0.1)" },
@ -361,7 +364,13 @@
} else { } else {
for (let i = 0; i < series.length; i++) { for (let i = 0; i < series.length; i++) {
plotData.push(series[i].data); plotData.push(series[i].data);
if (forNode === true && plotData[1].length != 0) plotData[1].push(null); // timestamp 0 with null value for reversed time axis /* deprecated: sparse data handled by uplot */
// if (forNode === true && plotData[1].length != 0) {
// if (plotData[1][-1] != null) {
// plotData[1].push(null);
// };
// };
plotSeries.push({ plotSeries.push({
label: label:
scope === "node" scope === "node"
@ -398,6 +407,8 @@
hooks: { hooks: {
init: [ init: [
(u) => { (u) => {
/* IF Zoom Enabled */
if (resampleConfig) {
u.over.addEventListener("dblclick", (e) => { u.over.addEventListener("dblclick", (e) => {
// console.log('Dispatch Reset') // console.log('Dispatch Reset')
dispatch('zoom', { dispatch('zoom', {
@ -407,7 +418,8 @@
} }
}); });
}); });
} };
},
], ],
draw: [ draw: [
(u) => { (u) => {
@ -451,30 +463,32 @@
}, },
], ],
setScale: [ setScale: [
(u, key) => { (u, key) => { // If ZoomResample is Configured && Not System/Node View
if (key === 'x') { if (resampleConfig && !forNode && key === 'x') {
const numX = (u.series[0].idxs[1] - u.series[0].idxs[0]) const numX = (u.series[0].idxs[1] - u.series[0].idxs[0])
if (numX <= 20 && timestep !== 60) { // Zoom IN if not at MAX if (numX <= resampleTrigger && timestep !== resampleMinimum) {
// console.log('Dispatch Zoom') /* Get closest zoom level; prevents multiple iterative zoom requests for big zoom-steps (e.g. 600 -> 300 -> 120 -> 60) */
if (timestep == 600) { // Which resolution to theoretically request to achieve 30 or more visible data points:
dispatch('zoom', { const target = (numX * timestep) / resampleTrigger
newRes: 240, // Which configured resolution actually matches the closest to theoretical target:
lastZoomState: u?.scales const closest = resampleResolutions.reduce(function(prev, curr) {
return (Math.abs(curr - target) < Math.abs(prev - target) ? curr : prev);
}); });
} else if (timestep === 240) { // Prevents non-required dispatches
if (timestep !== closest) {
// console.log('Dispatch Zoom with Res from / to', timestep, closest)
dispatch('zoom', { dispatch('zoom', {
newRes: 60, newRes: closest,
lastZoomState: u?.scales lastZoomState: u?.scales
}); });
} }
} else { } else {
// console.log('Dispatch Update')
dispatch('zoom', { dispatch('zoom', {
lastZoomState: u?.scales lastZoomState: u?.scales
}); });
}
}; };
} };
},
] ]
}, },
scales: { scales: {
@ -507,7 +521,6 @@
opts.width = width; opts.width = width;
opts.height = height; opts.height = height;
if (zoomState) { if (zoomState) {
// console.log('Use last state for uPlot init:', metric, scope, zoomState)
opts.scales = {...zoomState} opts.scales = {...zoomState}
} }
uplot = new uPlot(opts, plotData, plotWrapper); uplot = new uPlot(opts, plotData, plotWrapper);

View File

@ -69,6 +69,7 @@
<InputGroup class="inline-from"> <InputGroup class="inline-from">
<InputGroupText><Icon name="clock-history" /></InputGroupText> <InputGroupText><Icon name="clock-history" /></InputGroupText>
<InputGroupText>Range</InputGroupText>
<select <select
class="form-select" class="form-select"
bind:value={timeRange} bind:value={timeRange}

View File

@ -9,6 +9,7 @@ new Job({
roles: roles roles: roles
}, },
context: new Map([ context: new Map([
['cc-config', clusterCockpitConfig] ['cc-config', clusterCockpitConfig],
['resampling', resampleConfig]
]) ])
}) })

View File

@ -14,6 +14,7 @@
<script> <script>
import { import {
getContext,
createEventDispatcher createEventDispatcher
} from "svelte"; } from "svelte";
import { import {
@ -41,11 +42,14 @@
export let rawData; export let rawData;
export let isShared = false; export let isShared = false;
const resampleConfig = getContext("resampling") || null;
const resampleDefault = resampleConfig ? Math.max(...resampleConfig.resolutions) : 0;
let selectedHost = null; let selectedHost = null;
let error = null; let error = null;
let selectedScope = minScope(scopes); let selectedScope = minScope(scopes);
let selectedResolution; let selectedResolution = null;
let pendingResolution = 600; let pendingResolution = resampleDefault;
let selectedScopeIndex = scopes.findIndex((s) => s == minScope(scopes)); let selectedScopeIndex = scopes.findIndex((s) => s == minScope(scopes));
let patternMatches = false; let patternMatches = false;
let nodeOnly = false; // If, after load-all, still only node scope returned let nodeOnly = false; // If, after load-all, still only node scope returned
@ -112,12 +116,13 @@
selectedResolution = Number(pendingResolution) selectedResolution = Number(pendingResolution)
} else { } else {
if (selectedScope == "load-all") { if (selectedScope == "load-all") {
selectedScopes = [...scopes, "socket", "core", "accelerator"] selectedScopes = [...scopes, "socket", "core", "accelerator"]
} }
if (pendingResolution) {
selectedResolution = Number(pendingResolution) selectedResolution = Number(pendingResolution)
}
metricData = queryStore({ metricData = queryStore({
client: client, client: client,

View File

@ -9,6 +9,7 @@ new Jobs({
roles: roles roles: roles
}, },
context: new Map([ context: new Map([
['cc-config', clusterCockpitConfig] ['cc-config', clusterCockpitConfig],
['resampling', resampleConfig]
]) ])
}) })

View File

@ -8,6 +8,7 @@ new User({
user: userInfos user: userInfos
}, },
context: new Map([ context: new Map([
['cc-config', clusterCockpitConfig] ['cc-config', clusterCockpitConfig],
['resampling', resampleConfig]
]) ])
}) })

View File

@ -12,6 +12,7 @@
const username = {{ .User.Username }}; const username = {{ .User.Username }};
const filterPresets = {{ .FilterPresets }}; const filterPresets = {{ .FilterPresets }};
const clusterCockpitConfig = {{ .Config }}; const clusterCockpitConfig = {{ .Config }};
const resampleConfig = {{ .Resampling }};
</script> </script>
<script src='/build/config.js'></script> <script src='/build/config.js'></script>
{{end}} {{end}}

View File

@ -13,6 +13,7 @@
const clusterCockpitConfig = {{ .Config }}; const clusterCockpitConfig = {{ .Config }};
const authlevel = {{ .User.GetAuthLevel }}; const authlevel = {{ .User.GetAuthLevel }};
const roles = {{ .Roles }}; const roles = {{ .Roles }};
const resampleConfig = {{ .Resampling }};
</script> </script>
<script src='/build/job.js'></script> <script src='/build/job.js'></script>
{{end}} {{end}}

View File

@ -12,6 +12,7 @@
const clusterCockpitConfig = {{ .Config }}; const clusterCockpitConfig = {{ .Config }};
const authlevel = {{ .User.GetAuthLevel }}; const authlevel = {{ .User.GetAuthLevel }};
const roles = {{ .Roles }}; const roles = {{ .Roles }};
const resampleConfig = {{ .Resampling }};
</script> </script>
<script src='/build/jobs.js'></script> <script src='/build/jobs.js'></script>
{{end}} {{end}}

View File

@ -10,6 +10,7 @@
const userInfos = {{ .Infos }}; const userInfos = {{ .Infos }};
const filterPresets = {{ .FilterPresets }}; const filterPresets = {{ .FilterPresets }};
const clusterCockpitConfig = {{ .Config }}; const clusterCockpitConfig = {{ .Config }};
const resampleConfig = {{ .Resampling }};
</script> </script>
<script src='/build/user.js'></script> <script src='/build/user.js'></script>
{{end}} {{end}}

View File

@ -98,6 +98,7 @@ type Page struct {
FilterPresets map[string]interface{} // For pages with the Filter component, this can be used to set initial filters. FilterPresets map[string]interface{} // For pages with the Filter component, this can be used to set initial filters.
Infos map[string]interface{} // For generic use (e.g. username for /monitoring/user/<id>, job id for /monitoring/job/<id>) Infos map[string]interface{} // For generic use (e.g. username for /monitoring/user/<id>, job id for /monitoring/job/<id>)
Config map[string]interface{} // UI settings for the currently logged in user (e.g. line width, ...) Config map[string]interface{} // UI settings for the currently logged in user (e.g. line width, ...)
Resampling *schema.ResampleConfig // If not nil, defines resampling trigger and resolutions
} }
func RenderTemplate(rw http.ResponseWriter, file string, page *Page) { func RenderTemplate(rw http.ResponseWriter, file string, page *Page) {