mirror of
https://github.com/ClusterCockpit/cc-backend
synced 2026-03-20 23:07:29 +01:00
Replace explicit resampling config with policy based approach
Entire-Checkpoint: f69e38210bb1
This commit is contained in:
@@ -112,6 +112,8 @@ type ResampleConfig struct {
|
|||||||
Resolutions []int `json:"resolutions"`
|
Resolutions []int `json:"resolutions"`
|
||||||
// Trigger next zoom level at less than this many visible datapoints
|
// Trigger next zoom level at less than this many visible datapoints
|
||||||
Trigger int `json:"trigger"`
|
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 {
|
type NATSConfig struct {
|
||||||
|
|||||||
113
internal/graph/resample.go
Normal file
113
internal/graph/resample.go
Normal 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
|
||||||
|
}
|
||||||
@@ -499,26 +499,27 @@ 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, resampleAlgo *model.ResampleAlgo) ([]*model.JobMetricWithName, error) {
|
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)
|
job, err := r.Query().Job(ctx, id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
cclog.Warn("Error while querying job for metrics")
|
cclog.Warn("Error while querying job for metrics")
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
algoName := ""
|
// Resolve resolution: explicit param > user policy > global config > 0
|
||||||
if resampleAlgo != nil {
|
if resolution == nil {
|
||||||
algoName = strings.ToLower(resampleAlgo.String())
|
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)
|
data, err := metricdispatch.LoadData(job, metrics, scopes, ctx, *resolution, algoName)
|
||||||
if err != nil {
|
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.
|
// 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) {
|
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 {
|
if config.Keys.EnableResampling != nil {
|
||||||
defaultRes := slices.Max(config.Keys.EnableResampling.Resolutions)
|
defaultRes := slices.Max(config.Keys.EnableResampling.Resolutions)
|
||||||
resolution = &defaultRes
|
resolution = &defaultRes
|
||||||
} else { // Set 0 (Loads configured metric timestep)
|
} else {
|
||||||
defaultRes := 0
|
defaultRes := 0
|
||||||
resolution = &defaultRes
|
resolution = &defaultRes
|
||||||
}
|
}
|
||||||
@@ -906,10 +912,7 @@ func (r *queryResolver) NodeMetricsList(ctx context.Context, cluster string, sub
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
algoName := ""
|
algoName := resolveResampleAlgo(ctx, resampleAlgo)
|
||||||
if resampleAlgo != nil {
|
|
||||||
algoName = strings.ToLower(resampleAlgo.String())
|
|
||||||
}
|
|
||||||
|
|
||||||
// data -> map hostname:jobdata
|
// data -> map hostname:jobdata
|
||||||
data, err := metricdispatch.LoadNodeListData(cluster, subCluster, nodes, metrics, scopes, *resolution, from, to, ctx, algoName)
|
data, err := metricdispatch.LoadNodeListData(cluster, subCluster, nodes, metrics, scopes, *resolution, from, to, ctx, algoName)
|
||||||
|
|||||||
49
internal/metricdispatch/resamplepolicy.go
Normal file
49
internal/metricdispatch/resamplepolicy.go
Normal 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
|
||||||
|
}
|
||||||
68
internal/metricdispatch/resamplepolicy_test.go
Normal file
68
internal/metricdispatch/resamplepolicy_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,6 +15,7 @@ import (
|
|||||||
|
|
||||||
"github.com/ClusterCockpit/cc-backend/internal/config"
|
"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/metricdispatch"
|
||||||
"github.com/ClusterCockpit/cc-backend/internal/repository"
|
"github.com/ClusterCockpit/cc-backend/internal/repository"
|
||||||
"github.com/ClusterCockpit/cc-backend/web"
|
"github.com/ClusterCockpit/cc-backend/web"
|
||||||
cclog "github.com/ClusterCockpit/cc-lib/v2/ccLogger"
|
cclog "github.com/ClusterCockpit/cc-lib/v2/ccLogger"
|
||||||
@@ -493,13 +494,15 @@ func SetupRoutes(router chi.Router, buildInfo web.Build) {
|
|||||||
// Get Roles
|
// Get Roles
|
||||||
availableRoles, _ := schema.GetValidRolesMap(user)
|
availableRoles, _ := schema.GetValidRolesMap(user)
|
||||||
|
|
||||||
|
resampling := resamplingForUser(conf)
|
||||||
|
|
||||||
page := web.Page{
|
page := web.Page{
|
||||||
Title: title,
|
Title: title,
|
||||||
User: *user,
|
User: *user,
|
||||||
Roles: availableRoles,
|
Roles: availableRoles,
|
||||||
Build: buildInfo,
|
Build: buildInfo,
|
||||||
Config: conf,
|
Config: conf,
|
||||||
Resampling: config.Keys.EnableResampling,
|
Resampling: resampling,
|
||||||
Infos: infos,
|
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})
|
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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -16,6 +16,7 @@
|
|||||||
Card,
|
Card,
|
||||||
CardTitle,
|
CardTitle,
|
||||||
} from "@sveltestrap/sveltestrap";
|
} from "@sveltestrap/sveltestrap";
|
||||||
|
import { getContext } from "svelte";
|
||||||
import { fade } from "svelte/transition";
|
import { fade } from "svelte/transition";
|
||||||
|
|
||||||
/* Svelte 5 Props */
|
/* Svelte 5 Props */
|
||||||
@@ -25,6 +26,9 @@
|
|||||||
displayMessage = $bindable(),
|
displayMessage = $bindable(),
|
||||||
updateSetting
|
updateSetting
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
|
const resampleConfig = getContext("resampling");
|
||||||
|
const resamplingEnabled = !!resampleConfig;
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Row cols={3} class="p-2 g-2">
|
<Row cols={3} class="p-2 g-2">
|
||||||
@@ -219,4 +223,92 @@
|
|||||||
</form>
|
</form>
|
||||||
</Card>
|
</Card>
|
||||||
</Col>
|
</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>
|
</Row>
|
||||||
@@ -73,9 +73,10 @@
|
|||||||
const subClusterTopology = $derived(getContext("getHardwareTopology")(cluster, subCluster));
|
const subClusterTopology = $derived(getContext("getHardwareTopology")(cluster, subCluster));
|
||||||
const metricConfig = $derived(getContext("getMetricConfig")(cluster, subCluster, metric));
|
const metricConfig = $derived(getContext("getMetricConfig")(cluster, subCluster, metric));
|
||||||
const usesMeanStatsSeries = $derived((statisticsSeries?.mean && statisticsSeries.mean.length != 0));
|
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 resampleResolutions = $derived(resampleConfig?.resolutions ? [...resampleConfig.resolutions] : null);
|
||||||
const resampleMinimum = $derived(resampleConfig?.resolutions ? Math.min(...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 useStatsSeries = $derived(!!statisticsSeries); // Display Stats Series By Default if Exists
|
||||||
const thresholds = $derived(findJobAggregationThresholds(
|
const thresholds = $derived(findJobAggregationThresholds(
|
||||||
subClusterTopology,
|
subClusterTopology,
|
||||||
@@ -515,24 +516,29 @@
|
|||||||
if (resampleConfig && !forNode && 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 <= resampleTrigger && timestep !== resampleMinimum) {
|
if (numX <= resampleTrigger && timestep !== resampleMinimum) {
|
||||||
/* Get closest zoom level; prevents multiple iterative zoom requests for big zoom-steps (e.g. 600 -> 300 -> 120 -> 60) */
|
let newRes;
|
||||||
// Which resolution to theoretically request to achieve 30 or more visible data points:
|
if (resampleTargetPoints && !resampleResolutions) {
|
||||||
const target = (numX * timestep) / resampleTrigger
|
// Policy-based: compute resolution dynamically from visible window
|
||||||
// Which configured resolution actually matches the closest to theoretical target:
|
const visibleDuration = (u.scales.x.max - u.scales.x.min);
|
||||||
const closest = resampleResolutions.reduce(function(prev, curr) {
|
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);
|
return (Math.abs(curr - target) < Math.abs(prev - target) ? curr : prev);
|
||||||
});
|
});
|
||||||
|
}
|
||||||
// Prevents non-required dispatches
|
// Prevents non-required dispatches
|
||||||
if (timestep !== closest) {
|
if (newRes && timestep !== newRes) {
|
||||||
// console.log('Dispatch: Zoom with Res from / to', timestep, closest)
|
|
||||||
onZoom({
|
onZoom({
|
||||||
newRes: closest,
|
newRes: newRes,
|
||||||
lastZoomState: u?.scales,
|
lastZoomState: u?.scales,
|
||||||
lastThreshold: thresholds?.normal
|
lastThreshold: thresholds?.normal
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// console.log('Dispatch: Zoom Update States')
|
|
||||||
onZoom({
|
onZoom({
|
||||||
lastZoomState: u?.scales,
|
lastZoomState: u?.scales,
|
||||||
lastThreshold: thresholds?.normal
|
lastThreshold: thresholds?.normal
|
||||||
|
|||||||
@@ -72,6 +72,8 @@ type PlotConfiguration struct {
|
|||||||
PlotsPerRow int `json:"plots-per-row"`
|
PlotsPerRow int `json:"plots-per-row"`
|
||||||
LineWidth int `json:"line-width"`
|
LineWidth int `json:"line-width"`
|
||||||
ColorScheme []string `json:"color-scheme"`
|
ColorScheme []string `json:"color-scheme"`
|
||||||
|
ResampleAlgo string `json:"resample-algo"`
|
||||||
|
ResamplePolicy string `json:"resample-policy"`
|
||||||
}
|
}
|
||||||
|
|
||||||
var UIDefaults = WebConfig{
|
var UIDefaults = WebConfig{
|
||||||
@@ -144,6 +146,8 @@ func Init(rawConfig json.RawMessage) error {
|
|||||||
UIDefaultsMap["plotConfiguration_plotsPerRow"] = UIDefaults.PlotConfiguration.PlotsPerRow
|
UIDefaultsMap["plotConfiguration_plotsPerRow"] = UIDefaults.PlotConfiguration.PlotsPerRow
|
||||||
UIDefaultsMap["plotConfiguration_lineWidth"] = UIDefaults.PlotConfiguration.LineWidth
|
UIDefaultsMap["plotConfiguration_lineWidth"] = UIDefaults.PlotConfiguration.LineWidth
|
||||||
UIDefaultsMap["plotConfiguration_colorScheme"] = UIDefaults.PlotConfiguration.ColorScheme
|
UIDefaultsMap["plotConfiguration_colorScheme"] = UIDefaults.PlotConfiguration.ColorScheme
|
||||||
|
UIDefaultsMap["plotConfiguration_resampleAlgo"] = UIDefaults.PlotConfiguration.ResampleAlgo
|
||||||
|
UIDefaultsMap["plotConfiguration_resamplePolicy"] = UIDefaults.PlotConfiguration.ResamplePolicy
|
||||||
|
|
||||||
for _, c := range UIDefaults.MetricConfig.Clusters {
|
for _, c := range UIDefaults.MetricConfig.Clusters {
|
||||||
if c.JobListMetrics != nil {
|
if c.JobListMetrics != nil {
|
||||||
|
|||||||
Reference in New Issue
Block a user