mirror of
https://github.com/ClusterCockpit/cc-backend
synced 2024-12-25 12:59:06 +01:00
feat: add configurability to frontend plot zoom
This commit is contained in:
parent
f1893c596e
commit
21e4870e4c
@ -8,6 +8,7 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"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.
|
||||
func (r *queryResolver) JobMetrics(ctx context.Context, id string, metrics []string, scopes []schema.MetricScope, resolution *int) ([]*model.JobMetricWithName, error) {
|
||||
defaultRes := 600
|
||||
if resolution == nil {
|
||||
if resolution == nil && config.Keys.EnableResampling != nil {
|
||||
defaultRes := slices.Max(config.Keys.EnableResampling.Resolutions)
|
||||
resolution = &defaultRes
|
||||
}
|
||||
|
||||
@ -445,11 +446,9 @@ func (r *Resolver) Query() generated.QueryResolver { return &queryResolver{r} }
|
||||
// SubCluster returns generated.SubClusterResolver implementation.
|
||||
func (r *Resolver) SubCluster() generated.SubClusterResolver { return &subClusterResolver{r} }
|
||||
|
||||
type (
|
||||
clusterResolver struct{ *Resolver }
|
||||
jobResolver struct{ *Resolver }
|
||||
metricValueResolver struct{ *Resolver }
|
||||
mutationResolver struct{ *Resolver }
|
||||
queryResolver struct{ *Resolver }
|
||||
subClusterResolver struct{ *Resolver }
|
||||
)
|
||||
type clusterResolver struct{ *Resolver }
|
||||
type jobResolver struct{ *Resolver }
|
||||
type metricValueResolver struct{ *Resolver }
|
||||
type mutationResolver struct{ *Resolver }
|
||||
type queryResolver struct{ *Resolver }
|
||||
type subClusterResolver struct{ *Resolver }
|
||||
|
@ -13,6 +13,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/ClusterCockpit/cc-backend/internal/config"
|
||||
"github.com/ClusterCockpit/cc-backend/internal/graph/model"
|
||||
"github.com/ClusterCockpit/cc-backend/internal/repository"
|
||||
"github.com/ClusterCockpit/cc-backend/internal/util"
|
||||
@ -272,12 +273,13 @@ func SetupRoutes(router *mux.Router, buildInfo web.Build) {
|
||||
availableRoles, _ := schema.GetValidRolesMap(user)
|
||||
|
||||
page := web.Page{
|
||||
Title: title,
|
||||
User: *user,
|
||||
Roles: availableRoles,
|
||||
Build: buildInfo,
|
||||
Config: conf,
|
||||
Infos: infos,
|
||||
Title: title,
|
||||
User: *user,
|
||||
Roles: availableRoles,
|
||||
Build: buildInfo,
|
||||
Config: conf,
|
||||
Resampling: config.Keys.EnableResampling,
|
||||
Infos: infos,
|
||||
}
|
||||
|
||||
if route.Filter {
|
||||
|
@ -76,6 +76,13 @@ type Retention struct {
|
||||
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.
|
||||
type ProgramConfig struct {
|
||||
// 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.
|
||||
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
|
||||
MachineStateDir string `json:"machine-state-dir"`
|
||||
|
||||
|
@ -424,6 +424,27 @@
|
||||
"plot_general_colorscheme",
|
||||
"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": [
|
||||
|
@ -9,6 +9,7 @@ new Config({
|
||||
username: username
|
||||
},
|
||||
context: new Map([
|
||||
['cc-config', clusterCockpitConfig]
|
||||
['cc-config', clusterCockpitConfig],
|
||||
['resampling', resampleConfig]
|
||||
])
|
||||
})
|
||||
|
@ -51,7 +51,5 @@
|
||||
<Col>
|
||||
<EditProject on:reload={getUserList} />
|
||||
</Col>
|
||||
<Col>
|
||||
<Options />
|
||||
</Col>
|
||||
<Options />
|
||||
</Row>
|
||||
|
@ -3,11 +3,13 @@
|
||||
-->
|
||||
|
||||
<script>
|
||||
import { onMount } from "svelte";
|
||||
import { Card, CardBody, CardTitle } from "@sveltestrap/sveltestrap";
|
||||
import { getContext, onMount } from "svelte";
|
||||
import { Col, Card, CardBody, CardTitle } from "@sveltestrap/sveltestrap";
|
||||
|
||||
let scrambled;
|
||||
|
||||
const resampleConfig = getContext("resampling");
|
||||
|
||||
onMount(() => {
|
||||
scrambled = window.localStorage.getItem("cc-scramble-names") != null;
|
||||
});
|
||||
@ -23,16 +25,30 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<Card class="h-100">
|
||||
<CardBody>
|
||||
<CardTitle class="mb-3">Scramble Names / Presentation Mode</CardTitle>
|
||||
<input
|
||||
type="checkbox"
|
||||
id="scramble-names-checkbox"
|
||||
style="margin-right: 1em;"
|
||||
on:click={handleScramble}
|
||||
bind:checked={scrambled}
|
||||
/>
|
||||
Active?
|
||||
</CardBody>
|
||||
</Card>
|
||||
<Col>
|
||||
<Card class="h-100">
|
||||
<CardBody>
|
||||
<CardTitle class="mb-3">Scramble Names / Presentation Mode</CardTitle>
|
||||
<input
|
||||
type="checkbox"
|
||||
id="scramble-names-checkbox"
|
||||
style="margin-right: 1em;"
|
||||
on:click={handleScramble}
|
||||
bind:checked={scrambled}
|
||||
/>
|
||||
Active?
|
||||
</CardBody>
|
||||
</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}
|
||||
|
@ -26,13 +26,16 @@
|
||||
export let showFootprint;
|
||||
export let triggerMetricRefresh = false;
|
||||
|
||||
const resampleConfig = getContext("resampling") || null;
|
||||
const resampleDefault = resampleConfig ? Math.max(...resampleConfig.resolutions) : 0;
|
||||
|
||||
let { id } = job;
|
||||
let scopes = job.numNodes == 1
|
||||
? job.numAcc >= 1
|
||||
? ["core", "accelerator"]
|
||||
: ["core"]
|
||||
: ["node"];
|
||||
let selectedResolution = 600;
|
||||
let selectedResolution = resampleDefault;
|
||||
let zoomStates = {};
|
||||
|
||||
const cluster = getContext("clusters").find((c) => c.name == job.cluster);
|
||||
@ -69,7 +72,7 @@
|
||||
`;
|
||||
|
||||
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]?.y?.max !== detail?.lastZoomState?.y?.max)
|
||||
) {
|
||||
@ -187,7 +190,7 @@
|
||||
isShared={job.exclusive != 1}
|
||||
numhwthreads={job.numHWThreads}
|
||||
numaccs={job.numAcc}
|
||||
zoomState={zoomStates[metric.data.name]}
|
||||
zoomState={zoomStates[metric.data.name] || null}
|
||||
/>
|
||||
{:else if metric.disabled == true && metric.data}
|
||||
<Card body color="info"
|
||||
|
@ -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]
|
||||
- `numhwthreads Number?`: Number of job HWThreads [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">
|
||||
@ -39,7 +40,7 @@
|
||||
|
||||
function timeIncrs(timestep, maxX, forNode) {
|
||||
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 {
|
||||
let incrs = [];
|
||||
for (let t = timestep; t < maxX; t *= 10)
|
||||
@ -131,8 +132,6 @@
|
||||
export let numaccs = 0;
|
||||
export let zoomState = null;
|
||||
|
||||
// $: console.log('Changed ZoomState for', metric, zoomState)
|
||||
|
||||
if (useStatsSeries == null) useStatsSeries = statisticsSeries != null;
|
||||
|
||||
if (useStatsSeries == false && series == null) useStatsSeries = true;
|
||||
@ -160,6 +159,17 @@
|
||||
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
|
||||
function legendAsTooltipPlugin({
|
||||
className,
|
||||
@ -298,7 +308,6 @@
|
||||
},
|
||||
];
|
||||
const plotData = [new Array(longestSeries)];
|
||||
|
||||
if (forNode === true) {
|
||||
// Negative Timestamp Buildup
|
||||
for (let i = 0; i <= longestSeries; i++) {
|
||||
@ -319,15 +328,15 @@
|
||||
plotData.push(statisticsSeries.min);
|
||||
plotData.push(statisticsSeries.max);
|
||||
plotData.push(statisticsSeries.median);
|
||||
// plotData.push(statisticsSeries.mean);
|
||||
|
||||
if (forNode === true) {
|
||||
// timestamp 0 with null value for reversed time axis
|
||||
if (plotData[1].length != 0) plotData[1].push(null);
|
||||
if (plotData[2].length != 0) plotData[2].push(null);
|
||||
if (plotData[3].length != 0) plotData[3].push(null);
|
||||
// if (plotData[4].length != 0) plotData[4].push(null);
|
||||
}
|
||||
/* deprecated: sparse data handled by uplot */
|
||||
// if (forNode === true) {
|
||||
// if (plotData[1][-1] != null && plotData[2][-1] != null && plotData[3][-1] != null) {
|
||||
// if (plotData[1].length != 0) plotData[1].push(null);
|
||||
// if (plotData[2].length != 0) plotData[2].push(null);
|
||||
// if (plotData[3].length != 0) plotData[3].push(null);
|
||||
// }
|
||||
// }
|
||||
|
||||
plotSeries.push({
|
||||
label: "min",
|
||||
@ -347,12 +356,6 @@
|
||||
width: lineWidth,
|
||||
stroke: "black",
|
||||
});
|
||||
// plotSeries.push({
|
||||
// label: "mean",
|
||||
// scale: "y",
|
||||
// width: lineWidth,
|
||||
// stroke: "blue",
|
||||
// });
|
||||
|
||||
plotBands = [
|
||||
{ series: [2, 3], fill: "rgba(0,255,0,0.1)" },
|
||||
@ -361,7 +364,13 @@
|
||||
} else {
|
||||
for (let i = 0; i < series.length; i++) {
|
||||
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({
|
||||
label:
|
||||
scope === "node"
|
||||
@ -398,16 +407,19 @@
|
||||
hooks: {
|
||||
init: [
|
||||
(u) => {
|
||||
u.over.addEventListener("dblclick", (e) => {
|
||||
// console.log('Dispatch Reset')
|
||||
dispatch('zoom', {
|
||||
lastZoomState: {
|
||||
x: { time: false },
|
||||
y: { auto: true }
|
||||
}
|
||||
/* IF Zoom Enabled */
|
||||
if (resampleConfig) {
|
||||
u.over.addEventListener("dblclick", (e) => {
|
||||
// console.log('Dispatch Reset')
|
||||
dispatch('zoom', {
|
||||
lastZoomState: {
|
||||
x: { time: false },
|
||||
y: { auto: true }
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
};
|
||||
},
|
||||
],
|
||||
draw: [
|
||||
(u) => {
|
||||
@ -451,30 +463,32 @@
|
||||
},
|
||||
],
|
||||
setScale: [
|
||||
(u, key) => {
|
||||
if (key === 'x') {
|
||||
(u, key) => { // If ZoomResample is Configured && Not System/Node View
|
||||
if (resampleConfig && !forNode && key === 'x') {
|
||||
const numX = (u.series[0].idxs[1] - u.series[0].idxs[0])
|
||||
if (numX <= 20 && timestep !== 60) { // Zoom IN if not at MAX
|
||||
// console.log('Dispatch Zoom')
|
||||
if (timestep == 600) {
|
||||
if (numX <= resampleTrigger && timestep !== resampleMinimum) {
|
||||
/* Get closest zoom level; prevents multiple iterative zoom requests for big zoom-steps (e.g. 600 -> 300 -> 120 -> 60) */
|
||||
// Which resolution to theoretically request to achieve 30 or more visible data points:
|
||||
const target = (numX * timestep) / resampleTrigger
|
||||
// Which configured resolution actually matches the closest to theoretical target:
|
||||
const closest = resampleResolutions.reduce(function(prev, curr) {
|
||||
return (Math.abs(curr - target) < Math.abs(prev - target) ? curr : prev);
|
||||
});
|
||||
// Prevents non-required dispatches
|
||||
if (timestep !== closest) {
|
||||
// console.log('Dispatch Zoom with Res from / to', timestep, closest)
|
||||
dispatch('zoom', {
|
||||
newRes: 240,
|
||||
lastZoomState: u?.scales
|
||||
});
|
||||
} else if (timestep === 240) {
|
||||
dispatch('zoom', {
|
||||
newRes: 60,
|
||||
newRes: closest,
|
||||
lastZoomState: u?.scales
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// console.log('Dispatch Update')
|
||||
dispatch('zoom', {
|
||||
lastZoomState: u?.scales
|
||||
});
|
||||
}
|
||||
};
|
||||
};
|
||||
}
|
||||
},
|
||||
]
|
||||
},
|
||||
scales: {
|
||||
@ -507,7 +521,6 @@
|
||||
opts.width = width;
|
||||
opts.height = height;
|
||||
if (zoomState) {
|
||||
// console.log('Use last state for uPlot init:', metric, scope, zoomState)
|
||||
opts.scales = {...zoomState}
|
||||
}
|
||||
uplot = new uPlot(opts, plotData, plotWrapper);
|
||||
|
@ -69,6 +69,7 @@
|
||||
|
||||
<InputGroup class="inline-from">
|
||||
<InputGroupText><Icon name="clock-history" /></InputGroupText>
|
||||
<InputGroupText>Range</InputGroupText>
|
||||
<select
|
||||
class="form-select"
|
||||
bind:value={timeRange}
|
||||
|
@ -9,6 +9,7 @@ new Job({
|
||||
roles: roles
|
||||
},
|
||||
context: new Map([
|
||||
['cc-config', clusterCockpitConfig]
|
||||
['cc-config', clusterCockpitConfig],
|
||||
['resampling', resampleConfig]
|
||||
])
|
||||
})
|
||||
|
@ -14,6 +14,7 @@
|
||||
|
||||
<script>
|
||||
import {
|
||||
getContext,
|
||||
createEventDispatcher
|
||||
} from "svelte";
|
||||
import {
|
||||
@ -41,11 +42,14 @@
|
||||
export let rawData;
|
||||
export let isShared = false;
|
||||
|
||||
const resampleConfig = getContext("resampling") || null;
|
||||
const resampleDefault = resampleConfig ? Math.max(...resampleConfig.resolutions) : 0;
|
||||
|
||||
let selectedHost = null;
|
||||
let error = null;
|
||||
let selectedScope = minScope(scopes);
|
||||
let selectedResolution;
|
||||
let pendingResolution = 600;
|
||||
let selectedResolution = null;
|
||||
let pendingResolution = resampleDefault;
|
||||
let selectedScopeIndex = scopes.findIndex((s) => s == minScope(scopes));
|
||||
let patternMatches = false;
|
||||
let nodeOnly = false; // If, after load-all, still only node scope returned
|
||||
@ -112,12 +116,13 @@
|
||||
selectedResolution = Number(pendingResolution)
|
||||
|
||||
} else {
|
||||
|
||||
if (selectedScope == "load-all") {
|
||||
selectedScopes = [...scopes, "socket", "core", "accelerator"]
|
||||
}
|
||||
|
||||
selectedResolution = Number(pendingResolution)
|
||||
if (pendingResolution) {
|
||||
selectedResolution = Number(pendingResolution)
|
||||
}
|
||||
|
||||
metricData = queryStore({
|
||||
client: client,
|
||||
|
@ -9,6 +9,7 @@ new Jobs({
|
||||
roles: roles
|
||||
},
|
||||
context: new Map([
|
||||
['cc-config', clusterCockpitConfig]
|
||||
['cc-config', clusterCockpitConfig],
|
||||
['resampling', resampleConfig]
|
||||
])
|
||||
})
|
||||
|
@ -8,6 +8,7 @@ new User({
|
||||
user: userInfos
|
||||
},
|
||||
context: new Map([
|
||||
['cc-config', clusterCockpitConfig]
|
||||
['cc-config', clusterCockpitConfig],
|
||||
['resampling', resampleConfig]
|
||||
])
|
||||
})
|
||||
|
@ -12,6 +12,7 @@
|
||||
const username = {{ .User.Username }};
|
||||
const filterPresets = {{ .FilterPresets }};
|
||||
const clusterCockpitConfig = {{ .Config }};
|
||||
const resampleConfig = {{ .Resampling }};
|
||||
</script>
|
||||
<script src='/build/config.js'></script>
|
||||
{{end}}
|
@ -13,6 +13,7 @@
|
||||
const clusterCockpitConfig = {{ .Config }};
|
||||
const authlevel = {{ .User.GetAuthLevel }};
|
||||
const roles = {{ .Roles }};
|
||||
const resampleConfig = {{ .Resampling }};
|
||||
</script>
|
||||
<script src='/build/job.js'></script>
|
||||
{{end}}
|
||||
|
@ -12,6 +12,7 @@
|
||||
const clusterCockpitConfig = {{ .Config }};
|
||||
const authlevel = {{ .User.GetAuthLevel }};
|
||||
const roles = {{ .Roles }};
|
||||
const resampleConfig = {{ .Resampling }};
|
||||
</script>
|
||||
<script src='/build/jobs.js'></script>
|
||||
{{end}}
|
||||
|
@ -10,6 +10,7 @@
|
||||
const userInfos = {{ .Infos }};
|
||||
const filterPresets = {{ .FilterPresets }};
|
||||
const clusterCockpitConfig = {{ .Config }};
|
||||
const resampleConfig = {{ .Resampling }};
|
||||
</script>
|
||||
<script src='/build/user.js'></script>
|
||||
{{end}}
|
||||
|
@ -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.
|
||||
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, ...)
|
||||
Resampling *schema.ResampleConfig // If not nil, defines resampling trigger and resolutions
|
||||
}
|
||||
|
||||
func RenderTemplate(rw http.ResponseWriter, file string, page *Page) {
|
||||
|
Loading…
Reference in New Issue
Block a user