<persisted-output>
Output too large (44.8KB). Full output saved to: /Users/jan/.claude/projects/-Users-jan-prg-ClusterCockpit-cc-backend/f8d732b5-ee26-4579-bdeb-237fa0cb9261/tool-results/b8xdra4vi.txt

Preview (first 2KB):
commit c0d2d65f9636fe229ab1ca9a023d6b8076352dd4
Author: Jan Eitzinger <jan@moebiusband.org>
Date:   Thu Mar 19 21:16:48 2026 +0100

    Introduce average resampler support

    Fixes #526

    Entire-Checkpoint: 893a1de325b5

diff --git a/api/schema.graphqls b/api/schema.graphqls
index cf8f5273..8981c859 100644
--- a/api/schema.graphqls
+++ b/api/schema.graphqls
@@ -250,6 +250,12 @@ type TimeWeights {
   coreHours: [NullableFloat!]!
 }

+enum ResampleAlgo {
+  LTTB
+  AVERAGE
+  SIMPLE
+}
+
 enum Aggregate {
   USER
   PROJECT
@@ -340,6 +346,7 @@ type Query {
     metrics: [String!]
     scopes: [MetricScope!]
     resolution: Int
+    resampleAlgo: ResampleAlgo
   ): [JobMetricWithName!]!

   jobStats(id: ID!, metrics: [String!]): [NamedStats!]!
@@ -399,6 +406,7 @@ type Query {
     to: Time!
     page: PageRequest
     resolution: Int
+    resampleAlgo: ResampleAlgo
   ): NodesResultList!

   clusterMetrics(
diff --git a/go.mod b/go.mod
index 1e3b7bf1..f830a366 100644
--- a/go.mod
+++ b/go.mod
@@ -7,28 +7,30 @@ tool (
 	github.com/swaggo/swag/cmd/swag
 )

+replace github.com/ClusterCockpit/cc-lib/v2 => ../../prg/CC/cc-lib
+
 require (
 	github.com/99designs/gqlgen v0.17.88
-	github.com/ClusterCockpit/cc-lib/v2 v2.9.1
+	github.com/ClusterCockpit/cc-lib/v2 v2.10.0
 	github.com/ClusterCockpit/cc-line-protocol/v2 v2.4.0
 	github.com/Masterminds/squirrel v1.5.4
-	github.com/aws/aws-sdk-go-v2 v1.41.3
-	github.com/aws/aws-sdk-go-v2/config v1.32.11
-	github.com/aws/aws-sdk-go-v2/credentials v1.19.11
-	github.com/aws/aws-sdk-go-v2/service/s3 v1.97.0
+	github.com/aws/aws-sdk-go-v2 v1.41.4
+	github.com/aws/aws-sdk-go-v2/config v1.32.12
+	github.com/aws/aws-sdk-go-v2/credentials v1.19.12
+	github.com/aws/aws-sdk-go-v2/service/s3 v1.97.1
 	github.com/coreos/go-oidc/v3 v3.17.0
 	github.com/expr-lang/expr v1.17.8
 	github.com/go-chi/chi/v5 v5.2.5
 	github.com/go-chi/cors v1.2.2
 	github.com/go-co-op/gocron/v2 v2.19.1
-	github.com/go-ldap/ldap/v3 v3.4.12
...
</persisted-output>


     1→// Copyright (C) NHR@FAU, University Erlangen-Nuremberg.
     2→// All rights reserved. This file is part of cc-backend.
     3→// Use of this source code is governed by a MIT-style
     4→// license that can be found in the LICENSE file.
     5→
     6→// Package config implements the program configuration data structures, validation and parsing
     7→package config
     8→
     9→import (
    10→	"bytes"
    11→	"encoding/json"
    12→	"time"
    13→
    14→	cclog "github.com/ClusterCockpit/cc-lib/v2/ccLogger"
    15→	"github.com/ClusterCockpit/cc-lib/v2/resampler"
    16→)
    17→
    18→type ProgramConfig struct {
    19→	// Address where the http (or https) server will listen on (for example: 'localhost:80').
    20→	Addr string `json:"addr"`
    21→
    22→	// Addresses from which secured admin API endpoints can be reached, can be wildcard "*"
    23→	APIAllowedIPs []string `json:"api-allowed-ips"`
    24→
    25→	APISubjects *NATSConfig `json:"api-subjects"`
    26→
    27→	// Drop root permissions once .env was read and the port was taken.
    28→	User  string `json:"user"`
    29→	Group string `json:"group"`
    30→
    31→	// Disable authentication (for everything: API, Web-UI, ...)
    32→	DisableAuthentication bool `json:"disable-authentication"`
    33→
    34→	// If `embed-static-files` is true (default), the frontend files are directly
    35→	// embeded into the go binary and expected to be in web/frontend. Only if
    36→	// it is false the files in `static-files` are served instead.
    37→	EmbedStaticFiles bool   `json:"embed-static-files"`
    38→	StaticFiles      string `json:"static-files"`
    39→
    40→	// Path to SQLite database file
    41→	DB string `json:"db"`
    42→
    43→	EnableJobTaggers bool `json:"enable-job-taggers"`
    44→
    45→	// Validate json input against schema
    46→	Validate bool `json:"validate"`
    47→
    48→	// If 0 or empty, the session does not expire!
    49→	SessionMaxAge string `json:"session-max-age"`
    50→
    51→	// If both those options are not empty, use HTTPS using those certificates.
    52→	HTTPSCertFile string `json:"https-cert-file"`
    53→	HTTPSKeyFile  string `json:"https-key-file"`
    54→
    55→	// If not the empty string and `addr` does not end in ":80",
    56→	// redirect every request incoming at port 80 to that url.
    57→	RedirectHTTPTo string `json:"redirect-http-to"`
    58→
    59→	// Where to store MachineState files
    60→	MachineStateDir string `json:"machine-state-dir"`
    61→
    62→	// If not zero, automatically mark jobs as stopped running X seconds longer than their walltime.
    63→	StopJobsExceedingWalltime int `json:"stop-jobs-exceeding-walltime"`
    64→
    65→	// Defines time X in seconds in which jobs are considered to be "short" and will be filtered in specific views.
    66→	ShortRunningJobsDuration int `json:"short-running-jobs-duration"`
    67→
    68→	// Energy Mix CO2 Emission Constant [g/kWh]
    69→	// If entered, displays estimated CO2 emission for job based on jobs totalEnergy
    70→	EmissionConstant int `json:"emission-constant"`
    71→
    72→	// If exists, will enable dynamic zoom in frontend metric plots using the configured values
    73→	EnableResampling *ResampleConfig `json:"resampling"`
    74→
    75→	// Systemd unit name for log viewer (default: "clustercockpit")
    76→	SystemdUnit string `json:"systemd-unit"`
    77→
    78→	// Node state retention configuration
    79→	NodeStateRetention *NodeStateRetention `json:"nodestate-retention"`
    80→
    81→	// Database tuning configuration
    82→	DbConfig *DbConfig `json:"db-config"`
    83→}
    84→
    85→type DbConfig struct {
    86→	CacheSizeMB               int `json:"cache-size-mb"`
    87→	SoftHeapLimitMB           int `json:"soft-heap-limit-mb"`
    88→	MaxOpenConnections        int `json:"max-open-connections"`
    89→	MaxIdleConnections        int `json:"max-idle-connections"`
    90→	ConnectionMaxIdleTimeMins int `json:"max-idle-time-minutes"`
    91→	BusyTimeoutMs             int `json:"busy-timeout-ms"`
    92→}
    93→
    94→type NodeStateRetention struct {
    95→	Policy             string `json:"policy"`      // "delete" or "move"
    96→	Age                int    `json:"age"`         // hours, default 24
    97→	TargetKind         string `json:"target-kind"` // "file" or "s3"
    98→	TargetPath         string `json:"target-path"`
    99→	TargetEndpoint     string `json:"target-endpoint"`
   100→	TargetBucket       string `json:"target-bucket"`
   101→	TargetAccessKey    string `json:"target-access-key"`
   102→	TargetSecretKey    string `json:"target-secret-key"`
   103→	TargetRegion       string `json:"target-region"`
   104→	TargetUsePathStyle bool   `json:"target-use-path-style"`
   105→	MaxFileSizeMB      int    `json:"max-file-size-mb"`
   106→}
   107→
   108→type ResampleConfig struct {
   109→	// Minimum number of points to trigger resampling of data
   110→	MinimumPoints int `json:"minimum-points"`
   111→	// Array of resampling target resolutions, in seconds; Example: [600,300,60]
   112→	Resolutions []int `json:"resolutions"`
   113→	// Trigger next zoom level at less than this many visible datapoints
   114→	Trigger int `json:"trigger"`
   115→	// Policy-derived target point count (set dynamically from user preference, not from config.json)
   116→	TargetPoints int `json:"targetPoints,omitempty"`
   117→}
   118→
   119→type NATSConfig struct {
   120→	SubjectJobEvent  string `json:"subject-job-event"`
   121→	SubjectNodeState string `json:"subject-node-state"`
   122→	JobConcurrency   int    `json:"job-concurrency"`
   123→	NodeConcurrency  int    `json:"node-concurrency"`
   124→}
   125→
   126→type IntRange struct {
   127→	From int `json:"from"`
   128→	To   int `json:"to"`
   129→}
   130→
   131→type TimeRange struct {
   132→	From  *time.Time `json:"from"`
   133→	To    *time.Time `json:"to"`
   134→	Range string     `json:"range,omitempty"`
   135→}
   136→
   137→type FilterRanges struct {
   138→	Duration  *IntRange  `json:"duration"`
   139→	NumNodes  *IntRange  `json:"num-nodes"`
   140→	StartTime *TimeRange `json:"start-time"`
   141→}
   142→
   143→var Keys ProgramConfig = ProgramConfig{
   144→	Addr:                      "localhost:8080",
   145→	EmbedStaticFiles:          true,
   146→	DB:                        "./var/job.db",
   147→	SessionMaxAge:             "168h",
   148→	StopJobsExceedingWalltime: 0,
   149→	ShortRunningJobsDuration:  5 * 60,
   150→}
   151→
   152→func Init(mainConfig json.RawMessage) {
   153→	Validate(configSchema, mainConfig)
   154→	dec := json.NewDecoder(bytes.NewReader(mainConfig))
   155→	dec.DisallowUnknownFields()
   156→	if err := dec.Decode(&Keys); err != nil {
   157→		cclog.Abortf("Config Init: Could not decode config file '%s'.\nError: %s\n", mainConfig, err.Error())
   158→	}
   159→
   160→	if Keys.EnableResampling != nil && Keys.EnableResampling.MinimumPoints > 0 {
   161→		resampler.SetMinimumRequiredPoints(Keys.EnableResampling.MinimumPoints)
   162→	}
   163→}
   164→


     1→// Copyright (C) NHR@FAU, University Erlangen-Nuremberg.
     2→// All rights reserved. This file is part of cc-backend.
     3→// Use of this source code is governed by a MIT-style
     4→// license that can be found in the LICENSE file.
     5→package metricdispatch
     6→
     7→import "math"
     8→
     9→type ResamplePolicy string
    10→
    11→const (
    12→	ResamplePolicyLow    ResamplePolicy = "low"
    13→	ResamplePolicyMedium ResamplePolicy = "medium"
    14→	ResamplePolicyHigh   ResamplePolicy = "high"
    15→)
    16→
    17→// TargetPointsForPolicy returns the target number of data points for a given policy.
    18→func TargetPointsForPolicy(policy ResamplePolicy) int {
    19→	switch policy {
    20→	case ResamplePolicyLow:
    21→		return 200
    22→	case ResamplePolicyMedium:
    23→		return 500
    24→	case ResamplePolicyHigh:
    25→		return 1000
    26→	default:
    27→		return 0
    28→	}
    29→}
    30→
    31→// ComputeResolution computes the resampling resolution in seconds for a given
    32→// job duration, metric frequency, and target point count. Returns 0 if the
    33→// total number of data points is already at or below targetPoints (no resampling needed).
    34→func ComputeResolution(duration int64, frequency int64, targetPoints int) int {
    35→	if frequency <= 0 || targetPoints <= 0 || duration <= 0 {
    36→		return 0
    37→	}
    38→
    39→	totalPoints := duration / frequency
    40→	if totalPoints <= int64(targetPoints) {
    41→		return 0
    42→	}
    43→
    44→	targetRes := math.Ceil(float64(duration) / float64(targetPoints))
    45→	// Round up to nearest multiple of frequency
    46→	resolution := int(math.Ceil(targetRes/float64(frequency))) * int(frequency)
    47→
    48→	return resolution
    49→}
    50→


     1→// Copyright (C) NHR@FAU, University Erlangen-Nuremberg.
     2→// All rights reserved. This file is part of cc-backend.
     3→// Use of this source code is governed by a MIT-style
     4→// license that can be found in the LICENSE file.
     5→package metricdispatch
     6→
     7→import "testing"
     8→
     9→func TestTargetPointsForPolicy(t *testing.T) {
    10→	tests := []struct {
    11→		policy ResamplePolicy
    12→		want   int
    13→	}{
    14→		{ResamplePolicyLow, 200},
    15→		{ResamplePolicyMedium, 500},
    16→		{ResamplePolicyHigh, 1000},
    17→		{ResamplePolicy("unknown"), 0},
    18→		{ResamplePolicy(""), 0},
    19→	}
    20→
    21→	for _, tt := range tests {
    22→		if got := TargetPointsForPolicy(tt.policy); got != tt.want {
    23→			t.Errorf("TargetPointsForPolicy(%q) = %d, want %d", tt.policy, got, tt.want)
    24→		}
    25→	}
    26→}
    27→
    28→func TestComputeResolution(t *testing.T) {
    29→	tests := []struct {
    30→		name         string
    31→		duration     int64
    32→		frequency    int64
    33→		targetPoints int
    34→		want         int
    35→	}{
    36→		// 24h job, 60s frequency, 1440 total points
    37→		{"low_24h_60s", 86400, 60, 200, 480},
    38→		{"medium_24h_60s", 86400, 60, 500, 180},
    39→		{"high_24h_60s", 86400, 60, 1000, 120},
    40→
    41→		// 2h job, 60s frequency, 120 total points — no resampling needed
    42→		{"low_2h_60s", 7200, 60, 200, 0},
    43→		{"medium_2h_60s", 7200, 60, 500, 0},
    44→		{"high_2h_60s", 7200, 60, 1000, 0},
    45→
    46→		// Edge: zero/negative inputs
    47→		{"zero_duration", 0, 60, 200, 0},
    48→		{"zero_frequency", 86400, 0, 200, 0},
    49→		{"zero_target", 86400, 60, 0, 0},
    50→		{"negative_duration", -100, 60, 200, 0},
    51→
    52→		// 12h job, 30s frequency, 1440 total points
    53→		{"medium_12h_30s", 43200, 30, 500, 90},
    54→
    55→		// Exact fit: total points == target points
    56→		{"exact_fit", 12000, 60, 200, 0},
    57→	}
    58→
    59→	for _, tt := range tests {
    60→		t.Run(tt.name, func(t *testing.T) {
    61→			got := ComputeResolution(tt.duration, tt.frequency, tt.targetPoints)
    62→			if got != tt.want {
    63→				t.Errorf("ComputeResolution(%d, %d, %d) = %d, want %d",
    64→					tt.duration, tt.frequency, tt.targetPoints, got, tt.want)
    65→			}
    66→		})
    67→	}
    68→}
    69→


     1→// Copyright (C) NHR@FAU, University Erlangen-Nuremberg.
     2→// All rights reserved. This file is part of cc-backend.
     3→// Use of this source code is governed by a MIT-style
     4→// license that can be found in the LICENSE file.
     5→package graph
     6→
     7→import (
     8→	"context"
     9→	"strings"
    10→
    11→	"github.com/ClusterCockpit/cc-backend/internal/graph/model"
    12→	"github.com/ClusterCockpit/cc-backend/internal/metricdispatch"
    13→	"github.com/ClusterCockpit/cc-backend/internal/repository"
    14→	"github.com/ClusterCockpit/cc-backend/pkg/archive"
    15→)
    16→
    17→// resolveResolutionFromPolicy reads the user's resample policy preference and
    18→// computes a resolution based on job duration and metric frequency. Returns nil
    19→// if the user has no policy set.
    20→func resolveResolutionFromPolicy(ctx context.Context, duration int64, cluster string, metrics []string) *int {
    21→	user := repository.GetUserFromContext(ctx)
    22→	if user == nil {
    23→		return nil
    24→	}
    25→
    26→	conf, err := repository.GetUserCfgRepo().GetUIConfig(user)
    27→	if err != nil {
    28→		return nil
    29→	}
    30→
    31→	policyVal, ok := conf["plotConfiguration_resamplePolicy"]
    32→	if !ok {
    33→		return nil
    34→	}
    35→	policyStr, ok := policyVal.(string)
    36→	if !ok || policyStr == "" {
    37→		return nil
    38→	}
    39→
    40→	policy := metricdispatch.ResamplePolicy(policyStr)
    41→	targetPoints := metricdispatch.TargetPointsForPolicy(policy)
    42→	if targetPoints == 0 {
    43→		return nil
    44→	}
    45→
    46→	// Find the smallest metric frequency across the requested metrics
    47→	frequency := smallestFrequency(cluster, metrics)
    48→	if frequency <= 0 {
    49→		return nil
    50→	}
    51→
    52→	res := metricdispatch.ComputeResolution(duration, int64(frequency), targetPoints)
    53→	return &res
    54→}
    55→
    56→// resolveResampleAlgo returns the resampling algorithm name to use, checking
    57→// the explicit GraphQL parameter first, then the user's preference.
    58→func resolveResampleAlgo(ctx context.Context, resampleAlgo *model.ResampleAlgo) string {
    59→	if resampleAlgo != nil {
    60→		return strings.ToLower(resampleAlgo.String())
    61→	}
    62→
    63→	user := repository.GetUserFromContext(ctx)
    64→	if user == nil {
    65→		return ""
    66→	}
    67→
    68→	conf, err := repository.GetUserCfgRepo().GetUIConfig(user)
    69→	if err != nil {
    70→		return ""
    71→	}
    72→
    73→	algoVal, ok := conf["plotConfiguration_resampleAlgo"]
    74→	if !ok {
    75→		return ""
    76→	}
    77→	algoStr, ok := algoVal.(string)
    78→	if !ok {
    79→		return ""
    80→	}
    81→
    82→	return algoStr
    83→}
    84→
    85→// smallestFrequency returns the smallest metric timestep (in seconds) among the
    86→// requested metrics for the given cluster. Falls back to 0 if nothing is found.
    87→func smallestFrequency(cluster string, metrics []string) int {
    88→	cl := archive.GetCluster(cluster)
    89→	if cl == nil {
    90→		return 0
    91→	}
    92→
    93→	minFreq := 0
    94→	for _, mc := range cl.MetricConfig {
    95→		if len(metrics) > 0 {
    96→			found := false
    97→			for _, m := range metrics {
    98→				if mc.Name == m {
    99→					found = true
   100→					break
   101→				}
   102→			}
   103→			if !found {
   104→				continue
   105→			}
   106→		}
   107→		if minFreq == 0 || mc.Timestep < minFreq {
   108→			minFreq = mc.Timestep
   109→		}
   110→	}
   111→
   112→	return minFreq
   113→}
   114→


     1→// Copyright (C) NHR@FAU, University Erlangen-Nuremberg.
     2→// All rights reserved. This file is part of cc-backend.
     3→// Use of this source code is governed by a MIT-style
     4→// license that can be found in the LICENSE file.
     5→package routerConfig
     6→
     7→import (
     8→	"fmt"
     9→	"net/http"
    10→	"net/url"
    11→	"os"
    12→	"strconv"
    13→	"strings"
    14→	"time"
    15→
    16→	"github.com/ClusterCockpit/cc-backend/internal/config"
    17→	"github.com/ClusterCockpit/cc-backend/internal/graph/model"
    18→	"github.com/ClusterCockpit/cc-backend/internal/metricdispatch"
    19→	"github.com/ClusterCockpit/cc-backend/internal/repository"
    20→	"github.com/ClusterCockpit/cc-backend/web"
    21→	cclog "github.com/ClusterCockpit/cc-lib/v2/ccLogger"
    22→	"github.com/ClusterCockpit/cc-lib/v2/schema"
    23→	"github.com/ClusterCockpit/cc-lib/v2/util"
    24→	"github.com/go-chi/chi/v5"
    25→)
    26→
    27→type InfoType map[string]any
    28→
    29→type Route struct {
    30→	Route    string
    31→	Template string
    32→	Title    string
    33→	Filter   bool
    34→	Setup    func(i InfoType, r *http.Request) InfoType
    35→}
    36→
    37→var routes []Route = []Route{
    38→	{"/", "home.tmpl", "ClusterCockpit", false, setupHomeRoute},
    39→	{"/config", "config.tmpl", "Settings", false, setupConfigRoute},
    40→	{"/monitoring/jobs/", "monitoring/jobs.tmpl", "Jobs - ClusterCockpit", true, func(i InfoType, r *http.Request) InfoType { return i }},
    41→	{"/monitoring/job/{id:[0-9]+}", "monitoring/job.tmpl", "Job <ID> - ClusterCockpit", false, setupJobRoute},
    42→	{"/monitoring/users/", "monitoring/list.tmpl", "Users - ClusterCockpit", true, func(i InfoType, r *http.Request) InfoType { i["listType"] = "USER"; return i }},
    43→	{"/monitoring/projects/", "monitoring/list.tmpl", "Projects - ClusterCockpit", true, func(i InfoType, r *http.Request) InfoType { i["listType"] = "PROJECT"; return i }},
    44→	{"/monitoring/tags/", "monitoring/taglist.tmpl", "Tags - ClusterCockpit", false, setupTaglistRoute},
    45→	{"/monitoring/user/{id}", "monitoring/user.tmpl", "User <ID> - ClusterCockpit", true, setupUserRoute},
    46→	{"/monitoring/systems/{cluster}", "monitoring/systems.tmpl", "Cluster <ID> Node Overview - ClusterCockpit", false, setupClusterOverviewRoute},
    47→	{"/monitoring/systems/list/{cluster}", "monitoring/systems.tmpl", "Cluster <ID> Node List - ClusterCockpit", false, setupClusterListRoute},
    48→	{"/monitoring/systems/list/{cluster}/{subcluster}", "monitoring/systems.tmpl", "Cluster <ID> <SID> Node List - ClusterCockpit", false, setupClusterListRoute},
    49→	{"/monitoring/node/{cluster}/{hostname}", "monitoring/node.tmpl", "Node <ID> - ClusterCockpit", false, setupNodeRoute},
    50→	{"/monitoring/analysis/{cluster}", "monitoring/analysis.tmpl", "Analysis - ClusterCockpit", true, setupAnalysisRoute},
    51→	{"/monitoring/status/{cluster}", "monitoring/status.tmpl", "<ID> Dashboard - ClusterCockpit", false, setupClusterStatusRoute},
    52→	{"/monitoring/status/detail/{cluster}", "monitoring/status.tmpl", "Status of <ID> - ClusterCockpit", false, setupClusterDetailRoute},
    53→	{"/monitoring/dashboard/{cluster}", "monitoring/dashboard.tmpl", "<ID> Dashboard - ClusterCockpit", false, setupDashboardRoute},
    54→	{"/monitoring/logs", "monitoring/logs.tmpl", "Logs - ClusterCockpit", false, func(i InfoType, r *http.Request) InfoType { return i }},
    55→}
    56→
    57→func setupHomeRoute(i InfoType, r *http.Request) InfoType {
    58→	jobRepo := repository.GetJobRepository()
    59→	groupBy := model.AggregateCluster
    60→
    61→	// startJobCount := time.Now()
    62→	stats, err := jobRepo.JobCountGrouped(r.Context(), nil, &groupBy)
    63→	if err != nil {
    64→		cclog.Warnf("failed to count jobs: %s", err.Error())
    65→	}
    66→	// cclog.Infof("Timer HOME ROUTE startJobCount: %s", time.Since(startJobCount))
    67→
    68→	// startRunningJobCount := time.Now()
    69→	stats, err = jobRepo.AddJobCountGrouped(r.Context(), nil, &groupBy, stats, "running")
    70→	if err != nil {
    71→		cclog.Warnf("failed to count running jobs: %s", err.Error())
    72→	}
    73→	// cclog.Infof("Timer HOME ROUTE startRunningJobCount: %s", time.Since(startRunningJobCount))
    74→
    75→	i["clusters"] = stats
    76→
    77→	if util.CheckFileExists("./var/notice.txt") {
    78→		msg, err := os.ReadFile("./var/notice.txt")
    79→		if err != nil {
    80→			cclog.Warnf("failed to read notice.txt file: %s", err.Error())
    81→		} else {
    82→			i["message"] = string(msg)
    83→		}
    84→	}
    85→
    86→	return i
    87→}
    88→
    89→func setupConfigRoute(i InfoType, r *http.Request) InfoType {
    90→	if util.CheckFileExists("./var/notice.txt") {
    91→		msg, err := os.ReadFile("./var/notice.txt")
    92→		if err == nil {
    93→			i["ncontent"] = string(msg)
    94→		}
    95→	}
    96→
    97→	return i
    98→}
    99→
   100→func setupJobRoute(i InfoType, r *http.Request) InfoType {
   101→	i["id"] = chi.URLParam(r, "id")
   102→	if config.Keys.EmissionConstant != 0 {
   103→		i["emission"] = config.Keys.EmissionConstant
   104→	}
   105→	return i
   106→}
   107→
   108→func setupUserRoute(i InfoType, r *http.Request) InfoType {
   109→	username := chi.URLParam(r, "id")
   110→	i["id"] = username
   111→	i["username"] = username
   112→	// TODO: If forbidden (== err exists), redirect to error page
   113→	if user, _ := repository.GetUserRepository().FetchUserInCtx(r.Context(), username); user != nil {
   114→		i["name"] = user.Name
   115→		i["email"] = user.Email
   116→	}
   117→	return i
   118→}
   119→
   120→func setupClusterStatusRoute(i InfoType, r *http.Request) InfoType {
   121→	cluster := chi.URLParam(r, "cluster")
   122→	i["id"] = cluster
   123→	i["cluster"] = cluster
   124→	i["displayType"] = "DASHBOARD"
   125→	return i
   126→}
   127→
   128→func setupClusterDetailRoute(i InfoType, r *http.Request) InfoType {
   129→	cluster := chi.URLParam(r, "cluster")
   130→	i["id"] = cluster
   131→	i["cluster"] = cluster
   132→	i["displayType"] = "DETAILS"
   133→	return i
   134→}
   135→
   136→func setupDashboardRoute(i InfoType, r *http.Request) InfoType {
   137→	cluster := chi.URLParam(r, "cluster")
   138→	i["id"] = cluster
   139→	i["cluster"] = cluster
   140→	i["displayType"] = "PUBLIC" // Used in Main Template
   141→	return i
   142→}
   143→
   144→func setupClusterOverviewRoute(i InfoType, r *http.Request) InfoType {
   145→	cluster := chi.URLParam(r, "cluster")
   146→	i["id"] = cluster
   147→	i["cluster"] = cluster
   148→	i["displayType"] = "OVERVIEW"
   149→
   150→	from, to := r.URL.Query().Get("from"), r.URL.Query().Get("to")
   151→	if from != "" || to != "" {
   152→		i["from"] = from
   153→		i["to"] = to
   154→	}
   155→	return i
   156→}
   157→
   158→func setupClusterListRoute(i InfoType, r *http.Request) InfoType {
   159→	cluster := chi.URLParam(r, "cluster")
   160→	subcluster := chi.URLParam(r, "subcluster")
   161→	i["id"] = cluster
   162→	i["cluster"] = cluster
   163→	i["sid"] = subcluster
   164→	i["subCluster"] = subcluster
   165→	i["displayType"] = "LIST"
   166→
   167→	from, to := r.URL.Query().Get("from"), r.URL.Query().Get("to")
   168→	if from != "" || to != "" {
   169→		i["from"] = from
   170→		i["to"] = to
   171→	}
   172→	return i
   173→}
   174→
   175→func setupNodeRoute(i InfoType, r *http.Request) InfoType {
   176→	cluster := chi.URLParam(r, "cluster")
   177→	hostname := chi.URLParam(r, "hostname")
   178→	i["cluster"] = cluster
   179→	i["hostname"] = hostname
   180→	i["id"] = fmt.Sprintf("%s (%s)", cluster, hostname)
   181→	from, to := r.URL.Query().Get("from"), r.URL.Query().Get("to")
   182→	if from != "" && to != "" {
   183→		i["from"] = from
   184→		i["to"] = to
   185→	}
   186→	return i
   187→}
   188→
   189→func setupAnalysisRoute(i InfoType, r *http.Request) InfoType {
   190→	i["cluster"] = chi.URLParam(r, "cluster")
   191→	return i
   192→}
   193→
   194→func setupTaglistRoute(i InfoType, r *http.Request) InfoType {
   195→	jobRepo := repository.GetJobRepository()
   196→	tags, counts, err := jobRepo.CountTags(repository.GetUserFromContext(r.Context()))
   197→	tagMap := make(map[string][]map[string]any)
   198→	if err != nil {
   199→		cclog.Warnf("GetTags failed: %s", err.Error())
   200→		i["tagmap"] = tagMap
   201→		return i
   202→	}
   203→	// Reduces displayed tags for unauth'd users
   204→	userAuthlevel := repository.GetUserFromContext(r.Context()).GetAuthLevel()
   205→	// Uses tag.ID as second Map-Key component to differentiate tags with identical names
   206→	if userAuthlevel >= 4 { // Support+ : Show tags for all scopes, regardless of count
   207→		for _, tag := range tags {
   208→			tagItem := map[string]any{
   209→				"id":    tag.ID,
   210→				"name":  tag.Name,
   211→				"scope": tag.Scope,
   212→				"count": counts[fmt.Sprint(tag.Type, tag.Name, tag.ID)],
   213→			}
   214→			tagMap[tag.Type] = append(tagMap[tag.Type], tagItem)
   215→		}
   216→	} else if userAuthlevel < 4 && userAuthlevel >= 2 { // User+ : Show global and admin scope only if at least 1 tag used, private scope regardless of count
   217→		for _, tag := range tags {
   218→			tagCount := counts[fmt.Sprint(tag.Type, tag.Name, tag.ID)]
   219→			if ((tag.Scope == "global" || tag.Scope == "admin") && tagCount >= 1) || (tag.Scope != "global" && tag.Scope != "admin") {
   220→				tagItem := map[string]any{
   221→					"id":    tag.ID,
   222→					"name":  tag.Name,
   223→					"scope": tag.Scope,
   224→					"count": tagCount,
   225→				}
   226→				tagMap[tag.Type] = append(tagMap[tag.Type], tagItem)
   227→			}
   228→		}
   229→	} // auth < 2 return nothing for this route
   230→
   231→	i["tagmap"] = tagMap
   232→	return i
   233→}
   234→
   235→// FIXME: Lots of redundant code. Needs refactoring
   236→func buildFilterPresets(query url.Values) map[string]any {
   237→	filterPresets := map[string]any{}
   238→
   239→	if query.Get("cluster") != "" {
   240→		filterPresets["cluster"] = query.Get("cluster")
   241→	}
   242→	if query.Get("partition") != "" {
   243→		filterPresets["partition"] = query.Get("partition")
   244→	}
   245→	if query.Get("project") != "" {
   246→		filterPresets["project"] = query.Get("project")
   247→		filterPresets["projectMatch"] = "eq"
   248→	}
   249→	if query.Get("jobName") != "" {
   250→		filterPresets["jobName"] = query.Get("jobName")
   251→	}
   252→	if len(query["user"]) != 0 {
   253→		if len(query["user"]) == 1 {
   254→			filterPresets["user"] = query.Get("user")
   255→			filterPresets["userMatch"] = "contains"
   256→		} else {
   257→			filterPresets["user"] = query["user"]
   258→			filterPresets["userMatch"] = "in"
   259→		}
   260→	}
   261→	if len(query["state"]) != 0 {
   262→		filterPresets["state"] = query["state"]
   263→	}
   264→	if query.Get("shared") != "" {
   265→		filterPresets["shared"] = query.Get("shared")
   266→	}
   267→	if query.Get("schedule") != "" {
   268→		filterPresets["schedule"] = query.Get("schedule")
   269→	}
   270→	if rawtags, ok := query["tag"]; ok {
   271→		tags := make([]int, len(rawtags))
   272→		for i, tid := range rawtags {
   273→			var err error
   274→			tags[i], err = strconv.Atoi(tid)
   275→			if err != nil {
   276→				tags[i] = -1
   277→			}
   278→		}
   279→		filterPresets["tags"] = tags
   280→	}
   281→	if query.Get("duration") != "" {
   282→		parts := strings.Split(query.Get("duration"), "-")
   283→		if len(parts) == 2 {
   284→			if parts[0] == "lessthan" {
   285→				lt, lte := strconv.Atoi(parts[1])
   286→				if lte == nil {
   287→					filterPresets["duration"] = map[string]int{"lessThan": lt, "from": 0, "to": 0}
   288→				}
   289→			} else if parts[0] == "morethan" {
   290→				mt, mte := strconv.Atoi(parts[1])
   291→				if mte == nil {
   292→					filterPresets["duration"] = map[string]int{"moreThan": mt, "from": 0, "to": 0}
   293→				}
   294→			} else {
   295→				a, e1 := strconv.Atoi(parts[0])
   296→				b, e2 := strconv.Atoi(parts[1])
   297→				if e1 == nil && e2 == nil {
   298→					filterPresets["duration"] = map[string]int{"from": a, "to": b}
   299→				}
   300→			}
   301→		}
   302→	}
   303→	if query.Get("node") != "" {
   304→		filterPresets["node"] = query.Get("node")
   305→	}
   306→	if query.Get("numNodes") != "" {
   307→		parts := strings.Split(query.Get("numNodes"), "-")
   308→		if len(parts) == 2 {
   309→			if parts[0] == "lessthan" {
   310→				lt, lte := strconv.Atoi(parts[1])
   311→				if lte == nil {
   312→					filterPresets["numNodes"] = map[string]int{"from": 1, "to": lt}
   313→				}
   314→			} else if parts[0] == "morethan" {
   315→				mt, mte := strconv.Atoi(parts[1])
   316→				if mte == nil {
   317→					filterPresets["numNodes"] = map[string]int{"from": mt, "to": 0}
   318→				}
   319→			} else {
   320→				a, e1 := strconv.Atoi(parts[0])
   321→				b, e2 := strconv.Atoi(parts[1])
   322→				if e1 == nil && e2 == nil {
   323→					filterPresets["numNodes"] = map[string]int{"from": a, "to": b}
   324→				}
   325→			}
   326→		}
   327→	}
   328→	if query.Get("numHWThreads") != "" {
   329→		parts := strings.Split(query.Get("numHWThreads"), "-")
   330→		if len(parts) == 2 {
   331→			if parts[0] == "lessthan" {
   332→				lt, lte := strconv.Atoi(parts[1])
   333→				if lte == nil {
   334→					filterPresets["numHWThreads"] = map[string]int{"from": 1, "to": lt}
   335→				}
   336→			} else if parts[0] == "morethan" {
   337→				mt, mte := strconv.Atoi(parts[1])
   338→				if mte == nil {
   339→					filterPresets["numHWThreads"] = map[string]int{"from": mt, "to": 0}
   340→				}
   341→			} else {
   342→				a, e1 := strconv.Atoi(parts[0])
   343→				b, e2 := strconv.Atoi(parts[1])
   344→				if e1 == nil && e2 == nil {
   345→					filterPresets["numHWThreads"] = map[string]int{"from": a, "to": b}
   346→				}
   347→			}
   348→		}
   349→	}
   350→	if query.Get("numAccelerators") != "" {
   351→		parts := strings.Split(query.Get("numAccelerators"), "-")
   352→		if len(parts) == 2 {
   353→			if parts[0] == "lessthan" {
   354→				lt, lte := strconv.Atoi(parts[1])
   355→				if lte == nil {
   356→					filterPresets["numAccelerators"] = map[string]int{"from": 1, "to": lt}
   357→				}
   358→			} else if parts[0] == "morethan" {
   359→				mt, mte := strconv.Atoi(parts[1])
   360→				if mte == nil {
   361→					filterPresets["numAccelerators"] = map[string]int{"from": mt, "to": 0}
   362→				}
   363→			} else {
   364→				a, e1 := strconv.Atoi(parts[0])
   365→				b, e2 := strconv.Atoi(parts[1])
   366→				if e1 == nil && e2 == nil {
   367→					filterPresets["numAccelerators"] = map[string]int{"from": a, "to": b}
   368→				}
   369→			}
   370→		}
   371→	}
   372→	if len(query["dbId"]) != 0 {
   373→		filterPresets["dbId"] = query["dbId"]
   374→	}
   375→	if query.Get("jobId") != "" {
   376→		if len(query["jobId"]) == 1 {
   377→			filterPresets["jobId"] = query.Get("jobId")
   378→			filterPresets["jobIdMatch"] = "eq"
   379→		} else {
   380→			filterPresets["jobId"] = query["jobId"]
   381→			filterPresets["jobIdMatch"] = "in"
   382→		}
   383→	}
   384→	if query.Get("arrayJobId") != "" {
   385→		if num, err := strconv.Atoi(query.Get("arrayJobId")); err == nil {
   386→			filterPresets["arrayJobId"] = num
   387→		}
   388→	}
   389→	if query.Get("startTime") != "" {
   390→		parts := strings.Split(query.Get("startTime"), "-")
   391→		if len(parts) == 2 { // Time in seconds, from - to
   392→			a, e1 := strconv.ParseInt(parts[0], 10, 64)
   393→			b, e2 := strconv.ParseInt(parts[1], 10, 64)
   394→			if e1 == nil && e2 == nil {
   395→				filterPresets["startTime"] = map[string]string{
   396→					"from": time.Unix(a, 0).Format(time.RFC3339),
   397→					"to":   time.Unix(b, 0).Format(time.RFC3339),
   398→				}
   399→			}
   400→		} else { // named range
   401→			filterPresets["startTime"] = map[string]string{
   402→				"range": query.Get("startTime"),
   403→			}
   404→		}
   405→	}
   406→	if query.Get("energy") != "" {
   407→		parts := strings.Split(query.Get("energy"), "-")
   408→		if len(parts) == 2 {
   409→			if parts[0] == "lessthan" {
   410→				lt, lte := strconv.Atoi(parts[1])
   411→				if lte == nil {
   412→					filterPresets["energy"] = map[string]int{"from": 1, "to": lt}
   413→				}
   414→			} else if parts[0] == "morethan" {
   415→				mt, mte := strconv.Atoi(parts[1])
   416→				if mte == nil {
   417→					filterPresets["energy"] = map[string]int{"from": mt, "to": 0}
   418→				}
   419→			} else {
   420→				a, e1 := strconv.Atoi(parts[0])
   421→				b, e2 := strconv.Atoi(parts[1])
   422→				if e1 == nil && e2 == nil {
   423→					filterPresets["energy"] = map[string]int{"from": a, "to": b}
   424→				}
   425→			}
   426→		}
   427→	}
   428→	if len(query["stat"]) != 0 {
   429→		statList := make([]map[string]any, 0)
   430→		for _, statEntry := range query["stat"] {
   431→			parts := strings.Split(statEntry, "-")
   432→			if len(parts) == 3 { // Metric Footprint Stat Field, from - to
   433→				if parts[1] == "lessthan" {
   434→					lt, lte := strconv.ParseInt(parts[2], 10, 64)
   435→					if lte == nil {
   436→						statEntry := map[string]any{
   437→							"field": parts[0],
   438→							"from":  1,
   439→							"to":    lt,
   440→						}
   441→						statList = append(statList, statEntry)
   442→					}
   443→				} else if parts[1] == "morethan" {
   444→					mt, mte := strconv.ParseInt(parts[2], 10, 64)
   445→					if mte == nil {
   446→						statEntry := map[string]any{
   447→							"field": parts[0],
   448→							"from":  mt,
   449→							"to":    0,
   450→						}
   451→						statList = append(statList, statEntry)
   452→					}
   453→				} else {
   454→					a, e1 := strconv.ParseInt(parts[1], 10, 64)
   455→					b, e2 := strconv.ParseInt(parts[2], 10, 64)
   456→					if e1 == nil && e2 == nil {
   457→						statEntry := map[string]any{
   458→							"field": parts[0],
   459→							"from":  a,
   460→							"to":    b,
   461→						}
   462→						statList = append(statList, statEntry)
   463→					}
   464→				}
   465→			}
   466→		}
   467→		filterPresets["stats"] = statList
   468→	}
   469→	return filterPresets
   470→}
   471→
   472→func SetupRoutes(router chi.Router, buildInfo web.Build) {
   473→	userCfgRepo := repository.GetUserCfgRepo()
   474→	for _, route := range routes {
   475→		router.HandleFunc(route.Route, func(rw http.ResponseWriter, r *http.Request) {
   476→			conf, err := userCfgRepo.GetUIConfig(repository.GetUserFromContext(r.Context()))
   477→			if err != nil {
   478→				http.Error(rw, err.Error(), http.StatusInternalServerError)
   479→				return
   480→			}
   481→
   482→			title := route.Title
   483→			infos := route.Setup(map[string]any{}, r)
   484→			if id, ok := infos["id"]; ok {
   485→				title = strings.Replace(route.Title, "<ID>", id.(string), 1)
   486→				if sid, ok := infos["sid"]; ok { // 2nd ID element
   487→					title = strings.Replace(title, "<SID>", sid.(string), 1)
   488→				}
   489→			}
   490→
   491→			// Get User -> What if NIL?
   492→			user := repository.GetUserFromContext(r.Context())
   493→
   494→			// Get Roles
   495→			availableRoles, _ := schema.GetValidRolesMap(user)
   496→
   497→			resampling := resamplingForUser(conf)
   498→
   499→			page := web.Page{
   500→				Title:      title,
   501→				User:       *user,
   502→				Roles:      availableRoles,
   503→				Build:      buildInfo,
   504→				Config:     conf,
   505→				Resampling: resampling,
   506→				Infos:      infos,
   507→			}
   508→
   509→			if route.Filter {
   510→				page.FilterPresets = buildFilterPresets(r.URL.Query())
   511→			}
   512→
   513→			web.RenderTemplate(rw, route.Template, &page)
   514→		})
   515→	}
   516→}
   517→
   518→func HandleSearchBar(rw http.ResponseWriter, r *http.Request, buildInfo web.Build) {
   519→	user := repository.GetUserFromContext(r.Context())
   520→	availableRoles, _ := schema.GetValidRolesMap(user)
   521→
   522→	if search := r.URL.Query().Get("searchId"); search != "" {
   523→		repo := repository.GetJobRepository()
   524→		splitSearch := strings.Split(search, ":")
   525→
   526→		if len(splitSearch) == 2 {
   527→			switch strings.Trim(splitSearch[0], " ") {
   528→			case "jobId":
   529→				http.Redirect(rw, r, "/monitoring/jobs/?jobId="+url.QueryEscape(strings.Trim(splitSearch[1], " ")), http.StatusFound) // All Users: Redirect to Tablequery
   530→			case "jobName":
   531→				// Add Last 30 Days to migitate timeouts
   532→				untilTime := strconv.FormatInt(time.Now().Unix(), 10)
   533→				fromTime := strconv.FormatInt((time.Now().Unix() - int64(30*24*3600)), 10)
   534→
   535→				http.Redirect(rw, r, "/monitoring/jobs/?startTime="+fromTime+"-"+untilTime+"&jobName="+url.QueryEscape(strings.Trim(splitSearch[1], " ")), http.StatusFound) // All Users: Redirect to Tablequery
   536→			case "projectId":
   537→				http.Redirect(rw, r, "/monitoring/jobs/?projectMatch=eq&project="+url.QueryEscape(strings.Trim(splitSearch[1], " ")), http.StatusFound) // All Users: Redirect to Tablequery
   538→			case "arrayJobId":
   539→				// Add Last 30 Days to migitate timeouts
   540→				untilTime := strconv.FormatInt(time.Now().Unix(), 10)
   541→				fromTime := strconv.FormatInt((time.Now().Unix() - int64(30*24*3600)), 10)
   542→
   543→				http.Redirect(rw, r, "/monitoring/jobs/?startTime="+fromTime+"-"+untilTime+"&arrayJobId="+url.QueryEscape(strings.Trim(splitSearch[1], " ")), http.StatusFound) // All Users: Redirect to Tablequery
   544→			case "username":
   545→				if user.HasAnyRole([]schema.Role{schema.RoleAdmin, schema.RoleSupport, schema.RoleManager}) {
   546→					http.Redirect(rw, r, "/monitoring/users/?user="+url.QueryEscape(strings.Trim(splitSearch[1], " "))+"&startTime=last30d", http.StatusFound)
   547→				} else {
   548→					web.RenderTemplate(rw, "message.tmpl", &web.Page{Title: "Error", MsgType: "alert-danger", Message: "Missing Access Rights", User: *user, Roles: availableRoles, Build: buildInfo})
   549→				}
   550→			case "name":
   551→				usernames, _ := repo.FindColumnValues(user, strings.Trim(splitSearch[1], " "), "user", "username", "name")
   552→				if len(usernames) != 0 {
   553→					joinedNames := strings.Join(usernames, "&user=")
   554→					http.Redirect(rw, r, "/monitoring/users/?user="+joinedNames+"&startTime=last30d", http.StatusFound)
   555→				} else {
   556→					if user.HasAnyRole([]schema.Role{schema.RoleAdmin, schema.RoleSupport, schema.RoleManager}) {
   557→						http.Redirect(rw, r, "/monitoring/users/?user=NoUserNameFound&startTime=last30d", http.StatusPermanentRedirect)
   558→					} else {
   559→						web.RenderTemplate(rw, "message.tmpl", &web.Page{Title: "Error", MsgType: "alert-danger", Message: "Missing Access Rights", User: *user, Roles: availableRoles, Build: buildInfo})
   560→					}
   561→				}
   562→			default:
   563→				web.RenderTemplate(rw, "message.tmpl", &web.Page{Title: "Warning", MsgType: "alert-warning", Message: fmt.Sprintf("Unknown search type: %s", strings.Trim(splitSearch[0], " ")), User: *user, Roles: availableRoles, Build: buildInfo})
   564→			}
   565→		} else if len(splitSearch) == 1 {
   566→
   567→			jobid, username, project, jobname := repo.FindUserOrProjectOrJobname(user, strings.Trim(search, " "))
   568→
   569→			if jobid != "" {
   570→				http.Redirect(rw, r, "/monitoring/jobs/?jobId="+url.QueryEscape(jobid), http.StatusFound) // JobId (Match)
   571→			} else if username != "" {
   572→				http.Redirect(rw, r, "/monitoring/user/"+username, http.StatusFound) // User: Redirect to user page of first match
   573→			} else if project != "" {
   574→				http.Redirect(rw, r, "/monitoring/jobs/?projectMatch=eq&project="+url.QueryEscape(project), http.StatusFound) // projectId (equal)
   575→			} else if jobname != "" {
   576→				// Add Last 30 Days to migitate timeouts
   577→				untilTime := strconv.FormatInt(time.Now().Unix(), 10)
   578→				fromTime := strconv.FormatInt((time.Now().Unix() - int64(30*24*3600)), 10)
   579→
   580→				http.Redirect(rw, r, "/monitoring/jobs/?startTime="+fromTime+"-"+untilTime+"&jobName="+url.QueryEscape(jobname), http.StatusFound) // 30D Fitler + JobName (contains)
   581→			} else {
   582→				web.RenderTemplate(rw, "message.tmpl", &web.Page{Title: "Info", MsgType: "alert-info", Message: "Search without result", User: *user, Roles: availableRoles, Build: buildInfo})
   583→			}
   584→
   585→		} else {
   586→			web.RenderTemplate(rw, "message.tmpl", &web.Page{Title: "Error", MsgType: "alert-danger", Message: "Searchbar query parameters malformed", User: *user, Roles: availableRoles, Build: buildInfo})
   587→		}
   588→	} else {
   589→		web.RenderTemplate(rw, "message.tmpl", &web.Page{Title: "Warning", MsgType: "alert-warning", Message: "Empty search", User: *user, Roles: availableRoles, Build: buildInfo})
   590→	}
   591→}
   592→
   593→// resamplingForUser returns a ResampleConfig that incorporates the user's
   594→// resample policy preference. If the user has a policy set, it creates a
   595→// policy-derived config with targetPoints and trigger. Otherwise falls back
   596→// to the global config.
   597→func resamplingForUser(conf map[string]any) *config.ResampleConfig {
   598→	globalCfg := config.Keys.EnableResampling
   599→
   600→	policyVal, ok := conf["plotConfiguration_resamplePolicy"]
   601→	if !ok {
   602→		return globalCfg
   603→	}
   604→	policyStr, ok := policyVal.(string)
   605→	if !ok || policyStr == "" {
   606→		return globalCfg
   607→	}
   608→
   609→	policy := metricdispatch.ResamplePolicy(policyStr)
   610→	targetPoints := metricdispatch.TargetPointsForPolicy(policy)
   611→	if targetPoints == 0 {
   612→		return globalCfg
   613→	}
   614→
   615→	// Build a policy-derived config: targetPoints + trigger, no resolutions array
   616→	return &config.ResampleConfig{
   617→		TargetPoints: targetPoints,
   618→		Trigger:      targetPoints / 4,
   619→	}
   620→}
   621→


     1→// Copyright (C) NHR@FAU, University Erlangen-Nuremberg.
     2→// All rights reserved. This file is part of cc-backend.
     3→// Use of this source code is governed by a MIT-style
     4→// license that can be found in the LICENSE file.
     5→
     6→// Package web implements the HTML templating and web frontend configuration
     7→package web
     8→
     9→import (
    10→	"embed"
    11→	"encoding/json"
    12→	"html/template"
    13→	"io/fs"
    14→	"net/http"
    15→	"strings"
    16→
    17→	"github.com/ClusterCockpit/cc-backend/internal/config"
    18→	"github.com/ClusterCockpit/cc-backend/pkg/archive"
    19→	cclog "github.com/ClusterCockpit/cc-lib/v2/ccLogger"
    20→	"github.com/ClusterCockpit/cc-lib/v2/schema"
    21→	"github.com/ClusterCockpit/cc-lib/v2/util"
    22→)
    23→
    24→type WebConfig struct {
    25→	JobList           JobListConfig     `json:"job-list"`
    26→	NodeList          NodeListConfig    `json:"node-list"`
    27→	JobView           JobViewConfig     `json:"job-view"`
    28→	MetricConfig      MetricConfig      `json:"metric-config"`
    29→	PlotConfiguration PlotConfiguration `json:"plot-configuration"`
    30→}
    31→
    32→type JobListConfig struct {
    33→	UsePaging     bool `json:"use-paging"`
    34→	ShowFootprint bool `json:"show-footprint"`
    35→}
    36→
    37→type NodeListConfig struct {
    38→	UsePaging bool `json:"use-paging"`
    39→}
    40→
    41→type JobViewConfig struct {
    42→	ShowPolarPlot bool `json:"show-polar-plot"`
    43→	ShowFootprint bool `json:"show-footprint"`
    44→	ShowRoofline  bool `json:"show-roofline"`
    45→	ShowStatTable bool `json:"show-stat-table"`
    46→}
    47→
    48→type MetricConfig struct {
    49→	JobListMetrics      []string        `json:"job-list-metrics"`
    50→	JobViewPlotMetrics  []string        `json:"job-view-plot-metrics"`
    51→	JobViewTableMetrics []string        `json:"job-view-table-metrics"`
    52→	Clusters            []ClusterConfig `json:"clusters"`
    53→}
    54→
    55→type ClusterConfig struct {
    56→	Name                string             `json:"name"`
    57→	JobListMetrics      []string           `json:"job-list-metrics"`
    58→	JobViewPlotMetrics  []string           `json:"job-view-plot-metrics"`
    59→	JobViewTableMetrics []string           `json:"job-view-table-metrics"`
    60→	SubClusters         []SubClusterConfig `json:"sub-clusters"`
    61→}
    62→
    63→type SubClusterConfig struct {
    64→	Name                string   `json:"name"`
    65→	JobListMetrics      []string `json:"job-list-metrics"`
    66→	JobViewPlotMetrics  []string `json:"job-view-plot-metrics"`
    67→	JobViewTableMetrics []string `json:"job-view-table-metrics"`
    68→}
    69→
    70→type PlotConfiguration struct {
    71→	ColorBackground bool     `json:"color-background"`
    72→	PlotsPerRow     int      `json:"plots-per-row"`
    73→	LineWidth       int      `json:"line-width"`
    74→	ColorScheme     []string `json:"color-scheme"`
    75→	ResampleAlgo    string   `json:"resample-algo"`
    76→	ResamplePolicy  string   `json:"resample-policy"`
    77→}
    78→
    79→var UIDefaults = WebConfig{
    80→	JobList: JobListConfig{
    81→		UsePaging:     false,
    82→		ShowFootprint: false,
    83→	},
    84→	NodeList: NodeListConfig{
    85→		UsePaging: false,
    86→	},
    87→	JobView: JobViewConfig{
    88→		ShowPolarPlot: true,
    89→		ShowFootprint: false,
    90→		ShowRoofline:  true,
    91→		ShowStatTable: true,
    92→	},
    93→	MetricConfig: MetricConfig{
    94→		JobListMetrics:      []string{"cpu_load", "flops_any", "mem_bw", "mem_used"},
    95→		JobViewPlotMetrics:  []string{"cpu_load", "flops_any", "mem_bw", "mem_used"},
    96→		JobViewTableMetrics: []string{"flops_any", "mem_bw", "mem_used"},
    97→	},
    98→	PlotConfiguration: PlotConfiguration{
    99→		ColorBackground: true,
   100→		PlotsPerRow:     3,
   101→		LineWidth:       3,
   102→		ColorScheme:     []string{"#00bfff", "#0000ff", "#ff00ff", "#ff0000", "#ff8000", "#ffff00", "#80ff00"},
   103→	},
   104→}
   105→
   106→var UIDefaultsMap map[string]any
   107→
   108→//
   109→// 	map[string]any{
   110→// 	"analysis_view_histogramMetrics":         []string{"flops_any", "mem_bw", "mem_used"},
   111→// 	"analysis_view_scatterPlotMetrics":       [][]string{{"flops_any", "mem_bw"}, {"flops_any", "cpu_load"}, {"cpu_load", "mem_bw"}},
   112→// 	"job_view_nodestats_selectedMetrics":     []string{"flops_any", "mem_bw", "mem_used"},
   113→// 	"plot_list_jobsPerPage":                  50,
   114→// 	"analysis_view_selectedTopEntity":        "user",
   115→// 	"analysis_view_selectedTopCategory":      "totalWalltime",
   116→// 	"status_view_selectedTopUserCategory":    "totalJobs",
   117→// 	"status_view_selectedTopProjectCategory": "totalJobs",
   118→// }
   119→
   120→func Init(rawConfig json.RawMessage) error {
   121→	var err error
   122→
   123→	if rawConfig != nil {
   124→		config.Validate(configSchema, rawConfig)
   125→		if err = json.Unmarshal(rawConfig, &UIDefaults); err != nil {
   126→			cclog.Warn("Error while unmarshaling raw config json")
   127→			return err
   128→		}
   129→	}
   130→
   131→	UIDefaultsMap = make(map[string]any)
   132→
   133→	UIDefaultsMap["jobList_usePaging"] = UIDefaults.JobList.UsePaging
   134→	UIDefaultsMap["jobList_showFootprint"] = UIDefaults.JobList.ShowFootprint
   135→	UIDefaultsMap["nodeList_usePaging"] = UIDefaults.NodeList.UsePaging
   136→	UIDefaultsMap["jobView_showPolarPlot"] = UIDefaults.JobView.ShowPolarPlot
   137→	UIDefaultsMap["jobView_showFootprint"] = UIDefaults.JobView.ShowFootprint
   138→	UIDefaultsMap["jobView_showRoofline"] = UIDefaults.JobView.ShowRoofline
   139→	UIDefaultsMap["jobView_showStatTable"] = UIDefaults.JobView.ShowStatTable
   140→
   141→	UIDefaultsMap["metricConfig_jobListMetrics"] = UIDefaults.MetricConfig.JobListMetrics
   142→	UIDefaultsMap["metricConfig_jobViewPlotMetrics"] = UIDefaults.MetricConfig.JobViewPlotMetrics
   143→	UIDefaultsMap["metricConfig_jobViewTableMetrics"] = UIDefaults.MetricConfig.JobViewTableMetrics
   144→
   145→	UIDefaultsMap["plotConfiguration_colorBackground"] = UIDefaults.PlotConfiguration.ColorBackground
   146→	UIDefaultsMap["plotConfiguration_plotsPerRow"] = UIDefaults.PlotConfiguration.PlotsPerRow
   147→	UIDefaultsMap["plotConfiguration_lineWidth"] = UIDefaults.PlotConfiguration.LineWidth
   148→	UIDefaultsMap["plotConfiguration_colorScheme"] = UIDefaults.PlotConfiguration.ColorScheme
   149→	UIDefaultsMap["plotConfiguration_resampleAlgo"] = UIDefaults.PlotConfiguration.ResampleAlgo
   150→	UIDefaultsMap["plotConfiguration_resamplePolicy"] = UIDefaults.PlotConfiguration.ResamplePolicy
   151→
   152→	for _, c := range UIDefaults.MetricConfig.Clusters {
   153→		if c.JobListMetrics != nil {
   154→			UIDefaultsMap["metricConfig_jobListMetrics:"+c.Name] = c.JobListMetrics
   155→		}
   156→		if c.JobViewPlotMetrics != nil {
   157→			UIDefaultsMap["metricConfig_jobViewPlotMetrics:"+c.Name] = c.JobViewPlotMetrics
   158→		}
   159→		if c.JobViewTableMetrics != nil {
   160→			UIDefaultsMap["metricConfig_jobViewTableMetrics:"+c.Name] = c.JobViewTableMetrics
   161→		}
   162→
   163→		for _, sc := range c.SubClusters {
   164→			suffix := strings.Join([]string{c.Name, sc.Name}, ":")
   165→			if sc.JobListMetrics != nil {
   166→				UIDefaultsMap["metricConfig_jobListMetrics:"+suffix] = sc.JobListMetrics
   167→			}
   168→			if sc.JobViewPlotMetrics != nil {
   169→				UIDefaultsMap["metricConfig_jobViewPlotMetrics:"+suffix] = sc.JobViewPlotMetrics
   170→			}
   171→			if sc.JobViewTableMetrics != nil {
   172→				UIDefaultsMap["metricConfig_jobViewTableMetrics:"+suffix] = sc.JobViewTableMetrics
   173→			}
   174→		}
   175→	}
   176→
   177→	return err
   178→}
   179→
   180→// / Go's embed is only allowed to embed files in a subdirectory of the embedding package ([see here](https://github.com/golang/go/issues/46056)).
   181→//
   182→//go:embed frontend/public/*
   183→var frontendFiles embed.FS
   184→
   185→func ServeFiles() http.Handler {
   186→	publicFiles, err := fs.Sub(frontendFiles, "frontend/public")
   187→	if err != nil {
   188→		cclog.Abortf("Serve Files: Could not find 'frontend/public' file directory.\nError: %s\n", err.Error())
   189→	}
   190→	return http.FileServer(http.FS(publicFiles))
   191→}
   192→
   193→// StaticFileExists checks whether a static file exists in the embedded frontend FS.
   194→func StaticFileExists(path string) bool {
   195→	path = strings.TrimPrefix(path, "/")
   196→	if path == "" {
   197→		return false
   198→	}
   199→	_, err := fs.Stat(frontendFiles, "frontend/public/"+path)
   200→	return err == nil


Bash

cd /Users/jan/prg/ClusterCockpit/cc-backend && grep -n "ResampleAlgo\|resampleAlgo" api/schema.graphqls | head -40


253:enum ResampleAlgo {
349:    resampleAlgo: ResampleAlgo
409:    resampleAlgo: ResampleAlgo


     1→scalar Time
     2→scalar Any
     3→
     4→scalar NullableFloat
     5→scalar MetricScope
     6→scalar JobState
     7→scalar SchedulerState
     8→scalar MonitoringState
     9→
    10→type Node {
    11→  id: ID!
    12→  hostname: String!
    13→  cluster: String!
    14→  subCluster: String!
    15→  jobsRunning: Int!
    16→  cpusAllocated: Int
    17→  memoryAllocated: Int
    18→  gpusAllocated: Int
    19→  schedulerState: SchedulerState!
    20→  healthState: MonitoringState!
    21→  metaData: Any
    22→  healthData: Any
    23→}
    24→
    25→type NodeStates {
    26→  state: String!
    27→  count: Int!
    28→}
    29→
    30→type NodeStatesTimed {
    31→  state: String!
    32→  counts: [Int!]!
    33→  times: [Int!]!
    34→}
    35→
    36→type Job {
    37→  id: ID!
    38→  jobId: Int!
    39→  user: String!
    40→  project: String!
    41→  cluster: String!
    42→  subCluster: String!
    43→  startTime: Time!
    44→  duration: Int!
    45→  walltime: Int!
    46→  numNodes: Int!
    47→  numHWThreads: Int!
    48→  numAcc: Int!
    49→  energy: Float!
    50→  SMT: Int!
    51→  shared: String!
    52→  partition: String!
    53→  arrayJobId: Int!
    54→  monitoringStatus: Int!
    55→  state: JobState!
    56→  tags: [Tag!]!
    57→  resources: [Resource!]!
    58→  concurrentJobs: JobLinkResultList
    59→  footprint: [FootprintValue]
    60→  energyFootprint: [EnergyFootprintValue]
    61→  metaData: Any
    62→  userData: User
    63→}
    64→
    65→type JobLink {
    66→  id: ID!
    67→  jobId: Int!
    68→}
    69→
    70→type Cluster {
    71→  name: String!
    72→  partitions: [String!]! # Slurm partitions
    73→  subClusters: [SubCluster!]! # Hardware partitions/subclusters
    74→}
    75→
    76→type SubCluster {
    77→  name: String!
    78→  nodes: String!
    79→  numberOfNodes: Int!
    80→  processorType: String!
    81→  socketsPerNode: Int!
    82→  coresPerSocket: Int!
    83→  threadsPerCore: Int!
    84→  flopRateScalar: MetricValue!
    85→  flopRateSimd: MetricValue!
    86→  memoryBandwidth: MetricValue!
    87→  topology: Topology!
    88→  metricConfig: [MetricConfig!]!
    89→  footprint: [String!]!
    90→}
    91→
    92→type FootprintValue {
    93→  name: String!
    94→  stat: String!
    95→  value: Float!
    96→}
    97→
    98→type EnergyFootprintValue {
    99→  hardware: String!
   100→  metric: String!
   101→  value: Float!
   102→}
   103→
   104→type MetricValue {
   105→  name: String
   106→  unit: Unit!
   107→  value: Float!
   108→}
   109→
   110→type Topology {
   111→  node: [Int!]
   112→  socket: [[Int!]!]
   113→  memoryDomain: [[Int!]!]
   114→  die: [[Int!]!]
   115→  core: [[Int!]!]
   116→  accelerators: [Accelerator!]
   117→}
   118→
   119→type Accelerator {
   120→  id: String!
   121→  type: String!
   122→  model: String!
   123→}
   124→
   125→type SubClusterConfig {
   126→  name: String!
   127→  peak: Float
   128→  normal: Float
   129→  caution: Float
   130→  alert: Float
   131→  remove: Boolean
   132→}
   133→
   134→type MetricConfig {
   135→  name: String!
   136→  unit: Unit!
   137→  scope: MetricScope!
   138→  aggregation: String!
   139→  timestep: Int!
   140→  peak: Float!
   141→  normal: Float
   142→  caution: Float!
   143→  alert: Float!
   144→  lowerIsBetter: Boolean
   145→  subClusters: [SubClusterConfig!]!
   146→}
   147→
   148→type Tag {
   149→  id: ID!
   150→  type: String!
   151→  name: String!
   152→  scope: String!
   153→}
   154→
   155→type Resource {
   156→  hostname: String!
   157→  hwthreads: [Int!]
   158→  accelerators: [String!]
   159→  configuration: String
   160→}
   161→
   162→type JobMetricWithName {
   163→  name: String!
   164→  scope: MetricScope!
   165→  metric: JobMetric!
   166→}
   167→
   168→type ClusterMetricWithName {
   169→  name: String!
   170→  unit: Unit
   171→  timestep: Int!
   172→  data: [NullableFloat!]!
   173→}
   174→
   175→type JobMetric {
   176→  unit: Unit
   177→  timestep: Int!
   178→  series: [Series!]
   179→  statisticsSeries: StatsSeries
   180→}
   181→
   182→type Series {
   183→  hostname: String!
   184→  id: String
   185→  statistics: MetricStatistics
   186→  data: [NullableFloat!]!
   187→}
   188→
   189→type StatsSeries {
   190→  mean: [NullableFloat!]!
   191→  median: [NullableFloat!]!
   192→  min: [NullableFloat!]!
   193→  max: [NullableFloat!]!
   194→}
   195→
   196→type NamedStatsWithScope {
   197→  name: String!
   198→  scope: MetricScope!
   199→  stats: [ScopedStats!]!
   200→}
   201→
   202→type ScopedStats {
   203→  hostname: String!
   204→  id: String
   205→  data: MetricStatistics!
   206→}
   207→
   208→type JobStats {
   209→  id: Int!
   210→  jobId: String!
   211→  startTime: Int!
   212→  duration: Int!
   213→  cluster: String!
   214→  subCluster: String!
   215→  numNodes: Int!
   216→  numHWThreads: Int
   217→  numAccelerators: Int
   218→  stats: [NamedStats!]!
   219→}
   220→
   221→type NamedStats {
   222→  name: String!
   223→  data: MetricStatistics!
   224→}
   225→
   226→type Unit {
   227→  base: String!
   228→  prefix: String
   229→}
   230→
   231→type MetricStatistics {
   232→  avg: Float!
   233→  min: Float!
   234→  max: Float!
   235→}
   236→
   237→type MetricFootprints {
   238→  metric: String!
   239→  data: [NullableFloat!]!
   240→}
   241→
   242→type Footprints {
   243→  timeWeights: TimeWeights!
   244→  metrics: [MetricFootprints!]!
   245→}
   246→
   247→type TimeWeights {
   248→  nodeHours: [NullableFloat!]!
   249→  accHours: [NullableFloat!]!
   250→  coreHours: [NullableFloat!]!
   251→}
   252→
   253→enum ResampleAlgo {
   254→  LTTB
   255→  AVERAGE
   256→  SIMPLE
   257→}
   258→
   259→enum Aggregate {
   260→  USER
   261→  PROJECT
   262→  CLUSTER
   263→  SUBCLUSTER
   264→}
   265→enum SortByAggregate {
   266→  TOTALWALLTIME
   267→  TOTALJOBS
   268→  TOTALUSERS
   269→  TOTALNODES
   270→  TOTALNODEHOURS
   271→  TOTALCORES
   272→  TOTALCOREHOURS
   273→  TOTALACCS
   274→  TOTALACCHOURS
   275→}
   276→
   277→type NodeMetrics {
   278→  host: String!
   279→  nodeState: String!
   280→  metricHealth: String!
   281→  subCluster: String!
   282→  metrics: [JobMetricWithName!]!
   283→}
   284→
   285→type ClusterMetrics {
   286→  nodeCount: Int!
   287→  metrics: [ClusterMetricWithName!]!
   288→}
   289→
   290→type NodesResultList {
   291→  items: [NodeMetrics!]!
   292→  offset: Int
   293→  limit: Int
   294→  count: Int
   295→  totalNodes: Int
   296→  hasNextPage: Boolean
   297→}
   298→
   299→type ClusterSupport {
   300→  cluster: String!
   301→  subClusters: [String!]!
   302→}
   303→
   304→type GlobalMetricListItem {
   305→  name: String!
   306→  unit: Unit!
   307→  scope: MetricScope!
   308→  footprint: String
   309→  availability: [ClusterSupport!]!
   310→}
   311→
   312→type Count {
   313→  name: String!
   314→  count: Int!
   315→}
   316→
   317→type User {
   318→  username: String!
   319→  name: String!
   320→  email: String!
   321→}
   322→
   323→input MetricStatItem {
   324→  metricName: String!
   325→  range: FloatRange!
   326→}
   327→
   328→type Query {
   329→  clusters: [Cluster!]! # List of all clusters
   330→  tags: [Tag!]! # List of all tags
   331→  globalMetrics: [GlobalMetricListItem!]!
   332→
   333→  user(username: String!): User
   334→  allocatedNodes(cluster: String!): [Count!]!
   335→
   336→  ## Node Queries New
   337→  node(id: ID!): Node
   338→  nodes(filter: [NodeFilter!], order: OrderByInput): NodeStateResultList!
   339→  nodesWithMeta(filter: [NodeFilter!], order: OrderByInput): NodeStateResultList!
   340→  nodeStates(filter: [NodeFilter!]): [NodeStates!]!
   341→  nodeStatesTimed(filter: [NodeFilter!], type: String!): [NodeStatesTimed!]!
   342→
   343→  job(id: ID!): Job
   344→  jobMetrics(
   345→    id: ID!
   346→    metrics: [String!]
   347→    scopes: [MetricScope!]
   348→    resolution: Int
   349→    resampleAlgo: ResampleAlgo
   350→  ): [JobMetricWithName!]!
   351→
   352→  jobStats(id: ID!, metrics: [String!]): [NamedStats!]!
   353→
   354→  scopedJobStats(
   355→    id: ID!
   356→    metrics: [String!]
   357→    scopes: [MetricScope!]
   358→  ): [NamedStatsWithScope!]!
   359→
   360→  jobs(
   361→    filter: [JobFilter!]
   362→    page: PageRequest
   363→    order: OrderByInput
   364→  ): JobResultList!
   365→
   366→  jobsStatistics(
   367→    filter: [JobFilter!]
   368→    metrics: [String!]
   369→    page: PageRequest
   370→    sortBy: SortByAggregate
   371→    groupBy: Aggregate
   372→    numDurationBins: String
   373→    numMetricBins: Int
   374→  ): [JobsStatistics!]!
   375→
   376→  jobsMetricStats(filter: [JobFilter!], metrics: [String!]): [JobStats!]!
   377→  jobsFootprints(filter: [JobFilter!], metrics: [String!]!): Footprints
   378→
   379→  rooflineHeatmap(
   380→    filter: [JobFilter!]!
   381→    rows: Int!
   382→    cols: Int!
   383→    minX: Float!
   384→    minY: Float!
   385→    maxX: Float!
   386→    maxY: Float!
   387→  ): [[Float!]!]!
   388→
   389→  nodeMetrics(
   390→    cluster: String!
   391→    nodes: [String!]
   392→    scopes: [MetricScope!]
   393→    metrics: [String!]
   394→    from: Time!
   395→    to: Time!
   396→  ): [NodeMetrics!]!
   397→
   398→  nodeMetricsList(
   399→    cluster: String!
   400→    subCluster: String!
   401→    stateFilter: String!
   402→    nodeFilter: String!
   403→    scopes: [MetricScope!]
   404→    metrics: [String!]
   405→    from: Time!
   406→    to: Time!
   407→    page: PageRequest
   408→    resolution: Int
   409→    resampleAlgo: ResampleAlgo
   410→  ): NodesResultList!
   411→
   412→  clusterMetrics(
   413→    cluster: String!
   414→    metrics: [String!]
   415→    from: Time!
   416→    to: Time!
   417→  ): ClusterMetrics!
   418→}
   419→
   420→type Mutation {
   421→  createTag(type: String!, name: String!, scope: String!): Tag!
   422→  deleteTag(id: ID!): ID!
   423→  addTagsToJob(job: ID!, tagIds: [ID!]!): [Tag!]!
   424→  removeTagsFromJob(job: ID!, tagIds: [ID!]!): [Tag!]!
   425→  removeTagFromList(tagIds: [ID!]!): [Int!]!
   426→
   427→  updateConfiguration(name: String!, value: String!): String
   428→}
   429→
   430→type IntRangeOutput {
   431→  from: Int!
   432→  to: Int!
   433→}
   434→type TimeRangeOutput {
   435→  range: String
   436→  from: Time!
   437→  to: Time!
   438→}
   439→
   440→input NodeFilter {
   441→  hostname: StringInput
   442→  cluster: StringInput
   443→  subCluster: StringInput
   444→  schedulerState: SchedulerState
   445→  healthState: MonitoringState
   446→  timeStart: Int
   447→}
   448→
   449→input JobFilter {
   450→  tags: [ID!]


cd /Users/jan/prg/ClusterCockpit/cc-backend && grep -n "func.*JobMetrics\|func.*NodeMetricsList" internal/graph/schema.resolvers.go


501:func (r *queryResolver) JobMetrics(ctx context.Context, id string, metrics []string, scopes []schema.MetricScope, resolution *int, resampleAlgo *model.ResampleAlgo) ([]*model.JobMetricWithName, error) {
881: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) {


   501→func (r *queryResolver) JobMetrics(ctx context.Context, id string, metrics []string, scopes []schema.MetricScope, resolution *int, resampleAlgo *model.ResampleAlgo) ([]*model.JobMetricWithName, error) {
   502→	job, err := r.Query().Job(ctx, id)
   503→	if err != nil {
   504→		cclog.Warn("Error while querying job for metrics")
   505→		return nil, err
   506→	}
   507→
   508→	// Resolve resolution: explicit param > user policy > global config > 0
   509→	if resolution == nil {
   510→		resolution = resolveResolutionFromPolicy(ctx, int64(job.Duration), job.Cluster, metrics)
   511→	}
   512→	if resolution == nil {
   513→		if config.Keys.EnableResampling != nil {
   514→			defaultRes := slices.Max(config.Keys.EnableResampling.Resolutions)
   515→			resolution = &defaultRes
   516→		} else {
   517→			defaultRes := 0
   518→			resolution = &defaultRes
   519→		}
   520→	}
   521→
   522→	algoName := resolveResampleAlgo(ctx, resampleAlgo)
   523→
   524→	data, err := metricdispatch.LoadData(job, metrics, scopes, ctx, *resolution, algoName)
   525→	if err != nil {
   526→		cclog.Warn("Error while loading job data")
   527→		return nil, err
   528→	}
   529→
   530→	res := []*model.JobMetricWithName{}
   531→	for name, md := range data {
   532→		for scope, metric := range md {
   533→			res = append(res, &model.JobMetricWithName{
   534→				Name:   name,
   535→				Scope:  scope,
   536→				Metric: metric,
   537→			})
   538→		}
   539→	}
   540→
   541→	return res, err
   542→}
   543→
   544→// JobStats is the resolver for the jobStats field.
   545→func (r *queryResolver) JobStats(ctx context.Context, id string, metrics []string) ([]*model.NamedStats, error) {
   546→	job, err := r.Query().Job(ctx, id)
   547→	if err != nil {
   548→		cclog.Warnf("Error while querying job %s for metadata", id)
   549→		return nil, err
   550→	}
   551→
   552→	data, err := metricdispatch.LoadJobStats(job, metrics, ctx)
   553→	if err != nil {
   554→		cclog.Warnf("Error while loading jobStats data for job id %s", id)
   555→		return nil, err
   556→	}
   557→
   558→	res := []*model.NamedStats{}
   559→	for name, md := range data {
   560→		res = append(res, &model.NamedStats{
   561→			Name: name,
   562→			Data: &md,
   563→		})
   564→	}
   565→
   566→	return res, err
   567→}
   568→
   569→// ScopedJobStats is the resolver for the scopedJobStats field.
   570→func (r *queryResolver) ScopedJobStats(ctx context.Context, id string, metrics []string, scopes []schema.MetricScope) ([]*model.NamedStatsWithScope, error) {
   571→	job, err := r.Query().Job(ctx, id)
   572→	if err != nil {
   573→		cclog.Warnf("Error while querying job %s for metadata", id)
   574→		return nil, err
   575→	}
   576→
   577→	data, err := metricdispatch.LoadScopedJobStats(job, metrics, scopes, ctx)
   578→	if err != nil {
   579→		cclog.Warnf("Error while loading scopedJobStats data for job id %s", id)
   580→		return nil, err
   581→	}
   582→
   583→	res := make([]*model.NamedStatsWithScope, 0)
   584→	for name, scoped := range data {
   585→		for scope, stats := range scoped {
   586→
   587→			mdlStats := make([]*model.ScopedStats, 0)
   588→			for _, stat := range stats {
   589→				mdlStats = append(mdlStats, &model.ScopedStats{
   590→					Hostname: stat.Hostname,
   591→					ID:       stat.ID,
   592→					Data:     stat.Data,
   593→				})
   594→			}
   595→
   596→			res = append(res, &model.NamedStatsWithScope{
   597→				Name:  name,
   598→				Scope: scope,
   599→				Stats: mdlStats,
   600→			})


   881→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) {
   882→	// Resolve resolution: explicit param > user policy > global config > 0
   883→	duration := int64(to.Sub(from).Seconds())
   884→	if resolution == nil {
   885→		resolution = resolveResolutionFromPolicy(ctx, duration, cluster, metrics)
   886→	}
   887→	if resolution == nil {
   888→		if config.Keys.EnableResampling != nil {
   889→			defaultRes := slices.Max(config.Keys.EnableResampling.Resolutions)
   890→			resolution = &defaultRes
   891→		} else {
   892→			defaultRes := 0
   893→			resolution = &defaultRes
   894→		}
   895→	}
   896→
   897→	user := repository.GetUserFromContext(ctx)
   898→	if user != nil && !user.HasAnyRole([]schema.Role{schema.RoleAdmin, schema.RoleSupport}) {
   899→		return nil, errors.New("you need to be administrator or support staff for this query")
   900→	}
   901→
   902→	nodeRepo := repository.GetNodeRepository()
   903→	// nodes -> array hostname
   904→	nodes, nodeStateMap, metricHealthMap, countNodes, hasNextPage, nerr := nodeRepo.GetNodesForList(ctx, cluster, subCluster, stateFilter, nodeFilter, page)
   905→	if nerr != nil {
   906→		return nil, errors.New("could not retrieve node list required for resolving NodeMetricsList")
   907→	}
   908→
   909→	if metrics == nil {
   910→		for _, mc := range archive.GetCluster(cluster).MetricConfig {
   911→			metrics = append(metrics, mc.Name)
   912→		}
   913→	}
   914→
   915→	algoName := resolveResampleAlgo(ctx, resampleAlgo)
   916→
   917→	// data -> map hostname:jobdata
   918→	data, err := metricdispatch.LoadNodeListData(cluster, subCluster, nodes, metrics, scopes, *resolution, from, to, ctx, algoName)
   919→	if err != nil {
   920→		cclog.Warn("error while loading node data (Resolver.NodeMetricsList")
   921→		return nil, err
   922→	}
   923→
   924→	nodeMetricsList := make([]*model.NodeMetrics, 0, len(data))
   925→	for _, hostname := range nodes {
   926→		host := &model.NodeMetrics{
   927→			Host:         hostname,
   928→			NodeState:    nodeStateMap[hostname],
   929→			MetricHealth: metricHealthMap[hostname],
   930→			Metrics:      make([]*model.JobMetricWithName, 0),
   931→		}
   932→		host.SubCluster, err = archive.GetSubClusterByNode(cluster, hostname)
   933→		if err != nil {
   934→			cclog.Warnf("error in nodeMetrics resolver: %s", err)
   935→		}
   936→
   937→		for metric, scopedMetrics := range data[hostname] {
   938→			for scope, scopedMetric := range scopedMetrics {
   939→				host.Metrics = append(host.Metrics, &model.JobMetricWithName{
   940→					Name:   metric,
   941→					Scope:  scope,
   942→					Metric: scopedMetric,
   943→				})
   944→			}
   945→		}
   946→
   947→		nodeMetricsList = append(nodeMetricsList, host)
   948→	}
   949→
   950→	nodeMetricsListResult := &model.NodesResultList{
   951→		Items: nodeMetricsList,
   952→		// TotalNodes depends on sum of nodes grouped on latest timestamp, see repo/node.go:357
   953→		TotalNodes:  &countNodes,
   954→		HasNextPage: &hasNextPage,
   955→	}
   956→
   957→	return nodeMetricsListResult, nil
   958→}
   959→
   960→// ClusterMetrics is the resolver for the clusterMetrics field.
   961→func (r *queryResolver) ClusterMetrics(ctx context.Context, cluster string, metrics []string, from time.Time, to time.Time) (*model.ClusterMetrics, error) {
   962→	user := repository.GetUserFromContext(ctx)
   963→	if user != nil && !user.HasAnyRole([]schema.Role{schema.RoleAdmin, schema.RoleSupport}) {
   964→		return nil, errors.New("you need to be administrator or support staff for this query")
   965→	}
   966→
   967→	if metrics == nil {
   968→		for _, mc := range archive.GetCluster(cluster).MetricConfig {
   969→			metrics = append(metrics, mc.Name)
   970→		}
   971→	}
   972→
   973→	// 'nodes' == nil -> Defaults to all nodes of cluster for existing query workflow
   974→	scopes := []schema.MetricScope{"node"}
   975→	data, err := metricdispatch.LoadNodeData(cluster, metrics, nil, scopes, from, to, ctx)
   976→	if err != nil {
   977→		cclog.Warn("error while loading node data")
   978→		return nil, err
   979→	}
   980→


1→
    10→
    11→
    32→
    33→<Row cols={3} class="p-2 g-2">
    34→
    35→  <Col>
    36→    <Card class="h-100">
    37→      <form
    38→        id="line-width-form"
    39→        method="post"
    40→        action="/frontend/configuration/"
    41→        class="card-body"
    42→        onsubmit={(e) => updateSetting(e, {
    43→          selector: "#line-width-form",
    44→          target: "lw",
    45→        })}
    46→      >
    47→
    48→        <CardTitle
    49→          style="margin-bottom: 1em; display: flex; align-items: center;"
    50→        >
    51→
Line Width

    52→
    53→          {#if displayMessage && message.target == "lw"}
    54→

    55→
    56→                Update: {message.msg}
    57→
    58→

    59→          {/if}
    60→        </CardTitle>
    61→        <input type="hidden" name="key" value="plotConfiguration_lineWidth" />
    62→

    63→          Line Width
    64→          <input
    65→            type="number"
    66→            class="form-control"
    67→            id="lwvalue"
    68→            name="value"
    69→            aria-describedby="lineWidthHelp"
    70→            value={config?.plotConfiguration_lineWidth}
    71→            min="1"
    72→          />
    73→

    74→            Width of the lines in the timeseries plots.
    75→

    76→

    77→        <Button color="primary" type="submit">Submit</Button>
    78→      </form>
    79→    </Card>
    80→  </Col>
    81→
    82→
    83→  <Col>
    84→    <Card class="h-100">
    85→      <form
    86→        id="plots-per-row-form"
    87→        method="post"
    88→        action="/frontend/configuration/"
    89→        class="card-body"
    90→        onsubmit={(e) => updateSetting(e, {
    91→          selector: "#plots-per-row-form",
    92→          target: "ppr",
    93→        })}
    94→      >
    95→
    96→        <CardTitle
    97→          style="margin-bottom: 1em; display: flex; align-items: center;"
    98→        >
    99→
Plots per Row

   100→          {#if displayMessage && message.target == "ppr"}
   101→

   102→
   103→                Update: {message.msg}
   104→
   105→

   106→          {/if}
   107→        </CardTitle>
   108→        <input type="hidden" name="key" value="plotConfiguration_plotsPerRow" />
   109→

   110→          Plots per Row
   111→          <input
   112→            type="number"
   113→            class="form-control"
   114→            id="pprvalue"
   115→            name="value"
   116→            aria-describedby="plotsperrowHelp"
   117→            value={config?.plotConfiguration_plotsPerRow}
   118→            min="1"
   119→          />
   120→

   121→            How many plots to show next to each other on pages such as
   122→            /monitoring/job/, /monitoring/system/...
   123→

   124→

   125→        <Button color="primary" type="submit">Submit</Button>
   126→      </form>
   127→    </Card>
   128→  </Col>
   129→
   130→
   131→  <Col class="d-flex justify-content-between">
   132→    <Card class="h-100" style="width: 49%;">
   133→      <form
   134→        id="backgrounds-form"
   135→        method="post"
   136→        action="/frontend/configuration/"
   137→        class="card-body"
   138→        onsubmit={(e) => updateSetting(e, {
   139→          selector: "#backgrounds-form",
   140→          target: "bg",
   141→        })}
   142→      >
   143→
   144→        <CardTitle
   145→          style="margin-bottom: 1em; display: flex; align-items: center;"
   146→        >
   147→
Colored Backgrounds

   148→          {#if displayMessage && message.target == "bg"}
   149→

   150→
   151→                Update: {message.msg}
   152→
   153→

   154→          {/if}
   155→        </CardTitle>
   156→        <input type="hidden" name="key" value="plotConfiguration_colorBackground" />
   157→

   158→

   159→            {#if config?.plotConfiguration_colorBackground}
   160→              <input type="radio" id="colb-true-checked" name="value" value="true" checked />
   161→            {:else}
   162→              <input type="radio" id="colb-true" name="value" value="true" />
   163→            {/if}
   164→            Yes
   165→

   166→

   167→            {#if config?.plotConfiguration_colorBackground}
   168→              <input type="radio" id="colb-false" name="value" value="false" />
   169→            {:else}
   170→              <input type="radio" id="colb-false-checked" name="value" value="false" checked />
   171→            {/if}
   172→            No
   173→

   174→

   175→        <Button color="primary" type="submit">Submit</Button>
   176→      </form>
   177→    </Card>
   178→    <Card class="h-100" style="width: 49%;">
   179→      <form
   180→        id="colorblindmode-form"
   181→        method="post"
   182→        action="/frontend/configuration/"
   183→        class="card-body"
   184→        onsubmit={(e) => updateSetting(e, {
   185→          selector: "#colorblindmode-form",
   186→          target: "cbm",
   187→        })}
   188→      >
   189→
   190→        <CardTitle
   191→          style="margin-bottom: 1em; display: flex; align-items: center;"
   192→        >
   193→
Color Blind Mode

   194→          {#if displayMessage && message.target == "cbm"}
   195→

   196→
   197→                Update: {message.msg}
   198→
   199→

   200→          {/if}
   201→        </CardTitle>
   202→        <input type="hidden" name="key" value="plotConfiguration_colorblindMode" />
   203→

   204→

   205→            {#if config?.plotConfiguration_colorblindMode}
   206→              <input type="radio" id="cbm-true-checked" name="value" value="true" checked />
   207→            {:else}
   208→              <input type="radio" id="cbm-true" name="value" value="true" />
   209→            {/if}
   210→            Yes
   211→

   212→

   213→            {#if config?.plotConfiguration_colorblindMode}
   214→              <input type="radio" id="cbm-false" name="value" value="false" />
   215→            {:else}
   216→              <input type="radio" id="cbm-false-checked" name="value" value="false" checked />
   217→            {/if}
   218→            No
   219→

   220→

   221→        <Button color="primary" type="submit">Submit</Button>
   222→      </form>
   223→    </Card>
   224→  </Col>
   225→
   226→
   227→  <Col>
   228→    <Card class="h-100">
   229→      <form
   230→        id="resample-policy-form"
   231→        method="post"
   232→        action="/frontend/configuration/"
   233→        class="card-body"
   234→        onsubmit={(e) => updateSetting(e, {
   235→          selector: "#resample-policy-form",
   236→          target: "rsp",
   237→        })}
   238→      >
   239→        <CardTitle
   240→          style="margin-bottom: 1em; display: flex; align-items: center;"
   241→        >
   242→
Resample Policy

   243→          {#if displayMessage && message.target == "rsp"}
   244→

   245→
   246→                Update: {message.msg}
   247→
   248→

   249→          {/if}
   250→        </CardTitle>
   251→        <input type="hidden" name="key" value="plotConfiguration_resamplePolicy" />
   252→

   253→          {#each [["", "Default"], ["low", "Low"], ["medium", "Medium"], ["high", "High"]] as [val, label]}
   254→

   255→              <input type="radio" id="rsp-{val || 'default'}" name="value" value={JSON.stringify(val)}
   256→                checked={(!config?.plotConfiguration_resamplePolicy && val === "") || config?.plotConfiguration_resamplePolicy === val} />
   257→              {label}
   258→

   259→          {/each}
   260→

   261→            Controls how many data points are shown in metric plots. Low = fast overview (~200 points), Medium = balanced (~500), High = maximum detail (~1000).
   262→

   263→

   264→        <Button color="primary" type="submit">Submit</Button>
   265→      </form>
   266→    </Card>
   267→  </Col>
   268→
   269→
   270→  <Col>
   271→    <Card class="h-100">
   272→      <form
   273→        id="resample-algo-form"
   274→        method="post"
   275→        action="/frontend/configuration/"
   276→        class="card-body"
   277→        onsubmit={(e) => updateSetting(e, {
   278→          selector: "#resample-algo-form",
   279→          target: "rsa",
   280→        })}
   281→      >
   282→        <CardTitle
   283→          style="margin-bottom: 1em; display: flex; align-items: center;"
   284→        >
   285→
Resample Algorithm

   286→          {#if displayMessage && message.target == "rsa"}
   287→

   288→
   289→                Update: {message.msg}
   290→
   291→

   292→          {/if}
   293→        </CardTitle>
   294→        <input type="hidden" name="key" value="plotConfiguration_resampleAlgo" />
   295→

   296→          {#each [["", "Default"], ["lttb", "LTTB"], ["average", "Average"], ["simple", "Simple"]] as [val, label]}
   297→

   298→              <input type="radio" id="rsa-{val || 'default'}" name="value" value={JSON.stringify(val)}
   299→                checked={(!config?.plotConfiguration_resampleAlgo && val === "") || config?.plotConfiguration_resampleAlgo === val} />
   300→              {label}
   301→

   302→          {/each}
   303→

   304→            Algorithm used when downsampling time-series data. LTTB preserves visual shape, Average smooths data, Simple picks every Nth point.
   305→

   306→

   307→        <Button color="primary" type="submit">Submit</Button>
   308→      </form>
   309→    </Card>
   310→  </Col>
   311→</Row>


    70→  let uplot = $state(null);
    71→
    72→  /* Derived */
    73→  const subClusterTopology = $derived(getContext("getHardwareTopology")(cluster, subCluster));
    74→  const metricConfig = $derived(getContext("getMetricConfig")(cluster, subCluster, metric));
    75→  const usesMeanStatsSeries = $derived((statisticsSeries?.mean && statisticsSeries.mean.length != 0));
    76→  const resampleTrigger = $derived(resampleConfig?.trigger ? Number(resampleConfig.trigger) : (resampleConfig?.targetPoints ? Math.floor(resampleConfig.targetPoints / 4) : null));
    77→  const resampleResolutions = $derived(resampleConfig?.resolutions ? [...resampleConfig.resolutions] : null);
    78→  const resampleMinimum = $derived(resampleConfig?.resolutions ? Math.min(...resampleConfig.resolutions) : null);
    79→  const resampleTargetPoints = $derived(resampleConfig?.targetPoints ? Number(resampleConfig.targetPoints) : null);
    80→  const useStatsSeries = $derived(!!statisticsSeries); // Display Stats Series By Default if Exists
    81→  const thresholds = $derived(findJobAggregationThresholds(
    82→    subClusterTopology,
    83→    metricConfig,
    84→    scope
    85→  ));
    86→  const longestSeries = $derived.by(() => {
    87→    if (useStatsSeries) {
    88→      return usesMeanStatsSeries ? statisticsSeries?.mean?.length : statisticsSeries?.median?.length;
    89→    } else {
    90→      return series.reduce((n, series) => Math.max(n, series.data.length), 0);
    91→    }
    92→  });
    93→  const maxX = $derived(longestSeries * timestep);
    94→  const maxY = $derived.by(() => {
    95→    let pendingY = 0;
    96→    if (useStatsSeries) {
    97→      pendingY = statisticsSeries.max.reduce(
    98→        (max, x) => Math.max(max, x),
    99→        thresholds?.normal,
   100→      ) || thresholds?.normal
   101→    } else {
   102→      pendingY = series.reduce(
   103→          (max, series) => Math.max(max, series?.statistics?.max),
   104→          thresholds?.normal,
   105→        ) || thresholds?.normal;
   106→    }
   107→
   108→    if (pendingY >= 10 * thresholds.peak) {
   109→      // Hard y-range render limit if outliers in series data
   110→      return (10 * thresholds.peak);
   111→    } else {
   112→      return pendingY;
   113→    }
   114→  });
   115→  const plotBands = $derived.by(() => {
   116→    if (useStatsSeries) {
   117→      return [
   118→        { series: [2, 3], fill: cbmode ? "rgba(0,0,255,0.1)" : "rgba(0,255,0,0.1)" },
   119→        { series: [3, 1], fill: cbmode ? "rgba(0,255,0,0.1)" : "rgba(255,0,0,0.1)" },
   120→      ];
   121→    };
   122→    return null;
   123→  })
   124→  const plotData = $derived.by(() => {
   125→    let pendingData = [new Array(longestSeries)];
   126→    // X
   127→    if (forNode === true) {
   128→      // Negative Timestamp Buildup
   129→      for (let i = 0; i <= longestSeries; i++) {
   130→        pendingData[0][i] = (longestSeries - i) * timestep * -1;
   131→      }
   132→    } else {
   133→      // Positive Timestamp Buildup
   134→      for (let j = 0; j < longestSeries; j++) {
   135→        pendingData[0][j] = j * timestep;
   136→      };
   137→    };
   138→    // Y
   139→    if (useStatsSeries) {
   140→      pendingData.push(statisticsSeries.min);
   141→      pendingData.push(statisticsSeries.max);
   142→      if (usesMeanStatsSeries) {
   143→        pendingData.push(statisticsSeries.mean);
   144→      } else {
   145→        pendingData.push(statisticsSeries.median);
   146→      }
   147→
   148→    } else {
   149→      for (let i = 0; i < series.length; i++) {
   150→        pendingData.push(series[i].data);
   151→      };
   152→    };
   153→    return pendingData;
   154→  })
   155→  const plotSeries = $derived.by(() => {
   156→    let pendingSeries = [
   157→      // Note: X-Legend Will not be shown as soon as Y-Axis are in extendedMode
   158→      {
   159→        label: "Runtime",
   160→        value: (u, ts, sidx, didx) =>
   161→        (didx == null) ? null : formatDurationTime(ts, forNode),
   162→      }
   163→    ];
   164→    // Y
   165→    if (useStatsSeries) {
   166→      pendingSeries.push({
   167→        label: "min",
   168→        scale: "y",
   169→        width: lineWidth,
   170→        stroke: cbmode ? "rgb(0,255,0)" : "red",
   171→      });
   172→      pendingSeries.push({
   173→        label: "max",
   174→        scale: "y",
   175→        width: lineWidth,
   176→        stroke: cbmode ? "rgb(0,0,255)" : "green",
   177→      });
   178→      pendingSeries.push({
   179→        label: usesMeanStatsSeries ? "mean" : "median",
   180→        scale: "y",
   181→        width: lineWidth,
   182→        stroke: "black",
   183→      });
   184→
   185→    } else {
   186→      for (let i = 0; i < series?.length; i++) {
   187→        // Default
   188→        if (!extendedLegendData) {
   189→          pendingSeries.push({
   190→            label:
   191→              scope === "node"
   192→              ? series[i].hostname
   193→              : scope === "accelerator"
   194→                ? 'Acc #' + (i + 1) // series[i].id.slice(9, 14) | Too Hardware Specific
   195→                : scope + " #" + (i + 1),
   196→            scale: "y",
   197→            width: lineWidth,
   198→            stroke: lineColor(i, clusterCockpitConfig.plotConfiguration_colorScheme),
   199→          });
   200→        }
   201→        // Extended Legend For NodeList
   202→        else {
   203→          pendingSeries.push({
   204→            label:
   205→              scope === "node"
   206→                ? series[i].hostname
   207→                : scope === "accelerator"
   208→                  ? 'Acc #' + (i + 1) // series[i].id.slice(9, 14) | Too Hardware Specific
   209→                  : scope + " #" + (i + 1),
   210→            scale: "y",
   211→            width: lineWidth,
   212→            stroke: lineColor(i, clusterCockpitConfig.plotConfiguration_colorScheme),
   213→            values: (u, sidx, idx) => {
   214→              // "i" = "sidx - 1" : sidx contains x-axis-data
   215→              if (idx == null)
   216→                return {
   217→                  time: '-',
   218→                  value: '-',
   219→                  user: '-',


   515→          (u, key) => { // If ZoomResample is Configured && Not System/Node View
   516→            if (resampleConfig && !forNode && key === 'x') {
   517→              const numX = (u.series[0].idxs[1] - u.series[0].idxs[0])
   518→              if (numX <= resampleTrigger && timestep !== resampleMinimum) {
   519→                let newRes;
   520→                if (resampleTargetPoints && !resampleResolutions) {
   521→                  // Policy-based: compute resolution dynamically from visible window
   522→                  const visibleDuration = (u.scales.x.max - u.scales.x.min);
   523→                  const nativeTimestep = metricConfig?.timestep || timestep;
   524→                  newRes = Math.ceil(visibleDuration / resampleTargetPoints / nativeTimestep) * nativeTimestep;
   525→                  if (newRes < nativeTimestep) newRes = nativeTimestep;
   526→                } else if (resampleResolutions) {
   527→                  // Array-based: find closest configured resolution
   528→                  const target = (numX * timestep) / resampleTrigger;
   529→                  newRes = resampleResolutions.reduce(function(prev, curr) {
   530→                    return (Math.abs(curr - target) < Math.abs(prev - target) ? curr : prev);
   531→                  });
   532→                }
   533→                // Prevents non-required dispatches
   534→                if (newRes && timestep !== newRes) {
   535→                  onZoom({
   536→                    newRes: newRes,
   537→                    lastZoomState: u?.scales,
   538→                    lastThreshold: thresholds?.normal
   539→                  });
   540→                }
   541→              } else {
   542→                onZoom({
   543→                  lastZoomState: u?.scales,
   544→                  lastThreshold: thresholds?.normal
   545→                });
   546→              };
   547→            };
   548→          },
   549→        ]
   550→      },
   551→      scales: {
   552→        x: { time: false },
   553→        y: maxY ? { min: 0, max: (maxY * 1.1) } : {auto: true}, // Add some space to upper render limit
   554→      },
   555→      legend: {
   556→        // Display legend until max 12 Y-dataseries
   557→        show: series.length <= 12 || useStatsSeries,
   558→        live: series.length <= 12 || useStatsSeries,
   559→      },
   560→      cursor: {
   561→        drag: { x: true, y: true },
   562→      }
   563→    };
   564→
   565→    // Handle Render
   566→    if (!uplot) {
   567→      opts.width = ren_width;
   568→      opts.height = ren_height;
   569→
   570→      if (plotSync) {
   571→        opts.cursor.sync = {
   572→          key: plotSync.key,
   573→          scales: ["x", null],
   574→        }
   575→      }
   576→
   577→      if (zoomState && metricConfig?.aggregation == "avg") {
   578→        opts.scales = {...zoomState}
   579→      } else if (zoomState && metricConfig?.aggregation == "sum") {
   580→        // Allow Zoom In === Ymin changed
   581→        if (zoomState.y.min !== 0) { // scope change?: only use zoomState if thresholds match
   582→          if ((thresholdState === thresholds?.normal)) { opts.scales = {...zoomState} };
   583→        } // else: reset scaling to default
   584→      }
   585→
   586→      uplot = new uPlot(opts, plotData, plotWrapper);
   587→    } else {
   588→      uplot.setSize({ width: ren_width, height: ren_height });
   589→    }
   590→  }
   591→
   592→  function onSizeChange(chg_width, chg_height) {
   593→    if (!uplot) return;
   594→    if (timeoutId != null) clearTimeout(timeoutId);
   595→    timeoutId = setTimeout(() => {
   596→      timeoutId = null;
   597→      render(chg_width, chg_height);
   598→    }, renderSleepTime);
   599→  }
   600→
   601→  /* On Mount */
   602→  onMount(() => {
   603→    if (plotWrapper) {
   604→      render(width, height);
   605→    }
   606→  });
   607→
   608→  /* On Destroy */
   609→  onDestroy(() => {
   610→    if (timeoutId != null) clearTimeout(timeoutId);
   611→    if (uplot) uplot.destroy();
   612→  });
   613→
   614→</script>


cd /Users/jan/prg/ClusterCockpit/cc-backend && find . -name "*.go" -path "*/metricdispatch/*" -type f | head -20


./internal/metricdispatch/resamplepolicy_test.go
./internal/metricdispatch/metricdata.go
./internal/metricdispatch/resamplepolicy.go
./internal/metricdispatch/configSchema.go
./internal/metricdispatch/dataLoader_test.go
./internal/metricdispatch/dataLoader.go


cd /Users/jan/prg/ClusterCockpit/cc-backend && grep -n "func LoadData\|func LoadNodeListData" internal/metricdispatch/dataLoader.go


86:func LoadData(job *schema.Job,
415:func LoadNodeListData(


    86→func LoadData(job *schema.Job,
    87→	metrics []string,
    88→	scopes []schema.MetricScope,
    89→	ctx context.Context,
    90→	resolution int,
    91→	resampleAlgo string,
    92→) (schema.JobData, error) {
    93→	data := cache.Get(cacheKey(job, metrics, scopes, resolution, resampleAlgo), func() (_ any, ttl time.Duration, size int) {
    94→		var jd schema.JobData
    95→		var err error
    96→
    97→		if job.State == schema.JobStateRunning ||
    98→			job.MonitoringStatus == schema.MonitoringStatusRunningOrArchiving {
    99→
   100→			ms, err := GetMetricDataRepo(job.Cluster, job.SubCluster)
   101→			if err != nil {
   102→				cclog.Errorf("failed to access metricDataRepo for cluster %s-%s: %s",
   103→					job.Cluster, job.SubCluster, err.Error())
   104→				return err, 0, 0
   105→			}
   106→
   107→			if scopes == nil {
   108→				scopes = append(scopes, schema.MetricScopeNode)
   109→			}
   110→
   111→			if metrics == nil {
   112→				cluster := archive.GetCluster(job.Cluster)
   113→				for _, mc := range cluster.MetricConfig {
   114→					metrics = append(metrics, mc.Name)
   115→				}
   116→			}
   117→
   118→			jd, err = ms.LoadData(job, metrics, scopes, ctx, resolution)
   119→			if err != nil {
   120→				if len(jd) != 0 {
   121→					cclog.Warnf("partial error loading metrics from store for job %d (user: %s, project: %s, cluster: %s-%s): %s",
   122→						job.JobID, job.User, job.Project, job.Cluster, job.SubCluster, err.Error())
   123→				} else {
   124→					cclog.Warnf("failed to load job data from metric store for job %d (user: %s, project: %s, cluster: %s-%s): %s",
   125→						job.JobID, job.User, job.Project, job.Cluster, job.SubCluster, err.Error())
   126→					return err, 0, 0
   127→				}
   128→			}
   129→			size = jd.Size()
   130→		} else {
   131→			var jdTemp schema.JobData
   132→			jdTemp, err = archive.GetHandle().LoadJobData(job)
   133→			if err != nil {
   134→				cclog.Warnf("failed to load job data from archive for job %d (user: %s, project: %s, cluster: %s-%s): %s",
   135→					job.JobID, job.User, job.Project, job.Cluster, job.SubCluster, err.Error())
   136→				return err, 0, 0
   137→			}
   138→
   139→			jd = deepCopy(jdTemp)
   140→
   141→			// Resample archived data to reduce data points to the requested resolution,
   142→			// improving transfer performance and client-side rendering.
   143→			resampleFn, rfErr := resampler.GetResampler(resampleAlgo)
   144→			if rfErr != nil {
   145→				return rfErr, 0, 0
   146→			}
   147→			for _, v := range jd {
   148→				for _, v_ := range v {
   149→					timestep := int64(0)
   150→					for i := 0; i < len(v_.Series); i += 1 {
   151→						v_.Series[i].Data, timestep, err = resampleFn(v_.Series[i].Data, int64(v_.Timestep), int64(resolution))
   152→						if err != nil {
   153→							return err, 0, 0
   154→						}
   155→					}
   156→					v_.Timestep = int(timestep)
   157→				}
   158→			}
   159→
   160→			// Filter job data to only include requested metrics and scopes, avoiding unnecessary data transfer.
   161→			if metrics != nil || scopes != nil {
   162→				if metrics == nil {
   163→					metrics = make([]string, 0, len(jd))
   164→					for k := range jd {
   165→						metrics = append(metrics, k)
   166→					}
   167→				}
   168→
   169→				res := schema.JobData{}
   170→				for _, metric := range metrics {
   171→					if perscope, ok := jd[metric]; ok {
   172→						if len(perscope) > 1 {
   173→							subset := make(map[schema.MetricScope]*schema.JobMetric)
   174→							for _, scope := range scopes {
   175→								if jm, ok := perscope[scope]; ok {
   176→									subset[scope] = jm
   177→								}
   178→							}
   179→
   180→							if len(subset) > 0 {
   181→								perscope = subset
   182→							}
   183→						}
   184→
   185→						res[metric] = perscope


cd /Users/jan/prg/ClusterCockpit/cc-backend && find . -name "config*.json" -o -name "*example*.json" | grep -v node_modules | head -10


./config.json
./configs/config.json
./configs/config-demo.json


cd /Users/jan/prg/ClusterCockpit/cc-backend && grep -A 5 '"resampling"' configs/config.json | head -15


    "resampling": {
      "minimum-points": 600,
      "trigger": 180,
      "resolutions": [240, 60]
    },
    "api-subjects": {


cd /Users/jan/prg/ClusterCockpit/cc-backend && grep -n "type Page\|Resampling" web/web.go | head -20


259:type Page struct {
271:	Resampling    *config.ResampleConfig // If not nil, defines resampling trigger and resolutions


   259→type Page struct {
   260→	Title         string                 // Page title
   261→	MsgType       string                 // For generic use in message boxes
   262→	Message       string                 // For generic use in message boxes
   263→	User          schema.User            // Information about the currently logged in user (Full User Info)
   264→	Roles         map[string]schema.Role // Available roles for frontend render checks
   265→	Build         Build                  // Latest information about the application
   266→	Clusters      []string               // List of all cluster names
   267→	SubClusters   map[string][]string    // Map per cluster of all subClusters for use in the Header
   268→	FilterPresets map[string]any         // For pages with the Filter component, this can be used to set initial filters.
   269→	Infos         map[string]any         // For generic use (e.g. username for /monitoring/user/<id>, job id for /monitoring/job/<id>)
   270→	Config        map[string]any         // UI settings for the currently logged in user (e.g. line width, ...)
   271→	Resampling    *config.ResampleConfig // If not nil, defines resampling trigger and resolutions
   272→	Redirect      string                 // The originally requested URL, for intermediate login handling
   273→}
   274→
   275→func RenderTemplate(rw http.ResponseWriter, file string, page *Page) {
   276→	t, ok := templates[file]
   277→	if !ok {
   278→		cclog.Errorf("WEB/WEB > template '%s' not found", file)
   279→	}
   280→
   281→	if page.Clusters == nil {
   282→		page.Clusters = make([]string, 0)
   283→	}
   284→
   285→	if page.SubClusters == nil {
   286→		page.SubClusters = make(map[string][]string)
   287→		for _, cluster := range archive.Clusters {
   288→			page.Clusters = append(page.Clusters, cluster.Name)
   289→
   290→			for _, sc := range cluster.SubClusters {
   291→				page.SubClusters[cluster.Name] = append(page.SubClusters[cluster.Name], sc.Name)
   292→			}
   293→		}
   294→	}
   295→
   296→	cclog.Debugf("Page config : %v\n", page.Config)
   297→	if err := t.Execute(rw, page); err != nil {
   298→		cclog.Errorf("Template error: %s", err.Error())
   299→	}
   300→}
   301→


cd /Users/jan/prg/ClusterCockpit/cc-backend && find . -path "*/web/templates/*.tmpl" -type f | head -5


./web/templates/404.tmpl
./web/templates/login.tmpl
./web/templates/message.tmpl
./web/templates/home.tmpl
./web/templates/base.tmpl


cd /Users/jan/prg/ClusterCockpit/cc-backend && grep -r "setContext.*resampling\|resampling.*context" web/templates/ web/frontend/src/ --include="*.svelte" --include="*.tmpl" | head -10


(Bash completed with no output)


cd /Users/jan/prg/ClusterCockpit/cc-backend && grep -r "getContext.*resampling" web/frontend/src/ --include="*.svelte" | head -10