Replace explicit resampling config with policy based approach

Entire-Checkpoint: f69e38210bb1
This commit is contained in:
2026-03-20 05:34:12 +01:00
parent c0d2d65f96
commit 0069c86e81
9 changed files with 401 additions and 32 deletions

View File

@@ -112,6 +112,8 @@ type ResampleConfig struct {
Resolutions []int `json:"resolutions"`
// Trigger next zoom level at less than this many visible datapoints
Trigger int `json:"trigger"`
// Policy-derived target point count (set dynamically from user preference, not from config.json)
TargetPoints int `json:"targetPoints,omitempty"`
}
type NATSConfig struct {

113
internal/graph/resample.go Normal file
View File

@@ -0,0 +1,113 @@
// Copyright (C) NHR@FAU, University Erlangen-Nuremberg.
// All rights reserved. This file is part of cc-backend.
// Use of this source code is governed by a MIT-style
// license that can be found in the LICENSE file.
package graph
import (
"context"
"strings"
"github.com/ClusterCockpit/cc-backend/internal/graph/model"
"github.com/ClusterCockpit/cc-backend/internal/metricdispatch"
"github.com/ClusterCockpit/cc-backend/internal/repository"
"github.com/ClusterCockpit/cc-backend/pkg/archive"
)
// resolveResolutionFromPolicy reads the user's resample policy preference and
// computes a resolution based on job duration and metric frequency. Returns nil
// if the user has no policy set.
func resolveResolutionFromPolicy(ctx context.Context, duration int64, cluster string, metrics []string) *int {
user := repository.GetUserFromContext(ctx)
if user == nil {
return nil
}
conf, err := repository.GetUserCfgRepo().GetUIConfig(user)
if err != nil {
return nil
}
policyVal, ok := conf["plotConfiguration_resamplePolicy"]
if !ok {
return nil
}
policyStr, ok := policyVal.(string)
if !ok || policyStr == "" {
return nil
}
policy := metricdispatch.ResamplePolicy(policyStr)
targetPoints := metricdispatch.TargetPointsForPolicy(policy)
if targetPoints == 0 {
return nil
}
// Find the smallest metric frequency across the requested metrics
frequency := smallestFrequency(cluster, metrics)
if frequency <= 0 {
return nil
}
res := metricdispatch.ComputeResolution(duration, int64(frequency), targetPoints)
return &res
}
// resolveResampleAlgo returns the resampling algorithm name to use, checking
// the explicit GraphQL parameter first, then the user's preference.
func resolveResampleAlgo(ctx context.Context, resampleAlgo *model.ResampleAlgo) string {
if resampleAlgo != nil {
return strings.ToLower(resampleAlgo.String())
}
user := repository.GetUserFromContext(ctx)
if user == nil {
return ""
}
conf, err := repository.GetUserCfgRepo().GetUIConfig(user)
if err != nil {
return ""
}
algoVal, ok := conf["plotConfiguration_resampleAlgo"]
if !ok {
return ""
}
algoStr, ok := algoVal.(string)
if !ok {
return ""
}
return algoStr
}
// smallestFrequency returns the smallest metric timestep (in seconds) among the
// requested metrics for the given cluster. Falls back to 0 if nothing is found.
func smallestFrequency(cluster string, metrics []string) int {
cl := archive.GetCluster(cluster)
if cl == nil {
return 0
}
minFreq := 0
for _, mc := range cl.MetricConfig {
if len(metrics) > 0 {
found := false
for _, m := range metrics {
if mc.Name == m {
found = true
break
}
}
if !found {
continue
}
}
if minFreq == 0 || mc.Timestep < minFreq {
minFreq = mc.Timestep
}
}
return minFreq
}

View File

@@ -499,26 +499,27 @@ 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, resampleAlgo *model.ResampleAlgo) ([]*model.JobMetricWithName, error) {
if resolution == nil { // Load from Config
if config.Keys.EnableResampling != nil {
defaultRes := slices.Max(config.Keys.EnableResampling.Resolutions)
resolution = &defaultRes
} else { // Set 0 (Loads configured metric timestep)
defaultRes := 0
resolution = &defaultRes
}
}
job, err := r.Query().Job(ctx, id)
if err != nil {
cclog.Warn("Error while querying job for metrics")
return nil, err
}
algoName := ""
if resampleAlgo != nil {
algoName = strings.ToLower(resampleAlgo.String())
// Resolve resolution: explicit param > user policy > global config > 0
if resolution == nil {
resolution = resolveResolutionFromPolicy(ctx, int64(job.Duration), job.Cluster, metrics)
}
if resolution == nil {
if config.Keys.EnableResampling != nil {
defaultRes := slices.Max(config.Keys.EnableResampling.Resolutions)
resolution = &defaultRes
} else {
defaultRes := 0
resolution = &defaultRes
}
}
algoName := resolveResampleAlgo(ctx, resampleAlgo)
data, err := metricdispatch.LoadData(job, metrics, scopes, ctx, *resolution, algoName)
if err != nil {
@@ -878,11 +879,16 @@ func (r *queryResolver) NodeMetrics(ctx context.Context, cluster string, nodes [
// NodeMetricsList is the resolver for the nodeMetricsList field.
func (r *queryResolver) NodeMetricsList(ctx context.Context, cluster string, subCluster string, stateFilter string, nodeFilter string, scopes []schema.MetricScope, metrics []string, from time.Time, to time.Time, page *model.PageRequest, resolution *int, resampleAlgo *model.ResampleAlgo) (*model.NodesResultList, error) {
if resolution == nil { // Load from Config
// Resolve resolution: explicit param > user policy > global config > 0
duration := int64(to.Sub(from).Seconds())
if resolution == nil {
resolution = resolveResolutionFromPolicy(ctx, duration, cluster, metrics)
}
if resolution == nil {
if config.Keys.EnableResampling != nil {
defaultRes := slices.Max(config.Keys.EnableResampling.Resolutions)
resolution = &defaultRes
} else { // Set 0 (Loads configured metric timestep)
} else {
defaultRes := 0
resolution = &defaultRes
}
@@ -906,10 +912,7 @@ func (r *queryResolver) NodeMetricsList(ctx context.Context, cluster string, sub
}
}
algoName := ""
if resampleAlgo != nil {
algoName = strings.ToLower(resampleAlgo.String())
}
algoName := resolveResampleAlgo(ctx, resampleAlgo)
// data -> map hostname:jobdata
data, err := metricdispatch.LoadNodeListData(cluster, subCluster, nodes, metrics, scopes, *resolution, from, to, ctx, algoName)

View File

@@ -0,0 +1,49 @@
// Copyright (C) NHR@FAU, University Erlangen-Nuremberg.
// All rights reserved. This file is part of cc-backend.
// Use of this source code is governed by a MIT-style
// license that can be found in the LICENSE file.
package metricdispatch
import "math"
type ResamplePolicy string
const (
ResamplePolicyLow ResamplePolicy = "low"
ResamplePolicyMedium ResamplePolicy = "medium"
ResamplePolicyHigh ResamplePolicy = "high"
)
// TargetPointsForPolicy returns the target number of data points for a given policy.
func TargetPointsForPolicy(policy ResamplePolicy) int {
switch policy {
case ResamplePolicyLow:
return 200
case ResamplePolicyMedium:
return 500
case ResamplePolicyHigh:
return 1000
default:
return 0
}
}
// ComputeResolution computes the resampling resolution in seconds for a given
// job duration, metric frequency, and target point count. Returns 0 if the
// total number of data points is already at or below targetPoints (no resampling needed).
func ComputeResolution(duration int64, frequency int64, targetPoints int) int {
if frequency <= 0 || targetPoints <= 0 || duration <= 0 {
return 0
}
totalPoints := duration / frequency
if totalPoints <= int64(targetPoints) {
return 0
}
targetRes := math.Ceil(float64(duration) / float64(targetPoints))
// Round up to nearest multiple of frequency
resolution := int(math.Ceil(targetRes/float64(frequency))) * int(frequency)
return resolution
}

View File

@@ -0,0 +1,68 @@
// Copyright (C) NHR@FAU, University Erlangen-Nuremberg.
// All rights reserved. This file is part of cc-backend.
// Use of this source code is governed by a MIT-style
// license that can be found in the LICENSE file.
package metricdispatch
import "testing"
func TestTargetPointsForPolicy(t *testing.T) {
tests := []struct {
policy ResamplePolicy
want int
}{
{ResamplePolicyLow, 200},
{ResamplePolicyMedium, 500},
{ResamplePolicyHigh, 1000},
{ResamplePolicy("unknown"), 0},
{ResamplePolicy(""), 0},
}
for _, tt := range tests {
if got := TargetPointsForPolicy(tt.policy); got != tt.want {
t.Errorf("TargetPointsForPolicy(%q) = %d, want %d", tt.policy, got, tt.want)
}
}
}
func TestComputeResolution(t *testing.T) {
tests := []struct {
name string
duration int64
frequency int64
targetPoints int
want int
}{
// 24h job, 60s frequency, 1440 total points
{"low_24h_60s", 86400, 60, 200, 480},
{"medium_24h_60s", 86400, 60, 500, 180},
{"high_24h_60s", 86400, 60, 1000, 120},
// 2h job, 60s frequency, 120 total points — no resampling needed
{"low_2h_60s", 7200, 60, 200, 0},
{"medium_2h_60s", 7200, 60, 500, 0},
{"high_2h_60s", 7200, 60, 1000, 0},
// Edge: zero/negative inputs
{"zero_duration", 0, 60, 200, 0},
{"zero_frequency", 86400, 0, 200, 0},
{"zero_target", 86400, 60, 0, 0},
{"negative_duration", -100, 60, 200, 0},
// 12h job, 30s frequency, 1440 total points
{"medium_12h_30s", 43200, 30, 500, 90},
// Exact fit: total points == target points
{"exact_fit", 12000, 60, 200, 0},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := ComputeResolution(tt.duration, tt.frequency, tt.targetPoints)
if got != tt.want {
t.Errorf("ComputeResolution(%d, %d, %d) = %d, want %d",
tt.duration, tt.frequency, tt.targetPoints, got, tt.want)
}
})
}
}

View File

@@ -15,6 +15,7 @@ import (
"github.com/ClusterCockpit/cc-backend/internal/config"
"github.com/ClusterCockpit/cc-backend/internal/graph/model"
"github.com/ClusterCockpit/cc-backend/internal/metricdispatch"
"github.com/ClusterCockpit/cc-backend/internal/repository"
"github.com/ClusterCockpit/cc-backend/web"
cclog "github.com/ClusterCockpit/cc-lib/v2/ccLogger"
@@ -493,13 +494,15 @@ func SetupRoutes(router chi.Router, buildInfo web.Build) {
// Get Roles
availableRoles, _ := schema.GetValidRolesMap(user)
resampling := resamplingForUser(conf)
page := web.Page{
Title: title,
User: *user,
Roles: availableRoles,
Build: buildInfo,
Config: conf,
Resampling: config.Keys.EnableResampling,
Resampling: resampling,
Infos: infos,
}
@@ -586,3 +589,32 @@ func HandleSearchBar(rw http.ResponseWriter, r *http.Request, buildInfo web.Buil
web.RenderTemplate(rw, "message.tmpl", &web.Page{Title: "Warning", MsgType: "alert-warning", Message: "Empty search", User: *user, Roles: availableRoles, Build: buildInfo})
}
}
// resamplingForUser returns a ResampleConfig that incorporates the user's
// resample policy preference. If the user has a policy set, it creates a
// policy-derived config with targetPoints and trigger. Otherwise falls back
// to the global config.
func resamplingForUser(conf map[string]any) *config.ResampleConfig {
globalCfg := config.Keys.EnableResampling
policyVal, ok := conf["plotConfiguration_resamplePolicy"]
if !ok {
return globalCfg
}
policyStr, ok := policyVal.(string)
if !ok || policyStr == "" {
return globalCfg
}
policy := metricdispatch.ResamplePolicy(policyStr)
targetPoints := metricdispatch.TargetPointsForPolicy(policy)
if targetPoints == 0 {
return globalCfg
}
// Build a policy-derived config: targetPoints + trigger, no resolutions array
return &config.ResampleConfig{
TargetPoints: targetPoints,
Trigger: targetPoints / 4,
}
}

View File

@@ -16,6 +16,7 @@
Card,
CardTitle,
} from "@sveltestrap/sveltestrap";
import { getContext } from "svelte";
import { fade } from "svelte/transition";
/* Svelte 5 Props */
@@ -25,6 +26,9 @@
displayMessage = $bindable(),
updateSetting
} = $props();
const resampleConfig = getContext("resampling");
const resamplingEnabled = !!resampleConfig;
</script>
<Row cols={3} class="p-2 g-2">
@@ -219,4 +223,92 @@
</form>
</Card>
</Col>
{#if resamplingEnabled}
<!-- RESAMPLE POLICY -->
<Col>
<Card class="h-100">
<form
id="resample-policy-form"
method="post"
action="/frontend/configuration/"
class="card-body"
onsubmit={(e) => updateSetting(e, {
selector: "#resample-policy-form",
target: "rsp",
})}
>
<CardTitle
style="margin-bottom: 1em; display: flex; align-items: center;"
>
<div>Resample Policy</div>
{#if displayMessage && message.target == "rsp"}
<div style="margin-left: auto; font-size: 0.9em;">
<code style="color: {message.color};" out:fade>
Update: {message.msg}
</code>
</div>
{/if}
</CardTitle>
<input type="hidden" name="key" value="plotConfiguration_resamplePolicy" />
<div class="mb-3">
{#each [["", "Default"], ["low", "Low"], ["medium", "Medium"], ["high", "High"]] as [val, label]}
<div>
<input type="radio" id="rsp-{val || 'default'}" name="value" value={JSON.stringify(val)}
checked={(!config.plotConfiguration_resamplePolicy && val === "") || config.plotConfiguration_resamplePolicy === val} />
<label for="rsp-{val || 'default'}">{label}</label>
</div>
{/each}
<div id="resamplePolicyHelp" class="form-text">
Controls how many data points are shown in metric plots. Low = fast overview (~200 points), Medium = balanced (~500), High = maximum detail (~1000).
</div>
</div>
<Button color="primary" type="submit">Submit</Button>
</form>
</Card>
</Col>
<!-- RESAMPLE ALGORITHM -->
<Col>
<Card class="h-100">
<form
id="resample-algo-form"
method="post"
action="/frontend/configuration/"
class="card-body"
onsubmit={(e) => updateSetting(e, {
selector: "#resample-algo-form",
target: "rsa",
})}
>
<CardTitle
style="margin-bottom: 1em; display: flex; align-items: center;"
>
<div>Resample Algorithm</div>
{#if displayMessage && message.target == "rsa"}
<div style="margin-left: auto; font-size: 0.9em;">
<code style="color: {message.color};" out:fade>
Update: {message.msg}
</code>
</div>
{/if}
</CardTitle>
<input type="hidden" name="key" value="plotConfiguration_resampleAlgo" />
<div class="mb-3">
{#each [["", "Default"], ["lttb", "LTTB"], ["average", "Average"], ["simple", "Simple"]] as [val, label]}
<div>
<input type="radio" id="rsa-{val || 'default'}" name="value" value={JSON.stringify(val)}
checked={(!config.plotConfiguration_resampleAlgo && val === "") || config.plotConfiguration_resampleAlgo === val} />
<label for="rsa-{val || 'default'}">{label}</label>
</div>
{/each}
<div id="resampleAlgoHelp" class="form-text">
Algorithm used when downsampling time-series data. LTTB preserves visual shape, Average smooths data, Simple picks every Nth point.
</div>
</div>
<Button color="primary" type="submit">Submit</Button>
</form>
</Card>
</Col>
{/if}
</Row>

View File

@@ -73,9 +73,10 @@
const subClusterTopology = $derived(getContext("getHardwareTopology")(cluster, subCluster));
const metricConfig = $derived(getContext("getMetricConfig")(cluster, subCluster, metric));
const usesMeanStatsSeries = $derived((statisticsSeries?.mean && statisticsSeries.mean.length != 0));
const resampleTrigger = $derived(resampleConfig?.trigger ? Number(resampleConfig.trigger) : null);
const resampleTrigger = $derived(resampleConfig?.trigger ? Number(resampleConfig.trigger) : (resampleConfig?.targetPoints ? Math.floor(resampleConfig.targetPoints / 4) : null));
const resampleResolutions = $derived(resampleConfig?.resolutions ? [...resampleConfig.resolutions] : null);
const resampleMinimum = $derived(resampleConfig?.resolutions ? Math.min(...resampleConfig.resolutions) : null);
const resampleTargetPoints = $derived(resampleConfig?.targetPoints ? Number(resampleConfig.targetPoints) : null);
const useStatsSeries = $derived(!!statisticsSeries); // Display Stats Series By Default if Exists
const thresholds = $derived(findJobAggregationThresholds(
subClusterTopology,
@@ -515,24 +516,29 @@
if (resampleConfig && !forNode && key === 'x') {
const numX = (u.series[0].idxs[1] - u.series[0].idxs[0])
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);
});
let newRes;
if (resampleTargetPoints && !resampleResolutions) {
// Policy-based: compute resolution dynamically from visible window
const visibleDuration = (u.scales.x.max - u.scales.x.min);
const nativeTimestep = metricConfig?.timestep || timestep;
newRes = Math.ceil(visibleDuration / resampleTargetPoints / nativeTimestep) * nativeTimestep;
if (newRes < nativeTimestep) newRes = nativeTimestep;
} else if (resampleResolutions) {
// Array-based: find closest configured resolution
const target = (numX * timestep) / resampleTrigger;
newRes = 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)
if (newRes && timestep !== newRes) {
onZoom({
newRes: closest,
newRes: newRes,
lastZoomState: u?.scales,
lastThreshold: thresholds?.normal
});
}
} else {
// console.log('Dispatch: Zoom Update States')
onZoom({
lastZoomState: u?.scales,
lastThreshold: thresholds?.normal

View File

@@ -72,6 +72,8 @@ type PlotConfiguration struct {
PlotsPerRow int `json:"plots-per-row"`
LineWidth int `json:"line-width"`
ColorScheme []string `json:"color-scheme"`
ResampleAlgo string `json:"resample-algo"`
ResamplePolicy string `json:"resample-policy"`
}
var UIDefaults = WebConfig{
@@ -144,6 +146,8 @@ func Init(rawConfig json.RawMessage) error {
UIDefaultsMap["plotConfiguration_plotsPerRow"] = UIDefaults.PlotConfiguration.PlotsPerRow
UIDefaultsMap["plotConfiguration_lineWidth"] = UIDefaults.PlotConfiguration.LineWidth
UIDefaultsMap["plotConfiguration_colorScheme"] = UIDefaults.PlotConfiguration.ColorScheme
UIDefaultsMap["plotConfiguration_resampleAlgo"] = UIDefaults.PlotConfiguration.ResampleAlgo
UIDefaultsMap["plotConfiguration_resamplePolicy"] = UIDefaults.PlotConfiguration.ResamplePolicy
for _, c := range UIDefaults.MetricConfig.Clusters {
if c.JobListMetrics != nil {