Now let me read the relevant sections of schema.resolvers.go more precisely.


   500→// JobMetrics is the resolver for the jobMetrics field.
   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→

// JobMetrics is the resolver for the jobMetrics field.
func (r *queryResolver) JobMetrics(ctx context.Context, id string, metrics []string, scopes []schema.MetricScope, resolution *int, resampleAlgo *model.ResampleAlgo) ([]*model.JobMetricWithName, error) {
	job, err := r.Query().Job(ctx, id)
	if err != nil {
		cclog.Warn("Error while querying job for metrics")
		return nil, err
	}

	// Resolve resolution: explicit param > user policy > global config > 0
	if resolution == nil {
		resolution = resolveResolutionFromPolicy(ctx, int64(job.Duration), job.Cluster, metrics)
	}
	if resolution == nil {
		if config.Keys.EnableResampling != nil {
			defaultRes := slices.Max(config.Keys.EnableResampling.Resolutions)
			resolution = &defaultRes
		} else {
			defaultRes := 0
			resolution = &defaultRes
		}
	}

	algoName := resolveResampleAlgo(ctx, resampleAlgo)

	data, err := metricdispatch.LoadData(job, metrics, scopes, ctx, *resolution, algoName)
	if err != nil {
		cclog.Warn("Error while loading job data")
		return nil, err
	}


   878→}
   879→
   880→// NodeMetricsList is the resolver for the nodeMetricsList field.
   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()

}

// NodeMetricsList is the resolver for the nodeMetricsList field.
func (r *queryResolver) NodeMetricsList(ctx context.Context, cluster string, subCluster string, stateFilter string, nodeFilter string, scopes []schema.MetricScope, metrics []string, from time.Time, to time.Time, page *model.PageRequest, resolution *int, resampleAlgo *model.ResampleAlgo) (*model.NodesResultList, error) {
	// Resolve resolution: explicit param > user policy > global config > 0
	duration := int64(to.Sub(from).Seconds())
	if resolution == nil {
		resolution = resolveResolutionFromPolicy(ctx, duration, cluster, metrics)
	}
	if resolution == nil {
		if config.Keys.EnableResampling != nil {
			defaultRes := slices.Max(config.Keys.EnableResampling.Resolutions)
			resolution = &defaultRes
		} else {
			defaultRes := 0
			resolution = &defaultRes
		}
	}

	user := repository.GetUserFromContext(ctx)
	if user != nil && !user.HasAnyRole([]schema.Role{schema.RoleAdmin, schema.RoleSupport}) {
		return nil, errors.New("you need to be administrator or support staff for this query")
	}

	nodeRepo := repository.GetNodeRepository()


Now I also need to check what imports are used in schema.resolvers.go for `slices`.


     1→package graph
     2→
     3→// This file will be automatically regenerated based on the schema, any resolver
     4→// implementations
     5→// will be copied through when generating and any unknown code will be moved to the end.
     6→// Code generated by github.com/99designs/gqlgen version v0.17.88
     7→
     8→import (
     9→	"context"
    10→	"errors"
    11→	"fmt"
    12→	"math"
    13→	"regexp"
    14→	"slices"
    15→	"strconv"
    16→	"strings"
    17→	"time"
    18→
    19→	"github.com/ClusterCockpit/cc-backend/internal/config"
    20→	"github.com/ClusterCockpit/cc-backend/internal/graph/generated"

package graph

// This file will be automatically regenerated based on the schema, any resolver
// implementations
// will be copied through when generating and any unknown code will be moved to the end.
// Code generated by github.com/99designs/gqlgen version v0.17.88

import (
	"context"
	"errors"
	"fmt"
	"math"
	"regexp"
	"slices"
	"strconv"
	"strings"
	"time"

	"github.com/ClusterCockpit/cc-backend/internal/config"
	"github.com/ClusterCockpit/cc-backend/internal/graph/generated"


Now let me check if `slices` is used elsewhere in this file.


514:			defaultRes := slices.Max(config.Keys.EnableResampling.Resolutions)
837:		metrics = slices.DeleteFunc(metrics, func(metric string) bool {
838:			return !slices.Contains(defaultMetrics, metric) // Remove undefined metrics.
889:			defaultRes := slices.Max(config.Keys.EnableResampling.Resolutions)


Good, `slices` is still used on lines 837-838, so the import stays. Now I have everything I need. Let me make all the edits.


Edit

type ResampleConfig struct {
	// Minimum number of points to trigger resampling of data
	MinimumPoints int `json:"minimum-points"`
	// Array of resampling target resolutions, in seconds; Example: [600,300,60]
	Resolutions []int `json:"resolutions"`
	// Trigger next zoom level at less than this many visible datapoints
	Trigger int `json:"trigger"`
	// Policy-derived target point count (set dynamically from user preference, not from config.json)
	TargetPoints int `json:"targetPoints,omitempty"`
}

type ResampleConfig struct {
	// Minimum number of points to trigger resampling of data
	MinimumPoints int `json:"minimum-points"`
	// Default resample policy when no user preference is set ("low", "medium", "high")
	DefaultPolicy string `json:"default-policy"`
	// Default resample algorithm when no user preference is set ("lttb", "average", "simple")
	DefaultAlgo string `json:"default-algo"`
	// Policy-derived target point count (set dynamically from user preference, not from config.json)
	TargetPoints int `json:"targetPoints,omitempty"`
}


The file /Users/jan/prg/ClusterCockpit/cc-backend/internal/config/config.go has been updated successfully.

type ResampleConfig struct {
	// Minimum number of points to trigger resampling of data
	MinimumPoints int `json:"minimum-points"`
	// Default resample policy when no user preference is set ("low", "medium", "high")
	DefaultPolicy string `json:"default-policy"`
	// Default resample algorithm when no user preference is set ("lttb", "average", "simple")
	DefaultAlgo string `json:"default-algo"`
	// Policy-derived target point count (set dynamically from user preference, not from config.json)
	TargetPoints int `json:"targetPoints,omitempty"`
}

// Copyright (C) NHR@FAU, University Erlangen-Nuremberg.
// All rights reserved. This file is part of cc-backend.
// Use of this source code is governed by a MIT-style
// license that can be found in the LICENSE file.

// Package config implements the program configuration data structures, validation and parsing
package config

import (
	"bytes"
	"encoding/json"
	"time"

	cclog "github.com/ClusterCockpit/cc-lib/v2/ccLogger"
	"github.com/ClusterCockpit/cc-lib/v2/resampler"
)

type ProgramConfig struct {
	// Address where the http (or https) server will listen on (for example: 'localhost:80').
	Addr string `json:"addr"`

	// Addresses from which secured admin API endpoints can be reached, can be wildcard "*"
	APIAllowedIPs []string `json:"api-allowed-ips"`

	APISubjects *NATSConfig `json:"api-subjects"`

	// Drop root permissions once .env was read and the port was taken.
	User  string `json:"user"`
	Group string `json:"group"`

	// Disable authentication (for everything: API, Web-UI, ...)
	DisableAuthentication bool `json:"disable-authentication"`

	// If `embed-static-files` is true (default), the frontend files are directly
	// embeded into the go binary and expected to be in web/frontend. Only if
	// it is false the files in `static-files` are served instead.
	EmbedStaticFiles bool   `json:"embed-static-files"`
	StaticFiles      string `json:"static-files"`

	// Path to SQLite database file
	DB string `json:"db"`

	EnableJobTaggers bool `json:"enable-job-taggers"`

	// Validate json input against schema
	Validate bool `json:"validate"`

	// If 0 or empty, the session does not expire!
	SessionMaxAge string `json:"session-max-age"`

	// If both those options are not empty, use HTTPS using those certificates.
	HTTPSCertFile string `json:"https-cert-file"`
	HTTPSKeyFile  string `json:"https-key-file"`

	// If not the empty string and `addr` does not end in ":80",
	// redirect every request incoming at port 80 to that url.
	RedirectHTTPTo string `json:"redirect-http-to"`

	// Where to store MachineState files
	MachineStateDir string `json:"machine-state-dir"`

	// If not zero, automatically mark jobs as stopped running X seconds longer than their walltime.
	StopJobsExceedingWalltime int `json:"stop-jobs-exceeding-walltime"`

	// Defines time X in seconds in which jobs are considered to be "short" and will be filtered in specific views.
	ShortRunningJobsDuration int `json:"short-running-jobs-duration"`

	// Energy Mix CO2 Emission Constant [g/kWh]
	// If entered, displays estimated CO2 emission for job based on jobs totalEnergy
	EmissionConstant int `json:"emission-constant"`

	// If exists, will enable dynamic zoom in frontend metric plots using the configured values
	EnableResampling *ResampleConfig `json:"resampling"`

	// Systemd unit name for log viewer (default: "clustercockpit")
	SystemdUnit string `json:"systemd-unit"`

	// Node state retention configuration
	NodeStateRetention *NodeStateRetention `json:"nodestate-retention"`

	// Database tuning configuration
	DbConfig *DbConfig `json:"db-config"`
}

type DbConfig struct {
	CacheSizeMB               int `json:"cache-size-mb"`
	SoftHeapLimitMB           int `json:"soft-heap-limit-mb"`
	MaxOpenConnections        int `json:"max-open-connections"`
	MaxIdleConnections        int `json:"max-idle-connections"`
	ConnectionMaxIdleTimeMins int `json:"max-idle-time-minutes"`
	BusyTimeoutMs             int `json:"busy-timeout-ms"`
}

type NodeStateRetention struct {
	Policy             string `json:"policy"`      // "delete" or "move"
	Age                int    `json:"age"`         // hours, default 24
	TargetKind         string `json:"target-kind"` // "file" or "s3"
	TargetPath         string `json:"target-path"`
	TargetEndpoint     string `json:"target-endpoint"`
	TargetBucket       string `json:"target-bucket"`
	TargetAccessKey    string `json:"target-access-key"`
	TargetSecretKey    string `json:"target-secret-key"`
	TargetRegion       string `json:"target-region"`
	TargetUsePathStyle bool   `json:"target-use-path-style"`
	MaxFileSizeMB      int    `json:"max-file-size-mb"`
}

type ResampleConfig struct {
	// Minimum number of points to trigger resampling of data
	MinimumPoints int `json:"minimum-points"`
	// Array of resampling target resolutions, in seconds; Example: [600,300,60]
	Resolutions []int `json:"resolutions"`
	// Trigger next zoom level at less than this many visible datapoints
	Trigger int `json:"trigger"`
	// Policy-derived target point count (set dynamically from user preference, not from config.json)
	TargetPoints int `json:"targetPoints,omitempty"`
}

type NATSConfig struct {
	SubjectJobEvent  string `json:"subject-job-event"`
	SubjectNodeState string `json:"subject-node-state"`
	JobConcurrency   int    `json:"job-concurrency"`
	NodeConcurrency  int    `json:"node-concurrency"`
}

type IntRange struct {
	From int `json:"from"`
	To   int `json:"to"`
}

type TimeRange struct {
	From  *time.Time `json:"from"`
	To    *time.Time `json:"to"`
	Range string     `json:"range,omitempty"`
}

type FilterRanges struct {
	Duration  *IntRange  `json:"duration"`
	NumNodes  *IntRange  `json:"num-nodes"`
	StartTime *TimeRange `json:"start-time"`
}

var Keys ProgramConfig = ProgramConfig{
	Addr:                      "localhost:8080",
	EmbedStaticFiles:          true,
	DB:                        "./var/job.db",
	SessionMaxAge:             "168h",
	StopJobsExceedingWalltime: 0,
	ShortRunningJobsDuration:  5 * 60,
}

func Init(mainConfig json.RawMessage) {
	Validate(configSchema, mainConfig)
	dec := json.NewDecoder(bytes.NewReader(mainConfig))
	dec.DisallowUnknownFields()
	if err := dec.Decode(&Keys); err != nil {
		cclog.Abortf("Config Init: Could not decode config file '%s'.\nError: %s\n", mainConfig, err.Error())
	}

	if Keys.EnableResampling != nil && Keys.EnableResampling.MinimumPoints > 0 {
		resampler.SetMinimumRequiredPoints(Keys.EnableResampling.MinimumPoints)
	}
}

-  // Array of resampling target resolutions, in seconds; Example: [600,300,60]

-  Resolutions []int `json:"resolutions"`

-  // Trigger next zoom level at less than this many visible datapoints

+  // Default resample policy when no user preference is set ("low", "medium", "high")

+  DefaultPolicy string `json:"default-policy"`

+  // Default resample algorithm when no user preference is set ("lttb", "average", "simple")

+  DefaultAlgo string `json:"default-algo"`


    "resampling": {
      "description": "Enable dynamic zoom in frontend metric plots.",
      "type": "object",
      "properties": {
        "minimum-points": {
          "description": "Minimum points to trigger resampling of time-series data.",
          "type": "integer"
        },
        "trigger": {
          "description": "Trigger next zoom level at less than this many visible datapoints.",
          "type": "integer"
        },
        "resolutions": {
          "description": "Array of resampling target resolutions, in seconds.",
          "type": "array",
          "items": {
            "type": "integer"
          }
        }
      },
      "required": ["trigger", "resolutions"]
    },

    "resampling": {
      "description": "Enable dynamic zoom in frontend metric plots.",
      "type": "object",
      "properties": {
        "minimum-points": {
          "description": "Minimum points to trigger resampling of time-series data.",
          "type": "integer"
        },
        "default-policy": {
          "description": "Default resample policy when no user preference is set.",
          "type": "string",
          "enum": ["low", "medium", "high"]
        },
        "default-algo": {
          "description": "Default resample algorithm when no user preference is set.",
          "type": "string",
          "enum": ["lttb", "average", "simple"]
        }
      }
    },


The file /Users/jan/prg/ClusterCockpit/cc-backend/internal/config/schema.go has been updated successfully.

    "resampling": {
      "description": "Enable dynamic zoom in frontend metric plots.",
      "type": "object",
      "properties": {
        "minimum-points": {
          "description": "Minimum points to trigger resampling of time-series data.",
          "type": "integer"
        },
        "default-policy": {
          "description": "Default resample policy when no user preference is set.",
          "type": "string",
          "enum": ["low", "medium", "high"]
        },
        "default-algo": {
          "description": "Default resample algorithm when no user preference is set.",
          "type": "string",
          "enum": ["lttb", "average", "simple"]
        }
      }
    },

// Copyright (C) NHR@FAU, University Erlangen-Nuremberg.
// All rights reserved. This file is part of cc-backend.
// Use of this source code is governed by a MIT-style
// license that can be found in the LICENSE file.

package config

var configSchema = `
{
  "type": "object",
  "properties": {
    "addr": {
      "description": "Address where the http (or https) server will listen on (for example: 'localhost:80').",
      "type": "string"
    },
    "api-allowed-ips": {
      "description": "Addresses from which secured API endpoints can be reached",
      "type": "array",
      "items": {
        "type": "string"
      }
    },
    "user": {
      "description": "Drop root permissions once .env was read and the port was taken. Only applicable if using privileged port.",
      "type": "string"
    },
    "group": {
      "description": "Drop root permissions once .env was read and the port was taken. Only applicable if using privileged port.",
      "type": "string"
    },
    "disable-authentication": {
      "description": "Disable authentication (for everything: API, Web-UI, ...).",
      "type": "boolean"
    },
    "embed-static-files": {
      "description": "If all files in web/frontend/public should be served from within the binary itself (they are embedded) or not.",
      "type": "boolean"
    },
    "static-files": {
      "description": "Folder where static assets can be found, if embed-static-files is false.",
      "type": "string"
    },
    "db": {
      "description": "Path to SQLite database file (e.g., './var/job.db')",
      "type": "string"
    },
    "enable-job-taggers": {
      "description": "Turn on automatic application and jobclass taggers",
      "type": "boolean"
    },
    "validate": {
      "description": "Validate all input json documents against json schema.",
      "type": "boolean"
    },
    "session-max-age": {
      "description": "Specifies for how long a session shall be valid  as a string parsable by time.ParseDuration(). If 0 or empty, the session/token does not expire!",
      "type": "string"
    },
    "https-cert-file": {
      "description": "Filepath to SSL certificate. If also https-key-file is set use HTTPS using those certificates.",
      "type": "string"
    },
    "https-key-file": {
      "description": "Filepath to SSL key file. If also https-cert-file is set use HTTPS using those certificates.",
      "type": "string"
    },
    "redirect-http-to": {
      "description": "If not the empty string and addr does not end in :80, redirect every request incoming at port 80 to that url.",
      "type": "string"
    },
    "stop-jobs-exceeding-walltime": {
      "description": "If not zero, automatically mark jobs as stopped running X seconds longer than their walltime. Only applies if walltime is set for job.",
      "type": "integer"
    },
    "short-running-jobs-duration": {
      "description": "Do not show running jobs shorter than X seconds.",
      "type": "integer"
    },
    "emission-constant": {
      "description": "Energy mix CO2 emission constant [g/kWh]. If set, displays estimated CO2 emission for jobs.",
      "type": "integer"
    },
    "machine-state-dir": {
      "description": "Where to store MachineState files.",
      "type": "string"
    },
    "systemd-unit": {
      "description": "Systemd unit name for log viewer (default: 'clustercockpit').",
      "type": "string"
    },
    "resampling": {
      "description": "Enable dynamic zoom in frontend metric plots.",
      "type": "object",
      "properties": {
        "minimum-points": {
          "description": "Minimum points to trigger resampling of time-series data.",
          "type": "integer"
        },
        "trigger": {
          "description": "Trigger next zoom level at less than this many visible datapoints.",
          "type": "integer"
        },
        "resolutions": {
          "description": "Array of resampling target resolutions, in seconds.",
          "type": "array",
          "items": {
            "type": "integer"
          }
        }
      },
      "required": ["trigger", "resolutions"]
    },
    "api-subjects": {
      "description": "NATS subjects configuration for subscribing to job and node events.",
      "type": "object",
      "properties": {
        "subject-job-event": {
          "description": "NATS subject for job events (start_job, stop_job)",
          "type": "string"
        },
        "subject-node-state": {
          "description": "NATS subject for node state updates",
          "type": "string"
        },
        "job-concurrency": {
          "description": "Number of concurrent worker goroutines for processing job events (default: 8).",
          "type": "integer"
        },
        "node-concurrency": {
          "description": "Number of concurrent worker goroutines for processing node state events (default: 2).",
          "type": "integer"
        }
      },
      "required": ["subject-job-event", "subject-node-state"]
    },
    "nodestate-retention": {
      "description": "Node state retention configuration for cleaning up old node_state rows.",
      "type": "object",
      "properties": {
        "policy": {
          "description": "Retention policy: 'delete' to remove old rows, 'move' to archive to Parquet then delete.",
          "type": "string",
          "enum": ["delete", "move"]
        },
        "age": {
          "description": "Retention age in hours (default: 24).",
          "type": "integer"
        },
        "target-kind": {
          "description": "Target kind for parquet archiving: 'file' or 's3'.",
          "type": "string",
          "enum": ["file", "s3"]
        },
        "target-path": {
          "description": "Filesystem path for parquet file target.",
          "type": "string"
        },
        "target-endpoint": {
          "description": "S3 endpoint URL.",
          "type": "string"
        },
        "target-bucket": {
          "description": "S3 bucket name.",
          "type": "string"
        },
        "target-access-key": {
          "description": "S3 access key.",
          "type": "string"
        },
        "target-secret-key": {
          "description": "S3 secret key.",
          "type": "string"
        },
        "target-region": {
          "description": "S3 region.",
          "type": "string"
        },
        "target-use-path-style": {
          "description": "Use path-style S3 addressing.",
          "type": "boolean"
        },
        "max-file-size-mb": {
          "description": "Maximum parquet file size in MB (default: 128).",
          "type": "integer"
        }
      },
      "required": ["policy"]
    },
    "db-config": {
      "description": "SQLite database tuning configuration.",
      "type": "object",
      "properties": {
        "cache-size-mb": {
          "description": "SQLite page cache size per connection in MB (default: 2048).",
          "type": "integer"
        },
        "soft-heap-limit-mb": {
          "description": "Process-wide SQLite soft heap limit in MB (default: 16384).",
          "type": "integer"
        },
        "max-open-connections": {
          "description": "Maximum number of open database connections (default: 4).",
          "type": "integer"
        },
        "max-idle-connections": {
          "description": "Maximum number of idle database connections (default: 4).",
          "type": "integer"
        },
        "max-idle-time-minutes": {
          "description": "Maximum idle time for a connection in minutes (default: 10).",
          "type": "integer"
        },
        "busy-timeout-ms": {
          "description": "SQLite busy timeout in milliseconds. When a write is blocked, SQLite retries with backoff for up to this duration before returning SQLITE_BUSY (default: 60000).",
          "type": "integer"
        }
      }
    }
  }
}`

-          "description": "Trigger next zoom level at less than this many visible datapoints.",

+          "description": "Default resample policy when no user preference is set.",

+          "enum": ["low", "medium", "high"]

-          "description": "Array of resampling target resolutions, in seconds.",

+          "description": "Default resample algorithm when no user preference is set.",

+          "enum": ["lttb", "average", "simple"]

-      "required": ["trigger", "resolutions"]


Now let me fix the remaining files.


	policyVal, ok := conf["plotConfiguration_resamplePolicy"]
	if !ok {
		return globalCfg
	}
	policyStr, ok := policyVal.(string)
	if !ok || policyStr == "" {
		return globalCfg
	}

	policy := metricdispatch.ResamplePolicy(policyStr)
	targetPoints := metricdispatch.TargetPointsForPolicy(policy)
	if targetPoints == 0 {
		return globalCfg
	}

	// Build a policy-derived config: targetPoints + trigger, no resolutions array
	return &config.ResampleConfig{
		TargetPoints: targetPoints,
		Trigger:      targetPoints / 4,
	}

	policyStr := ""
	if policyVal, ok := conf["plotConfiguration_resamplePolicy"]; ok {
		if s, ok := policyVal.(string); ok {
			policyStr = s
		}
	}

	// Fall back to global default policy, then to "medium"
	if policyStr == "" && globalCfg != nil {
		policyStr = globalCfg.DefaultPolicy
	}
	if policyStr == "" {
		policyStr = "medium"
	}

	policy := metricdispatch.ResamplePolicy(policyStr)
	targetPoints := metricdispatch.TargetPointsForPolicy(policy)
	if targetPoints == 0 {
		return globalCfg
	}

	return &config.ResampleConfig{
		TargetPoints: targetPoints,
	}


The file /Users/jan/prg/ClusterCockpit/cc-backend/internal/routerConfig/routes.go has been updated successfully.

	policyStr := ""
	if policyVal, ok := conf["plotConfiguration_resamplePolicy"]; ok {
		if s, ok := policyVal.(string); ok {
			policyStr = s
		}
	}

	// Fall back to global default policy, then to "medium"
	if policyStr == "" && globalCfg != nil {
		policyStr = globalCfg.DefaultPolicy
	}
	if policyStr == "" {
		policyStr = "medium"
	}

	policy := metricdispatch.ResamplePolicy(policyStr)
	targetPoints := metricdispatch.TargetPointsForPolicy(policy)
	if targetPoints == 0 {
		return globalCfg
	}

	return &config.ResampleConfig{
		TargetPoints: targetPoints,
	}

// Copyright (C) NHR@FAU, University Erlangen-Nuremberg.
// All rights reserved. This file is part of cc-backend.
// Use of this source code is governed by a MIT-style
// license that can be found in the LICENSE file.
package routerConfig

import (
	"fmt"
	"net/http"
	"net/url"
	"os"
	"strconv"
	"strings"
	"time"

	"github.com/ClusterCockpit/cc-backend/internal/config"
	"github.com/ClusterCockpit/cc-backend/internal/graph/model"
	"github.com/ClusterCockpit/cc-backend/internal/metricdispatch"
	"github.com/ClusterCockpit/cc-backend/internal/repository"
	"github.com/ClusterCockpit/cc-backend/web"
	cclog "github.com/ClusterCockpit/cc-lib/v2/ccLogger"
	"github.com/ClusterCockpit/cc-lib/v2/schema"
	"github.com/ClusterCockpit/cc-lib/v2/util"
	"github.com/go-chi/chi/v5"
)

type InfoType map[string]any

type Route struct {
	Route    string
	Template string
	Title    string
	Filter   bool
	Setup    func(i InfoType, r *http.Request) InfoType
}

var routes []Route = []Route{
	{"/", "home.tmpl", "ClusterCockpit", false, setupHomeRoute},
	{"/config", "config.tmpl", "Settings", false, setupConfigRoute},
	{"/monitoring/jobs/", "monitoring/jobs.tmpl", "Jobs - ClusterCockpit", true, func(i InfoType, r *http.Request) InfoType { return i }},
	{"/monitoring/job/{id:[0-9]+}", "monitoring/job.tmpl", "Job <ID> - ClusterCockpit", false, setupJobRoute},
	{"/monitoring/users/", "monitoring/list.tmpl", "Users - ClusterCockpit", true, func(i InfoType, r *http.Request) InfoType { i["listType"] = "USER"; return i }},
	{"/monitoring/projects/", "monitoring/list.tmpl", "Projects - ClusterCockpit", true, func(i InfoType, r *http.Request) InfoType { i["listType"] = "PROJECT"; return i }},
	{"/monitoring/tags/", "monitoring/taglist.tmpl", "Tags - ClusterCockpit", false, setupTaglistRoute},
	{"/monitoring/user/{id}", "monitoring/user.tmpl", "User <ID> - ClusterCockpit", true, setupUserRoute},
	{"/monitoring/systems/{cluster}", "monitoring/systems.tmpl", "Cluster <ID> Node Overview - ClusterCockpit", false, setupClusterOverviewRoute},
	{"/monitoring/systems/list/{cluster}", "monitoring/systems.tmpl", "Cluster <ID> Node List - ClusterCockpit", false, setupClusterListRoute},
	{"/monitoring/systems/list/{cluster}/{subcluster}", "monitoring/systems.tmpl", "Cluster <ID> <SID> Node List - ClusterCockpit", false, setupClusterListRoute},
	{"/monitoring/node/{cluster}/{hostname}", "monitoring/node.tmpl", "Node <ID> - ClusterCockpit", false, setupNodeRoute},
	{"/monitoring/analysis/{cluster}", "monitoring/analysis.tmpl", "Analysis - ClusterCockpit", true, setupAnalysisRoute},
	{"/monitoring/status/{cluster}", "monitoring/status.tmpl", "<ID> Dashboard - ClusterCockpit", false, setupClusterStatusRoute},
	{"/monitoring/status/detail/{cluster}", "monitoring/status.tmpl", "Status of <ID> - ClusterCockpit", false, setupClusterDetailRoute},
	{"/monitoring/dashboard/{cluster}", "monitoring/dashboard.tmpl", "<ID> Dashboard - ClusterCockpit", false, setupDashboardRoute},
	{"/monitoring/logs", "monitoring/logs.tmpl", "Logs - ClusterCockpit", false, func(i InfoType, r *http.Request) InfoType { return i }},
}

func setupHomeRoute(i InfoType, r *http.Request) InfoType {
	jobRepo := repository.GetJobRepository()
	groupBy := model.AggregateCluster

	// startJobCount := time.Now()
	stats, err := jobRepo.JobCountGrouped(r.Context(), nil, &groupBy)
	if err != nil {
		cclog.Warnf("failed to count jobs: %s", err.Error())
	}
	// cclog.Infof("Timer HOME ROUTE startJobCount: %s", time.Since(startJobCount))

	// startRunningJobCount := time.Now()
	stats, err = jobRepo.AddJobCountGrouped(r.Context(), nil, &groupBy, stats, "running")
	if err != nil {
		cclog.Warnf("failed to count running jobs: %s", err.Error())
	}
	// cclog.Infof("Timer HOME ROUTE startRunningJobCount: %s", time.Since(startRunningJobCount))

	i["clusters"] = stats

	if util.CheckFileExists("./var/notice.txt") {
		msg, err := os.ReadFile("./var/notice.txt")
		if err != nil {
			cclog.Warnf("failed to read notice.txt file: %s", err.Error())
		} else {
			i["message"] = string(msg)
		}
	}

	return i
}

func setupConfigRoute(i InfoType, r *http.Request) InfoType {
	if util.CheckFileExists("./var/notice.txt") {
		msg, err := os.ReadFile("./var/notice.txt")
		if err == nil {
			i["ncontent"] = string(msg)
		}
	}

	return i
}

func setupJobRoute(i InfoType, r *http.Request) InfoType {
	i["id"] = chi.URLParam(r, "id")
	if config.Keys.EmissionConstant != 0 {
		i["emission"] = config.Keys.EmissionConstant
	}
	return i
}

func setupUserRoute(i InfoType, r *http.Request) InfoType {
	username := chi.URLParam(r, "id")
	i["id"] = username
	i["username"] = username
	// TODO: If forbidden (== err exists), redirect to error page
	if user, _ := repository.GetUserRepository().FetchUserInCtx(r.Context(), username); user != nil {
		i["name"] = user.Name
		i["email"] = user.Email
	}
	return i
}

func setupClusterStatusRoute(i InfoType, r *http.Request) InfoType {
	cluster := chi.URLParam(r, "cluster")
	i["id"] = cluster
	i["cluster"] = cluster
	i["displayType"] = "DASHBOARD"
	return i
}

func setupClusterDetailRoute(i InfoType, r *http.Request) InfoType {
	cluster := chi.URLParam(r, "cluster")
	i["id"] = cluster
	i["cluster"] = cluster
	i["displayType"] = "DETAILS"
	return i
}

func setupDashboardRoute(i InfoType, r *http.Request) InfoType {
	cluster := chi.URLParam(r, "cluster")
	i["id"] = cluster
	i["cluster"] = cluster
	i["displayType"] = "PUBLIC" // Used in Main Template
	return i
}

func setupClusterOverviewRoute(i InfoType, r *http.Request) InfoType {
	cluster := chi.URLParam(r, "cluster")
	i["id"] = cluster
	i["cluster"] = cluster
	i["displayType"] = "OVERVIEW"

	from, to := r.URL.Query().Get("from"), r.URL.Query().Get("to")
	if from != "" || to != "" {
		i["from"] = from
		i["to"] = to
	}
	return i
}

func setupClusterListRoute(i InfoType, r *http.Request) InfoType {
	cluster := chi.URLParam(r, "cluster")
	subcluster := chi.URLParam(r, "subcluster")
	i["id"] = cluster
	i["cluster"] = cluster
	i["sid"] = subcluster
	i["subCluster"] = subcluster
	i["displayType"] = "LIST"

	from, to := r.URL.Query().Get("from"), r.URL.Query().Get("to")
	if from != "" || to != "" {
		i["from"] = from
		i["to"] = to
	}
	return i
}

func setupNodeRoute(i InfoType, r *http.Request) InfoType {
	cluster := chi.URLParam(r, "cluster")
	hostname := chi.URLParam(r, "hostname")
	i["cluster"] = cluster
	i["hostname"] = hostname
	i["id"] = fmt.Sprintf("%s (%s)", cluster, hostname)
	from, to := r.URL.Query().Get("from"), r.URL.Query().Get("to")
	if from != "" && to != "" {
		i["from"] = from
		i["to"] = to
	}
	return i
}

func setupAnalysisRoute(i InfoType, r *http.Request) InfoType {
	i["cluster"] = chi.URLParam(r, "cluster")
	return i
}

func setupTaglistRoute(i InfoType, r *http.Request) InfoType {
	jobRepo := repository.GetJobRepository()
	tags, counts, err := jobRepo.CountTags(repository.GetUserFromContext(r.Context()))
	tagMap := make(map[string][]map[string]any)
	if err != nil {
		cclog.Warnf("GetTags failed: %s", err.Error())
		i["tagmap"] = tagMap
		return i
	}
	// Reduces displayed tags for unauth'd users
	userAuthlevel := repository.GetUserFromContext(r.Context()).GetAuthLevel()
	// Uses tag.ID as second Map-Key component to differentiate tags with identical names
	if userAuthlevel >= 4 { // Support+ : Show tags for all scopes, regardless of count
		for _, tag := range tags {
			tagItem := map[string]any{
				"id":    tag.ID,
				"name":  tag.Name,
				"scope": tag.Scope,
				"count": counts[fmt.Sprint(tag.Type, tag.Name, tag.ID)],
			}
			tagMap[tag.Type] = append(tagMap[tag.Type], tagItem)
		}
	} else if userAuthlevel < 4 && userAuthlevel >= 2 { // User+ : Show global and admin scope only if at least 1 tag used, private scope regardless of count
		for _, tag := range tags {
			tagCount := counts[fmt.Sprint(tag.Type, tag.Name, tag.ID)]
			if ((tag.Scope == "global" || tag.Scope == "admin") && tagCount >= 1) || (tag.Scope != "global" && tag.Scope != "admin") {
				tagItem := map[string]any{
					"id":    tag.ID,
					"name":  tag.Name,
					"scope": tag.Scope,
					"count": tagCount,
				}
				tagMap[tag.Type] = append(tagMap[tag.Type], tagItem)
			}
		}
	} // auth < 2 return nothing for this route

	i["tagmap"] = tagMap
	return i
}

// FIXME: Lots of redundant code. Needs refactoring
func buildFilterPresets(query url.Values) map[string]any {
	filterPresets := map[string]any{}

	if query.Get("cluster") != "" {
		filterPresets["cluster"] = query.Get("cluster")
	}
	if query.Get("partition") != "" {
		filterPresets["partition"] = query.Get("partition")
	}
	if query.Get("project") != "" {
		filterPresets["project"] = query.Get("project")
		filterPresets["projectMatch"] = "eq"
	}
	if query.Get("jobName") != "" {
		filterPresets["jobName"] = query.Get("jobName")
	}
	if len(query["user"]) != 0 {
		if len(query["user"]) == 1 {
			filterPresets["user"] = query.Get("user")
			filterPresets["userMatch"] = "contains"
		} else {
			filterPresets["user"] = query["user"]
			filterPresets["userMatch"] = "in"
		}
	}
	if len(query["state"]) != 0 {
		filterPresets["state"] = query["state"]
	}
	if query.Get("shared") != "" {
		filterPresets["shared"] = query.Get("shared")
	}
	if query.Get("schedule") != "" {
		filterPresets["schedule"] = query.Get("schedule")
	}
	if rawtags, ok := query["tag"]; ok {
		tags := make([]int, len(rawtags))
		for i, tid := range rawtags {
			var err error
			tags[i], err = strconv.Atoi(tid)
			if err != nil {
				tags[i] = -1
			}
		}
		filterPresets["tags"] = tags
	}
	if query.Get("duration") != "" {
		parts := strings.Split(query.Get("duration"), "-")
		if len(parts) == 2 {
			if parts[0] == "lessthan" {
				lt, lte := strconv.Atoi(parts[1])
				if lte == nil {
					filterPresets["duration"] = map[string]int{"lessThan": lt, "from": 0, "to": 0}
				}
			} else if parts[0] == "morethan" {
				mt, mte := strconv.Atoi(parts[1])
				if mte == nil {
					filterPresets["duration"] = map[string]int{"moreThan": mt, "from": 0, "to": 0}
				}
			} else {
				a, e1 := strconv.Atoi(parts[0])
				b, e2 := strconv.Atoi(parts[1])
				if e1 == nil && e2 == nil {
					filterPresets["duration"] = map[string]int{"from": a, "to": b}
				}
			}
		}
	}
	if query.Get("node") != "" {
		filterPresets["node"] = query.Get("node")
	}
	if query.Get("numNodes") != "" {
		parts := strings.Split(query.Get("numNodes"), "-")
		if len(parts) == 2 {
			if parts[0] == "lessthan" {
				lt, lte := strconv.Atoi(parts[1])
				if lte == nil {
					filterPresets["numNodes"] = map[string]int{"from": 1, "to": lt}
				}
			} else if parts[0] == "morethan" {
				mt, mte := strconv.Atoi(parts[1])
				if mte == nil {
					filterPresets["numNodes"] = map[string]int{"from": mt, "to": 0}
				}
			} else {
				a, e1 := strconv.Atoi(parts[0])
				b, e2 := strconv.Atoi(parts[1])
				if e1 == nil && e2 == nil {
					filterPresets["numNodes"] = map[string]int{"from": a, "to": b}
				}
			}
		}
	}
	if query.Get("numHWThreads") != "" {
		parts := strings.Split(query.Get("numHWThreads"), "-")
		if len(parts) == 2 {
			if parts[0] == "lessthan" {
				lt, lte := strconv.Atoi(parts[1])
				if lte == nil {
					filterPresets["numHWThreads"] = map[string]int{"from": 1, "to": lt}
				}
			} else if parts[0] == "morethan" {
				mt, mte := strconv.Atoi(parts[1])
				if mte == nil {
					filterPresets["numHWThreads"] = map[string]int{"from": mt, "to": 0}
				}
			} else {
				a, e1 := strconv.Atoi(parts[0])
				b, e2 := strconv.Atoi(parts[1])
				if e1 == nil && e2 == nil {
					filterPresets["numHWThreads"] = map[string]int{"from": a, "to": b}
				}
			}
		}
	}
	if query.Get("numAccelerators") != "" {
		parts := strings.Split(query.Get("numAccelerators"), "-")
		if len(parts) == 2 {
			if parts[0] == "lessthan" {
				lt, lte := strconv.Atoi(parts[1])
				if lte == nil {
					filterPresets["numAccelerators"] = map[string]int{"from": 1, "to": lt}
				}
			} else if parts[0] == "morethan" {
				mt, mte := strconv.Atoi(parts[1])
				if mte == nil {
					filterPresets["numAccelerators"] = map[string]int{"from": mt, "to": 0}
				}
			} else {
				a, e1 := strconv.Atoi(parts[0])
				b, e2 := strconv.Atoi(parts[1])
				if e1 == nil && e2 == nil {
					filterPresets["numAccelerators"] = map[string]int{"from": a, "to": b}
				}
			}
		}
	}
	if len(query["dbId"]) != 0 {
		filterPresets["dbId"] = query["dbId"]
	}
	if query.Get("jobId") != "" {
		if len(query["jobId"]) == 1 {
			filterPresets["jobId"] = query.Get("jobId")
			filterPresets["jobIdMatch"] = "eq"
		} else {
			filterPresets["jobId"] = query["jobId"]
			filterPresets["jobIdMatch"] = "in"
		}
	}
	if query.Get("arrayJobId") != "" {
		if num, err := strconv.Atoi(query.Get("arrayJobId")); err == nil {
			filterPresets["arrayJobId"] = num
		}
	}
	if query.Get("startTime") != "" {
		parts := strings.Split(query.Get("startTime"), "-")
		if len(parts) == 2 { // Time in seconds, from - to
			a, e1 := strconv.ParseInt(parts[0], 10, 64)
			b, e2 := strconv.ParseInt(parts[1], 10, 64)
			if e1 == nil && e2 == nil {
				filterPresets["startTime"] = map[string]string{
					"from": time.Unix(a, 0).Format(time.RFC3339),
					"to":   time.Unix(b, 0).Format(time.RFC3339),
				}
			}
		} else { // named range
			filterPresets["startTime"] = map[string]string{
				"range": query.Get("startTime"),
			}
		}
	}
	if query.Get("energy") != "" {
		parts := strings.Split(query.Get("energy"), "-")
		if len(parts) == 2 {
			if parts[0] == "lessthan" {
				lt, lte := strconv.Atoi(parts[1])
				if lte == nil {
					filterPresets["energy"] = map[string]int{"from": 1, "to": lt}
				}
			} else if parts[0] == "morethan" {
				mt, mte := strconv.Atoi(parts[1])
				if mte == nil {
					filterPresets["energy"] = map[string]int{"from": mt, "to": 0}
				}
			} else {
				a, e1 := strconv.Atoi(parts[0])
				b, e2 := strconv.Atoi(parts[1])
				if e1 == nil && e2 == nil {
					filterPresets["energy"] = map[string]int{"from": a, "to": b}
				}
			}
		}
	}
	if len(query["stat"]) != 0 {
		statList := make([]map[string]any, 0)
		for _, statEntry := range query["stat"] {
			parts := strings.Split(statEntry, "-")
			if len(parts) == 3 { // Metric Footprint Stat Field, from - to
				if parts[1] == "lessthan" {
					lt, lte := strconv.ParseInt(parts[2], 10, 64)
					if lte == nil {
						statEntry := map[string]any{
							"field": parts[0],
							"from":  1,
							"to":    lt,
						}
						statList = append(statList, statEntry)
					}
				} else if parts[1] == "morethan" {
					mt, mte := strconv.ParseInt(parts[2], 10, 64)
					if mte == nil {
						statEntry := map[string]any{
							"field": parts[0],
							"from":  mt,
							"to":    0,
						}
						statList = append(statList, statEntry)
					}
				} else {
					a, e1 := strconv.ParseInt(parts[1], 10, 64)
					b, e2 := strconv.ParseInt(parts[2], 10, 64)
					if e1 == nil && e2 == nil {
						statEntry := map[string]any{
							"field": parts[0],
							"from":  a,
							"to":    b,
						}
						statList = append(statList, statEntry)
					}
				}
			}
		}
		filterPresets["stats"] = statList
	}
	return filterPresets
}

func SetupRoutes(router chi.Router, buildInfo web.Build) {
	userCfgRepo := repository.GetUserCfgRepo()
	for _, route := range routes {
		router.HandleFunc(route.Route, func(rw http.ResponseWriter, r *http.Request) {
			conf, err := userCfgRepo.GetUIConfig(repository.GetUserFromContext(r.Context()))
			if err != nil {
				http.Error(rw, err.Error(), http.StatusInternalServerError)
				return
			}

			title := route.Title
			infos := route.Setup(map[string]any{}, r)
			if id, ok := infos["id"]; ok {
				title = strings.Replace(route.Title, "<ID>", id.(string), 1)
				if sid, ok := infos["sid"]; ok { // 2nd ID element
					title = strings.Replace(title, "<SID>", sid.(string), 1)
				}
			}

			// Get User -> What if NIL?
			user := repository.GetUserFromContext(r.Context())

			// Get Roles
			availableRoles, _ := schema.GetValidRolesMap(user)

			resampling := resamplingForUser(conf)

			page := web.Page{
				Title:      title,
				User:       *user,
				Roles:      availableRoles,
				Build:      buildInfo,
				Config:     conf,
				Resampling: resampling,
				Infos:      infos,
			}

			if route.Filter {
				page.FilterPresets = buildFilterPresets(r.URL.Query())
			}

			web.RenderTemplate(rw, route.Template, &page)
		})
	}
}

func HandleSearchBar(rw http.ResponseWriter, r *http.Request, buildInfo web.Build) {
	user := repository.GetUserFromContext(r.Context())
	availableRoles, _ := schema.GetValidRolesMap(user)

	if search := r.URL.Query().Get("searchId"); search != "" {
		repo := repository.GetJobRepository()
		splitSearch := strings.Split(search, ":")

		if len(splitSearch) == 2 {
			switch strings.Trim(splitSearch[0], " ") {
			case "jobId":
				http.Redirect(rw, r, "/monitoring/jobs/?jobId="+url.QueryEscape(strings.Trim(splitSearch[1], " ")), http.StatusFound) // All Users: Redirect to Tablequery
			case "jobName":
				// Add Last 30 Days to migitate timeouts
				untilTime := strconv.FormatInt(time.Now().Unix(), 10)
				fromTime := strconv.FormatInt((time.Now().Unix() - int64(30*24*3600)), 10)

				http.Redirect(rw, r, "/monitoring/jobs/?startTime="+fromTime+"-"+untilTime+"&jobName="+url.QueryEscape(strings.Trim(splitSearch[1], " ")), http.StatusFound) // All Users: Redirect to Tablequery
			case "projectId":
				http.Redirect(rw, r, "/monitoring/jobs/?projectMatch=eq&project="+url.QueryEscape(strings.Trim(splitSearch[1], " ")), http.StatusFound) // All Users: Redirect to Tablequery
			case "arrayJobId":
				// Add Last 30 Days to migitate timeouts
				untilTime := strconv.FormatInt(time.Now().Unix(), 10)
				fromTime := strconv.FormatInt((time.Now().Unix() - int64(30*24*3600)), 10)

				http.Redirect(rw, r, "/monitoring/jobs/?startTime="+fromTime+"-"+untilTime+"&arrayJobId="+url.QueryEscape(strings.Trim(splitSearch[1], " ")), http.StatusFound) // All Users: Redirect to Tablequery
			case "username":
				if user.HasAnyRole([]schema.Role{schema.RoleAdmin, schema.RoleSupport, schema.RoleManager}) {
					http.Redirect(rw, r, "/monitoring/users/?user="+url.QueryEscape(strings.Trim(splitSearch[1], " "))+"&startTime=last30d", http.StatusFound)
				} else {
					web.RenderTemplate(rw, "message.tmpl", &web.Page{Title: "Error", MsgType: "alert-danger", Message: "Missing Access Rights", User: *user, Roles: availableRoles, Build: buildInfo})
				}
			case "name":
				usernames, _ := repo.FindColumnValues(user, strings.Trim(splitSearch[1], " "), "user", "username", "name")
				if len(usernames) != 0 {
					joinedNames := strings.Join(usernames, "&user=")
					http.Redirect(rw, r, "/monitoring/users/?user="+joinedNames+"&startTime=last30d", http.StatusFound)
				} else {
					if user.HasAnyRole([]schema.Role{schema.RoleAdmin, schema.RoleSupport, schema.RoleManager}) {
						http.Redirect(rw, r, "/monitoring/users/?user=NoUserNameFound&startTime=last30d", http.StatusPermanentRedirect)
					} else {
						web.RenderTemplate(rw, "message.tmpl", &web.Page{Title: "Error", MsgType: "alert-danger", Message: "Missing Access Rights", User: *user, Roles: availableRoles, Build: buildInfo})
					}
				}
			default:
				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})
			}
		} else if len(splitSearch) == 1 {

			jobid, username, project, jobname := repo.FindUserOrProjectOrJobname(user, strings.Trim(search, " "))

			if jobid != "" {
				http.Redirect(rw, r, "/monitoring/jobs/?jobId="+url.QueryEscape(jobid), http.StatusFound) // JobId (Match)
			} else if username != "" {
				http.Redirect(rw, r, "/monitoring/user/"+username, http.StatusFound) // User: Redirect to user page of first match
			} else if project != "" {
				http.Redirect(rw, r, "/monitoring/jobs/?projectMatch=eq&project="+url.QueryEscape(project), http.StatusFound) // projectId (equal)
			} else if jobname != "" {
				// Add Last 30 Days to migitate timeouts
				untilTime := strconv.FormatInt(time.Now().Unix(), 10)
				fromTime := strconv.FormatInt((time.Now().Unix() - int64(30*24*3600)), 10)

				http.Redirect(rw, r, "/monitoring/jobs/?startTime="+fromTime+"-"+untilTime+"&jobName="+url.QueryEscape(jobname), http.StatusFound) // 30D Fitler + JobName (contains)
			} else {
				web.RenderTemplate(rw, "message.tmpl", &web.Page{Title: "Info", MsgType: "alert-info", Message: "Search without result", User: *user, Roles: availableRoles, Build: buildInfo})
			}

		} else {
			web.RenderTemplate(rw, "message.tmpl", &web.Page{Title: "Error", MsgType: "alert-danger", Message: "Searchbar query parameters malformed", User: *user, Roles: availableRoles, Build: buildInfo})
		}
	} else {
		web.RenderTemplate(rw, "message.tmpl", &web.Page{Title: "Warning", MsgType: "alert-warning", Message: "Empty search", User: *user, Roles: availableRoles, Build: buildInfo})
	}
}

// resamplingForUser returns a ResampleConfig that incorporates the user's
// resample policy preference. If the user has a policy set, it creates a
// policy-derived config with targetPoints and trigger. Otherwise falls back
// to the global config.
func resamplingForUser(conf map[string]any) *config.ResampleConfig {
	globalCfg := config.Keys.EnableResampling

	policyVal, ok := conf["plotConfiguration_resamplePolicy"]
	if !ok {
		return globalCfg
	}
	policyStr, ok := policyVal.(string)
	if !ok || policyStr == "" {
		return globalCfg
	}

	policy := metricdispatch.ResamplePolicy(policyStr)
	targetPoints := metricdispatch.TargetPointsForPolicy(policy)
	if targetPoints == 0 {
		return globalCfg
	}

	// Build a policy-derived config: targetPoints + trigger, no resolutions array
	return &config.ResampleConfig{
		TargetPoints: targetPoints,
		Trigger:      targetPoints / 4,
	}
}

-  policyVal, ok := conf["plotConfiguration_resamplePolicy"]

+  if policyVal, ok := conf["plotConfiguration_resamplePolicy"]; ok {

+    if s, ok := policyVal.(string); ok {

+  // Fall back to global default policy, then to "medium"

+  if policyStr == "" && globalCfg != nil {

+    policyStr = globalCfg.DefaultPolicy

-  // Build a policy-derived config: targetPoints + trigger, no resolutions array


	if resolution == nil {
		if config.Keys.EnableResampling != nil {
			defaultRes := slices.Max(config.Keys.EnableResampling.Resolutions)
			resolution = &defaultRes
		} else {
			defaultRes := 0
			resolution = &defaultRes
		}
	}

	algoName := resolveResampleAlgo(ctx, resampleAlgo)

	data, err := metricdispatch.LoadData(job, metrics, scopes, ctx, *resolution, algoName)

	if resolution == nil {
		if config.Keys.EnableResampling != nil {
			resolution = resolveResolutionFromDefaultPolicy(int64(job.Duration), job.Cluster, metrics)
		}
		if resolution == nil {
			defaultRes := 0
			resolution = &defaultRes
		}
	}

	algoName := resolveResampleAlgo(ctx, resampleAlgo)

	data, err := metricdispatch.LoadData(job, metrics, scopes, ctx, *resolution, algoName)


The file /Users/jan/prg/ClusterCockpit/cc-backend/internal/graph/schema.resolvers.go has been updated successfully.

	if resolution == nil {
		if config.Keys.EnableResampling != nil {
			resolution = resolveResolutionFromDefaultPolicy(int64(job.Duration), job.Cluster, metrics)
		}
		if resolution == nil {
			defaultRes := 0
			resolution = &defaultRes
		}
	}

	algoName := resolveResampleAlgo(ctx, resampleAlgo)

	data, err := metricdispatch.LoadData(job, metrics, scopes, ctx, *resolution, algoName)

package graph

// This file will be automatically regenerated based on the schema, any resolver
// implementations
// will be copied through when generating and any unknown code will be moved to the end.
// Code generated by github.com/99designs/gqlgen version v0.17.88

import (
	"context"
	"errors"
	"fmt"
	"math"
	"regexp"
	"slices"
	"strconv"
	"strings"
	"time"

	"github.com/ClusterCockpit/cc-backend/internal/config"
	"github.com/ClusterCockpit/cc-backend/internal/graph/generated"
	"github.com/ClusterCockpit/cc-backend/internal/graph/model"
	"github.com/ClusterCockpit/cc-backend/internal/metricdispatch"
	"github.com/ClusterCockpit/cc-backend/internal/repository"
	"github.com/ClusterCockpit/cc-backend/pkg/archive"
	cclog "github.com/ClusterCockpit/cc-lib/v2/ccLogger"
	ccunit "github.com/ClusterCockpit/cc-lib/v2/ccUnits"
	"github.com/ClusterCockpit/cc-lib/v2/schema"
)

// Partitions is the resolver for the partitions field.
func (r *clusterResolver) Partitions(ctx context.Context, obj *schema.Cluster) ([]string, error) {
	return r.Repo.Partitions(obj.Name)
}

// StartTime is the resolver for the startTime field.
func (r *jobResolver) StartTime(ctx context.Context, obj *schema.Job) (*time.Time, error) {
	timestamp := time.Unix(obj.StartTime, 0)
	return &timestamp, nil
}

// Tags is the resolver for the tags field.
func (r *jobResolver) Tags(ctx context.Context, obj *schema.Job) ([]*schema.Tag, error) {
	return r.Repo.GetTags(repository.GetUserFromContext(ctx), obj.ID)
}

// ConcurrentJobs is the resolver for the concurrentJobs field.
func (r *jobResolver) ConcurrentJobs(ctx context.Context, obj *schema.Job) (*model.JobLinkResultList, error) {
	// FIXME: Make the hardcoded duration configurable
	if obj.Shared != "none" && obj.Duration > 600 {
		return r.Repo.FindConcurrentJobs(ctx, obj)
	}

	return nil, nil
}

// Footprint is the resolver for the footprint field.
func (r *jobResolver) Footprint(ctx context.Context, obj *schema.Job) ([]*model.FootprintValue, error) {
	rawFootprint, err := r.Repo.FetchFootprint(obj)
	if err != nil {
		cclog.Warn("Error while fetching job footprint data")
		return nil, err
	}

	res := []*model.FootprintValue{}
	for name, value := range rawFootprint {

		parts := strings.Split(name, "_")
		statPart := parts[len(parts)-1]
		nameParts := parts[:len(parts)-1]

		res = append(res, &model.FootprintValue{
			Name:  strings.Join(nameParts, "_"),
			Stat:  statPart,
			Value: value,
		})
	}

	return res, err
}

// EnergyFootprint is the resolver for the energyFootprint field.
func (r *jobResolver) EnergyFootprint(ctx context.Context, obj *schema.Job) ([]*model.EnergyFootprintValue, error) {
	rawEnergyFootprint, err := r.Repo.FetchEnergyFootprint(obj)
	if err != nil {
		cclog.Warn("Error while fetching job energy footprint data")
		return nil, err
	}

	res := []*model.EnergyFootprintValue{}
	for name, value := range rawEnergyFootprint {
		// Suboptimal: Nearly hardcoded metric name expectations
		matchCPU := regexp.MustCompile(`cpu|Cpu|CPU`)
		matchAcc := regexp.MustCompile(`acc|Acc|ACC`)
		matchMem := regexp.MustCompile(`mem|Mem|MEM`)
		matchCore := regexp.MustCompile(`core|Core|CORE`)

		hwType := ""
		switch test := name; { // NOtice ';' for var declaration
		case matchCPU.MatchString(test):
			hwType = "CPU"
		case matchAcc.MatchString(test):
			hwType = "Accelerator"
		case matchMem.MatchString(test):
			hwType = "Memory"
		case matchCore.MatchString(test):
			hwType = "Core"
		default:
			hwType = "Other"
		}

		res = append(res, &model.EnergyFootprintValue{
			Hardware: hwType,
			Metric:   name,
			Value:    value,
		})
	}
	return res, err
}

// MetaData is the resolver for the metaData field.
func (r *jobResolver) MetaData(ctx context.Context, obj *schema.Job) (any, error) {
	return r.Repo.FetchMetadata(obj)
}

// UserData is the resolver for the userData field.
func (r *jobResolver) UserData(ctx context.Context, obj *schema.Job) (*model.User, error) {
	return repository.GetUserRepository().FetchUserInCtx(ctx, obj.User)
}

// Name is the resolver for the name field.
func (r *metricValueResolver) Name(ctx context.Context, obj *schema.MetricValue) (*string, error) {
	panic(fmt.Errorf("not implemented: Name - name"))
}

// CreateTag is the resolver for the createTag field.
func (r *mutationResolver) CreateTag(ctx context.Context, typeArg string, name string, scope string) (*schema.Tag, error) {
	user := repository.GetUserFromContext(ctx)
	if user == nil {
		return nil, fmt.Errorf("no user in context")
	}

	// Test Access: Admins && Admin Tag OR Support/Admin and Global Tag OR Everyone && Private Tag
	if user.HasRole(schema.RoleAdmin) && scope == "admin" ||
		user.HasAnyRole([]schema.Role{schema.RoleAdmin, schema.RoleSupport}) && scope == "global" ||
		user.Username == scope {
		// Create in DB
		id, err := r.Repo.CreateTag(typeArg, name, scope)
		if err != nil {
			cclog.Warn("Error while creating tag")
			return nil, err
		}
		return &schema.Tag{ID: id, Type: typeArg, Name: name, Scope: scope}, nil
	} else {
		cclog.Warnf("Not authorized to create tag with scope: %s", scope)
		return nil, fmt.Errorf("not authorized to create tag with scope: %s", scope)
	}
}

// DeleteTag is the resolver for the deleteTag field.
func (r *mutationResolver) DeleteTag(ctx context.Context, id string) (string, error) {
	// This Uses ID string <-> ID string, removeTagFromList uses []string <-> []int
	panic(fmt.Errorf("not implemented: DeleteTag - deleteTag"))
}

// AddTagsToJob is the resolver for the addTagsToJob field.
func (r *mutationResolver) AddTagsToJob(ctx context.Context, job string, tagIds []string) ([]*schema.Tag, error) {
	user := repository.GetUserFromContext(ctx)
	if user == nil {
		return nil, fmt.Errorf("no user in context")
	}

	jid, err := strconv.ParseInt(job, 10, 64)
	if err != nil {
		cclog.Warn("Error while adding tag to job")
		return nil, err
	}

	tags := []*schema.Tag{}
	for _, tagID := range tagIds {
		// Get ID
		tid, err := strconv.ParseInt(tagID, 10, 64)
		if err != nil {
			cclog.Warn("Error while parsing tag id")
			return nil, err
		}

		// Test Exists
		_, _, tscope, exists := r.Repo.TagInfo(tid)
		if !exists {
			cclog.Warnf("Tag does not exist (ID): %d", tid)
			return nil, fmt.Errorf("tag does not exist (ID): %d", tid)
		}

		// Test Access: Admins && Admin Tag OR Support/Admin and Global Tag OR Everyone && Private Tag
		if user.HasRole(schema.RoleAdmin) && tscope == "admin" ||
			user.HasAnyRole([]schema.Role{schema.RoleAdmin, schema.RoleSupport}) && tscope == "global" ||
			user.Username == tscope {
			// Add to Job
			if tags, err = r.Repo.AddTag(user, jid, tid); err != nil {
				cclog.Warn("Error while adding tag")
				return nil, err
			}
		} else {
			cclog.Warnf("Not authorized to add tag: %d", tid)
			return nil, fmt.Errorf("not authorized to add tag: %d", tid)
		}
	}

	return tags, nil
}

// RemoveTagsFromJob is the resolver for the removeTagsFromJob field.
func (r *mutationResolver) RemoveTagsFromJob(ctx context.Context, job string, tagIds []string) ([]*schema.Tag, error) {
	user := repository.GetUserFromContext(ctx)
	if user == nil {
		return nil, fmt.Errorf("no user in context")
	}

	jid, err := strconv.ParseInt(job, 10, 64)
	if err != nil {
		cclog.Warn("Error while parsing job id")
		return nil, err
	}

	tags := []*schema.Tag{}
	for _, tagID := range tagIds {
		// Get ID
		tid, err := strconv.ParseInt(tagID, 10, 64)
		if err != nil {
			cclog.Warn("Error while parsing tag id")
			return nil, err
		}

		// Test Exists
		_, _, tscope, exists := r.Repo.TagInfo(tid)
		if !exists {
			cclog.Warnf("Tag does not exist (ID): %d", tid)
			return nil, fmt.Errorf("tag does not exist (ID): %d", tid)
		}

		// Test Access: Admins && Admin Tag OR Support/Admin and Global Tag OR Everyone && Private Tag
		if user.HasRole(schema.RoleAdmin) && tscope == "admin" ||
			user.HasAnyRole([]schema.Role{schema.RoleAdmin, schema.RoleSupport}) && tscope == "global" ||
			user.Username == tscope {
			// Remove from Job
			if tags, err = r.Repo.RemoveTag(user, jid, tid); err != nil {
				cclog.Warn("Error while removing tag")
				return nil, err
			}
		} else {
			cclog.Warnf("Not authorized to remove tag: %d", tid)
			return nil, fmt.Errorf("not authorized to remove tag: %d", tid)
		}

	}

	return tags, nil
}

// RemoveTagFromList is the resolver for the removeTagFromList field.
func (r *mutationResolver) RemoveTagFromList(ctx context.Context, tagIds []string) ([]int, error) {
	// Needs Contextuser
	user := repository.GetUserFromContext(ctx)
	if user == nil {
		return nil, fmt.Errorf("no user in context")
	}

	tags := []int{}
	for _, tagID := range tagIds {
		// Get ID
		tid, err := strconv.ParseInt(tagID, 10, 64)
		if err != nil {
			cclog.Warn("Error while parsing tag id for removal")
			return nil, err
		}

		// Test Exists
		_, _, tscope, exists := r.Repo.TagInfo(tid)
		if !exists {
			cclog.Warnf("Tag does not exist (ID): %d", tid)
			return nil, fmt.Errorf("tag does not exist (ID): %d", tid)
		}

		// Test Access: Admins && Admin Tag OR Everyone && Private Tag
		if user.HasRole(schema.RoleAdmin) && (tscope == "global" || tscope == "admin") || user.Username == tscope {
			// Remove from DB
			if err = r.Repo.RemoveTagByID(tid); err != nil {
				cclog.Warn("Error while removing tag")
				return nil, err
			} else {
				tags = append(tags, int(tid))
			}
		} else {
			cclog.Warnf("Not authorized to remove tag: %d", tid)
			return nil, fmt.Errorf("not authorized to remove tag: %d", tid)
		}
	}
	return tags, nil
}

// UpdateConfiguration is the resolver for the updateConfiguration field.
func (r *mutationResolver) UpdateConfiguration(ctx context.Context, name string, value string) (*string, error) {
	if err := repository.GetUserCfgRepo().UpdateConfig(name, value, repository.GetUserFromContext(ctx)); err != nil {
		cclog.Warn("Error while updating user config")
		return nil, err
	}

	return nil, nil
}

// ID is the resolver for the id field.
func (r *nodeResolver) ID(ctx context.Context, obj *schema.Node) (string, error) {
	panic(fmt.Errorf("not implemented: ID - id"))
}

// SchedulerState is the resolver for the schedulerState field.
func (r *nodeResolver) SchedulerState(ctx context.Context, obj *schema.Node) (schema.SchedulerState, error) {
	if obj.NodeState != "" {
		return obj.NodeState, nil
	} else {
		return "", fmt.Errorf("resolver: no SchedulerState (NodeState) on node object")
	}
}

// HealthState is the resolver for the healthState field.
func (r *nodeResolver) HealthState(ctx context.Context, obj *schema.Node) (string, error) {
	if obj.HealthState != "" {
		return string(obj.HealthState), nil
	} else {
		return "", fmt.Errorf("resolver: no HealthState (NodeState) on node object")
	}
}

// MetaData is the resolver for the metaData field.
func (r *nodeResolver) MetaData(ctx context.Context, obj *schema.Node) (any, error) {
	if obj.MetaData != nil {
		return obj.MetaData, nil
	} else {
		cclog.Debug("resolver: no MetaData (NodeState) on node object")
		emptyMeta := make(map[string]string, 0)
		return emptyMeta, nil
	}
}

// HealthData is the resolver for the healthData field.
func (r *nodeResolver) HealthData(ctx context.Context, obj *schema.Node) (any, error) {
	if obj.HealthData != nil {
		return obj.HealthData, nil
	} else {
		cclog.Debug("resolver: no HealthData (NodeState) on node object")
		emptyHealth := make(map[string][]string, 0)
		return emptyHealth, nil
	}
}

// Clusters is the resolver for the clusters field.
func (r *queryResolver) Clusters(ctx context.Context) ([]*schema.Cluster, error) {
	return archive.Clusters, nil
}

// Tags is the resolver for the tags field.
func (r *queryResolver) Tags(ctx context.Context) ([]*schema.Tag, error) {
	return r.Repo.GetTags(repository.GetUserFromContext(ctx), nil)
}

// GlobalMetrics is the resolver for the globalMetrics field.
func (r *queryResolver) GlobalMetrics(ctx context.Context) ([]*schema.GlobalMetricListItem, error) {
	user := repository.GetUserFromContext(ctx)

	if user != nil {
		if user.HasRole(schema.RoleUser) || user.HasRole(schema.RoleManager) {
			return archive.GlobalUserMetricList, nil
		}
	}

	return archive.GlobalMetricList, nil
}

// User is the resolver for the user field.
func (r *queryResolver) User(ctx context.Context, username string) (*model.User, error) {
	return repository.GetUserRepository().FetchUserInCtx(ctx, username)
}

// AllocatedNodes is the resolver for the allocatedNodes field.
func (r *queryResolver) AllocatedNodes(ctx context.Context, cluster string) ([]*model.Count, error) {
	data, err := r.Repo.AllocatedNodes(cluster)
	if err != nil {
		cclog.Warn("Error while fetching allocated nodes")
		return nil, err
	}

	counts := make([]*model.Count, 0, len(data))
	for subcluster, hosts := range data {
		counts = append(counts, &model.Count{
			Name:  subcluster,
			Count: len(hosts),
		})
	}

	return counts, nil
}

// Node is the resolver for the node field.
func (r *queryResolver) Node(ctx context.Context, id string) (*schema.Node, error) {
	repo := repository.GetNodeRepository()
	numericID, err := strconv.ParseInt(id, 10, 64)
	if err != nil {
		cclog.Warn("Error while parsing job id")
		return nil, err
	}
	return repo.GetNodeByID(numericID, false)
}

// Nodes is the resolver for the nodes field.
func (r *queryResolver) Nodes(ctx context.Context, filter []*model.NodeFilter, order *model.OrderByInput) (*model.NodeStateResultList, error) {
	repo := repository.GetNodeRepository()
	nodes, err := repo.QueryNodes(ctx, filter, nil, order) // Ignore Paging, Order Unused
	count := len(nodes)
	return &model.NodeStateResultList{Items: nodes, Count: &count}, err
}

// NodesWithMeta is the resolver for the nodesWithMeta field.
func (r *queryResolver) NodesWithMeta(ctx context.Context, filter []*model.NodeFilter, order *model.OrderByInput) (*model.NodeStateResultList, error) {
	// Why Extra Handler? -> graphql.CollectAllFields(ctx) only returns toplevel fields (i.e.: items, count), and not subfields like item.metaData
	repo := repository.GetNodeRepository()
	nodes, err := repo.QueryNodesWithMeta(ctx, filter, nil, order) // Ignore Paging, Order Unused
	count := len(nodes)
	return &model.NodeStateResultList{Items: nodes, Count: &count}, err
}

// NodeStates is the resolver for the nodeStates field.
func (r *queryResolver) NodeStates(ctx context.Context, filter []*model.NodeFilter) ([]*model.NodeStates, error) {
	repo := repository.GetNodeRepository()

	stateCounts, serr := repo.CountStates(ctx, filter, "node_state")
	if serr != nil {
		cclog.Warnf("Error while counting nodeStates: %s", serr.Error())
		return nil, serr
	}

	healthCounts, herr := repo.CountStates(ctx, filter, "health_state")
	if herr != nil {
		cclog.Warnf("Error while counting healthStates: %s", herr.Error())
		return nil, herr
	}

	allCounts := append(stateCounts, healthCounts...)

	return allCounts, nil
}

// NodeStatesTimed is the resolver for the nodeStatesTimed field.
func (r *queryResolver) NodeStatesTimed(ctx context.Context, filter []*model.NodeFilter, typeArg string) ([]*model.NodeStatesTimed, error) {
	repo := repository.GetNodeRepository()

	if typeArg == "node" {
		stateCounts, serr := repo.CountStatesTimed(ctx, filter, "node_state")
		if serr != nil {
			cclog.Warnf("Error while counting nodeStates in time: %s", serr.Error())
			return nil, serr
		}
		return stateCounts, nil
	}

	if typeArg == "health" {
		healthCounts, herr := repo.CountStatesTimed(ctx, filter, "health_state")
		if herr != nil {
			cclog.Warnf("Error while counting healthStates in time: %s", herr.Error())
			return nil, herr
		}
		return healthCounts, nil
	}

	return nil, errors.New("unknown Node State Query Type")
}

// Job is the resolver for the job field.
func (r *queryResolver) Job(ctx context.Context, id string) (*schema.Job, error) {
	numericID, err := strconv.ParseInt(id, 10, 64)
	if err != nil {
		cclog.Warn("Error while parsing job id")
		return nil, err
	}

	job, err := r.Repo.FindByID(ctx, numericID)
	if err != nil {
		cclog.Warn("Error while finding job by id")
		return nil, err
	}

	if user := repository.GetUserFromContext(ctx); user != nil &&
		job.User != user.Username &&
		user.HasNotRoles([]schema.Role{schema.RoleAdmin, schema.RoleSupport, schema.RoleManager}) {
		return nil, errors.New("you are not allowed to see this job")
	}

	return job, nil
}

// JobMetrics is the resolver for the jobMetrics field.
func (r *queryResolver) JobMetrics(ctx context.Context, id string, metrics []string, scopes []schema.MetricScope, resolution *int, resampleAlgo *model.ResampleAlgo) ([]*model.JobMetricWithName, error) {
	job, err := r.Query().Job(ctx, id)
	if err != nil {
		cclog.Warn("Error while querying job for metrics")
		return nil, err
	}

	// Resolve resolution: explicit param > user policy > global config > 0
	if resolution == nil {
		resolution = resolveResolutionFromPolicy(ctx, int64(job.Duration), job.Cluster, metrics)
	}
	if resolution == nil {
		if config.Keys.EnableResampling != nil {
			defaultRes := slices.Max(config.Keys.EnableResampling.Resolutions)
			resolution = &defaultRes
		} else {
			defaultRes := 0
			resolution = &defaultRes
		}
	}

	algoName := resolveResampleAlgo(ctx, resampleAlgo)

	data, err := metricdispatch.LoadData(job, metrics, scopes, ctx, *resolution, algoName)
	if err != nil {
		cclog.Warn("Error while loading job data")
		return nil, err
	}

	res := []*model.JobMetricWithName{}
	for name, md := range data {
		for scope, metric := range md {
			res = append(res, &model.JobMetricWithName{
				Name:   name,
				Scope:  scope,
				Metric: metric,
			})
		}
	}

	return res, err
}

// JobStats is the resolver for the jobStats field.
func (r *queryResolver) JobStats(ctx context.Context, id string, metrics []string) ([]*model.NamedStats, error) {
	job, err := r.Query().Job(ctx, id)
	if err != nil {
		cclog.Warnf("Error while querying job %s for metadata", id)
		return nil, err
	}

	data, err := metricdispatch.LoadJobStats(job, metrics, ctx)
	if err != nil {
		cclog.Warnf("Error while loading jobStats data for job id %s", id)
		return nil, err
	}

	res := []*model.NamedStats{}
	for name, md := range data {
		res = append(res, &model.NamedStats{
			Name: name,
			Data: &md,
		})
	}

	return res, err
}

// ScopedJobStats is the resolver for the scopedJobStats field.
func (r *queryResolver) ScopedJobStats(ctx context.Context, id string, metrics []string, scopes []schema.MetricScope) ([]*model.NamedStatsWithScope, error) {
	job, err := r.Query().Job(ctx, id)
	if err != nil {
		cclog.Warnf("Error while querying job %s for metadata", id)
		return nil, err
	}

	data, err := metricdispatch.LoadScopedJobStats(job, metrics, scopes, ctx)
	if err != nil {
		cclog.Warnf("Error while loading scopedJobStats data for job id %s", id)
		return nil, err
	}

	res := make([]*model.NamedStatsWithScope, 0)
	for name, scoped := range data {
		for scope, stats := range scoped {

			mdlStats := make([]*model.ScopedStats, 0)
			for _, stat := range stats {
				mdlStats = append(mdlStats, &model.ScopedStats{
					Hostname: stat.Hostname,
					ID:       stat.ID,
					Data:     stat.Data,
				})
			}

			res = append(res, &model.NamedStatsWithScope{
				Name:  name,
				Scope: scope,
				Stats: mdlStats,
			})
		}
	}

	return res, nil
}

// Jobs is the resolver for the jobs field.
func (r *queryResolver) Jobs(ctx context.Context, filter []*model.JobFilter, page *model.PageRequest, order *model.OrderByInput) (*model.JobResultList, error) {
	if page == nil {
		page = &model.PageRequest{
			ItemsPerPage: 50,
			Page:         1,
		}
	}

	jobs, err := r.Repo.QueryJobs(ctx, filter, page, order)
	if err != nil {
		cclog.Warn("Error while querying jobs")
		return nil, err
	}

	count, err := r.Repo.CountJobs(ctx, filter)
	if err != nil {
		cclog.Warn("Error while counting jobs")
		return nil, err
	}

	// Note: Even if App-Default 'config.Keys.UiDefaults["job_list_usePaging"]' is set, always return hasNextPage boolean.
	// Users can decide in frontend to use continuous scroll, even if app-default is paging!
	// Skip if page.ItemsPerPage == -1 ("Load All" -> No Next Page required, Status Dashboards)
	/*
	  Example Page 4 @ 10 IpP : Does item 41 exist?
	  Minimal Page 41 @ 1 IpP : If len(result) is 1, Page 5 @ 10 IpP exists.
	*/
	hasNextPage := false
	if page.ItemsPerPage != -1 {
		nextPage := &model.PageRequest{
			ItemsPerPage: 1,
			Page:         ((page.Page * page.ItemsPerPage) + 1),
		}
		nextJobs, err := r.Repo.QueryJobs(ctx, filter, nextPage, order)
		if err != nil {
			cclog.Warn("Error while querying next jobs")
			return nil, err
		}
		hasNextPage = len(nextJobs) == 1
	}

	return &model.JobResultList{Items: jobs, Count: &count, HasNextPage: &hasNextPage}, nil
}

// JobsStatistics is the resolver for the jobsStatistics field.
func (r *queryResolver) JobsStatistics(ctx context.Context, filter []*model.JobFilter, metrics []string, page *model.PageRequest, sortBy *model.SortByAggregate, groupBy *model.Aggregate, numDurationBins *string, numMetricBins *int) ([]*model.JobsStatistics, error) {
	var err error
	var stats []*model.JobsStatistics

	// Top Level Defaults
	defaultDurationBins := "1h"
	defaultMetricBins := 10

	// Build requested fields map for selective column computation
	statsFields := []string{
		"totalJobs", "totalUsers", "totalWalltime", "totalNodes", "totalCores",
		"totalAccs", "totalNodeHours", "totalCoreHours", "totalAccHours", "runningJobs", "shortJobs",
	}
	reqFields := make(map[string]bool, len(statsFields))
	fetchedMainStats := false
	for _, f := range statsFields {
		if requireField(ctx, f) {
			reqFields[f] = true
			if f != "runningJobs" && f != "shortJobs" {
				fetchedMainStats = true
			}
		}
	}

	if fetchedMainStats {
		if groupBy == nil {
			stats, err = r.Repo.JobsStats(ctx, filter, reqFields)
		} else {
			startGrouped := time.Now()
			// Use request-scoped cache: multiple aliases with same (filter, groupBy)
			// but different sortBy/page hit the DB only once.
			if cache := getStatsGroupCache(ctx); cache != nil {
				key := statsCacheKey(filter, groupBy, reqFields)
				var allStats []*model.JobsStatistics
				allStats, err = cache.getOrCompute(key, func() ([]*model.JobsStatistics, error) {
					return r.Repo.JobsStatsGrouped(ctx, filter, nil, nil, groupBy, reqFields)
				})
				if err == nil {
					stats = sortAndPageStats(allStats, sortBy, page)
				}
			} else {
				stats, err = r.Repo.JobsStatsGrouped(ctx, filter, page, sortBy, groupBy, reqFields)
			}
			cclog.Infof("Timer JobsStatsGrouped call: %s", time.Since(startGrouped))
		}
	} else {
		stats = make([]*model.JobsStatistics, 0, 1)
		stats = append(stats, &model.JobsStatistics{})
	}

	// runningJobs and shortJobs are already inlined in JobsStats/JobsStatsGrouped.
	// Only run separate count queries if main stats were not fetched.
	if !fetchedMainStats {
		if groupBy != nil {
			if requireField(ctx, "shortJobs") {
				stats, err = r.Repo.AddJobCountGrouped(ctx, filter, groupBy, stats, "short")
			}
			if requireField(ctx, "runningJobs") {
				stats, err = r.Repo.AddJobCountGrouped(ctx, filter, groupBy, stats, "running")
			}
		} else {
			if requireField(ctx, "shortJobs") {
				stats, err = r.Repo.AddJobCount(ctx, filter, stats, "short")
			}
			if requireField(ctx, "runningJobs") {
				stats, err = r.Repo.AddJobCount(ctx, filter, stats, "running")
			}
		}
	}

	if err != nil {
		return nil, err
	}

	if requireField(ctx, "histDuration") || requireField(ctx, "histNumNodes") || requireField(ctx, "histNumCores") || requireField(ctx, "histNumAccs") {

		if numDurationBins == nil {
			numDurationBins = &defaultDurationBins
		}

		if groupBy == nil {
			stats[0], err = r.Repo.AddHistograms(ctx, filter, stats[0], numDurationBins)
			if err != nil {
				return nil, err
			}
		} else {
			return nil, errors.New("histograms only implemented without groupBy argument")
		}
	}

	if requireField(ctx, "histMetrics") {

		if numMetricBins == nil {
			numMetricBins = &defaultMetricBins
		}

		if groupBy == nil {
			stats[0], err = r.Repo.AddMetricHistograms(ctx, filter, metrics, stats[0], numMetricBins)
			if err != nil {
				return nil, err
			}
		} else {
			return nil, errors.New("metric histograms only implemented without groupBy argument")
		}
	}

	return stats, nil
}

// JobsMetricStats is the resolver for the jobsMetricStats field.
func (r *queryResolver) JobsMetricStats(ctx context.Context, filter []*model.JobFilter, metrics []string) ([]*model.JobStats, error) {
	// No Paging, Fixed Order by StartTime ASC
	order := &model.OrderByInput{
		Field: "startTime",
		Type:  "col",
		Order: "ASC",
	}

	jobs, err := r.Repo.QueryJobs(ctx, filter, nil, order)
	if err != nil {
		cclog.Warn("Error while querying jobs for comparison")
		return nil, err
	}

	res := []*model.JobStats{}
	for _, job := range jobs {
		data, err := metricdispatch.LoadJobStats(job, metrics, ctx)
		if err != nil {
			cclog.Warnf("Error while loading comparison jobStats data for job id %d", job.JobID)
			continue
			// return nil, err
		}

		sres := []*model.NamedStats{}
		for name, md := range data {
			sres = append(sres, &model.NamedStats{
				Name: name,
				Data: &md,
			})
		}

		numThreadsInt := int(job.NumHWThreads)
		numAccsInt := int(job.NumAcc)
		res = append(res, &model.JobStats{
			ID:              int(*job.ID),
			JobID:           strconv.Itoa(int(job.JobID)),
			StartTime:       int(job.StartTime),
			Duration:        int(job.Duration),
			Cluster:         job.Cluster,
			SubCluster:      job.SubCluster,
			NumNodes:        int(job.NumNodes),
			NumHWThreads:    &numThreadsInt,
			NumAccelerators: &numAccsInt,
			Stats:           sres,
		})
	}
	return res, err
}

// JobsFootprints is the resolver for the jobsFootprints field.
func (r *queryResolver) JobsFootprints(ctx context.Context, filter []*model.JobFilter, metrics []string) (*model.Footprints, error) {
	// NOTE: Legacy Naming! This resolver is for normalized histograms in analysis view only - *Not* related to DB "footprint" column!
	return r.jobsFootprints(ctx, filter, metrics)
}

// RooflineHeatmap is the resolver for the rooflineHeatmap field.
func (r *queryResolver) RooflineHeatmap(ctx context.Context, filter []*model.JobFilter, rows int, cols int, minX float64, minY float64, maxX float64, maxY float64) ([][]float64, error) {
	return r.rooflineHeatmap(ctx, filter, rows, cols, minX, minY, maxX, maxY)
}

// NodeMetrics is the resolver for the nodeMetrics field.
func (r *queryResolver) NodeMetrics(ctx context.Context, cluster string, nodes []string, scopes []schema.MetricScope, metrics []string, from time.Time, to time.Time) ([]*model.NodeMetrics, error) {
	user := repository.GetUserFromContext(ctx)
	if user != nil && !user.HasAnyRole([]schema.Role{schema.RoleAdmin, schema.RoleSupport}) {
		return nil, errors.New("you need to be administrator or support staff for this query")
	}

	defaultMetrics := make([]string, 0)
	for _, mc := range archive.GetCluster(cluster).MetricConfig {
		defaultMetrics = append(defaultMetrics, mc.Name)
	}
	if metrics == nil {
		metrics = defaultMetrics
	} else {
		metrics = slices.DeleteFunc(metrics, func(metric string) bool {
			return !slices.Contains(defaultMetrics, metric) // Remove undefined metrics.
		})
	}

	data, err := metricdispatch.LoadNodeData(cluster, metrics, nodes, scopes, from, to, ctx)
	if err != nil {
		cclog.Warn("error while loading node data")
		return nil, err
	}

	nodeRepo := repository.GetNodeRepository()
	nodeStateMap, metricHealthMap, _ := nodeRepo.MapNodes(cluster)

	nodeMetrics := make([]*model.NodeMetrics, 0, len(data))
	for hostname, metrics := range data {
		host := &model.NodeMetrics{
			Host:         hostname,
			NodeState:    nodeStateMap[hostname],
			MetricHealth: metricHealthMap[hostname],
			Metrics:      make([]*model.JobMetricWithName, 0, len(metrics)*len(scopes)),
		}
		host.SubCluster, err = archive.GetSubClusterByNode(cluster, hostname)
		if err != nil {
			cclog.Warnf("error in nodeMetrics resolver: %s", err)
		}

		for metric, scopedMetrics := range metrics {
			for _, scopedMetric := range scopedMetrics {
				host.Metrics = append(host.Metrics, &model.JobMetricWithName{
					Name:   metric,
					Scope:  schema.MetricScopeNode,
					Metric: scopedMetric,
				})
			}
		}

		nodeMetrics = append(nodeMetrics, host)
	}

	return nodeMetrics, nil
}

// NodeMetricsList is the resolver for the nodeMetricsList field.
func (r *queryResolver) NodeMetricsList(ctx context.Context, cluster string, subCluster string, stateFilter string, nodeFilter string, scopes []schema.MetricScope, metrics []string, from time.Time, to time.Time, page *model.PageRequest, resolution *int, resampleAlgo *model.ResampleAlgo) (*model.NodesResultList, error) {
	// Resolve resolution: explicit param > user policy > global config > 0
	duration := int64(to.Sub(from).Seconds())
	if resolution == nil {
		resolution = resolveResolutionFromPolicy(ctx, duration, cluster, metrics)
	}
	if resolution == nil {
		if config.Keys.EnableResampling != nil {
			defaultRes := slices.Max(config.Keys.EnableResampling.Resolutions)
			resolution = &defaultRes
		} else {
			defaultRes := 0
			resolution = &defaultRes
		}
	}

	user := repository.GetUserFromContext(ctx)
	if user != nil && !user.HasAnyRole([]schema.Role{schema.RoleAdmin, schema.RoleSupport}) {
		return nil, errors.New("you need to be administrator or support staff for this query")
	}

	nodeRepo := repository.GetNodeRepository()
	// nodes -> array hostname
	nodes, nodeStateMap, metricHealthMap, countNodes, hasNextPage, nerr := nodeRepo.GetNodesForList(ctx, cluster, subCluster, stateFilter, nodeFilter, page)
	if nerr != nil {
		return nil, errors.New("could not retrieve node list required for resolving NodeMetricsList")
	}

	if metrics == nil {
		for _, mc := range archive.GetCluster(cluster).MetricConfig {
			metrics = append(metrics, mc.Name)
		}
	}

	algoName := resolveResampleAlgo(ctx, resampleAlgo)

	// data -> map hostname:jobdata
	data, err := metricdispatch.LoadNodeListData(cluster, subCluster, nodes, metrics, scopes, *resolution, from, to, ctx, algoName)
	if err != nil {
		cclog.Warn("error while loading node data (Resolver.NodeMetricsList")
		return nil, err
	}

	nodeMetricsList := make([]*model.NodeMetrics, 0, len(data))
	for _, hostname := range nodes {
		host := &model.NodeMetrics{
			Host:         hostname,
			NodeState:    nodeStateMap[hostname],
			MetricHealth: metricHealthMap[hostname],
			Metrics:      make([]*model.JobMetricWithName, 0),
		}
		host.SubCluster, err = archive.GetSubClusterByNode(cluster, hostname)
		if err != nil {
			cclog.Warnf("error in nodeMetrics resolver: %s", err)
		}

		for metric, scopedMetrics := range data[hostname] {
			for scope, scopedMetric := range scopedMetrics {
				host.Metrics = append(host.Metrics, &model.JobMetricWithName{
					Name:   metric,
					Scope:  scope,
					Metric: scopedMetric,
				})
			}
		}

		nodeMetricsList = append(nodeMetricsList, host)
	}

	nodeMetricsListResult := &model.NodesResultList{
		Items: nodeMetricsList,
		// TotalNodes depends on sum of nodes grouped on latest timestamp, see repo/node.go:357
		TotalNodes:  &countNodes,
		HasNextPage: &hasNextPage,
	}

	return nodeMetricsListResult, nil
}

// ClusterMetrics is the resolver for the clusterMetrics field.
func (r *queryResolver) ClusterMetrics(ctx context.Context, cluster string, metrics []string, from time.Time, to time.Time) (*model.ClusterMetrics, error) {
	user := repository.GetUserFromContext(ctx)
	if user != nil && !user.HasAnyRole([]schema.Role{schema.RoleAdmin, schema.RoleSupport}) {
		return nil, errors.New("you need to be administrator or support staff for this query")
	}

	if metrics == nil {
		for _, mc := range archive.GetCluster(cluster).MetricConfig {
			metrics = append(metrics, mc.Name)
		}
	}

	// 'nodes' == nil -> Defaults to all nodes of cluster for existing query workflow
	scopes := []schema.MetricScope{"node"}
	data, err := metricdispatch.LoadNodeData(cluster, metrics, nil, scopes, from, to, ctx)
	if err != nil {
		cclog.Warn("error while loading node data")
		return nil, err
	}

	clusterMetricData := make([]*model.ClusterMetricWithName, 0)
	clusterMetrics := model.ClusterMetrics{NodeCount: 0, Metrics: clusterMetricData}

	collectorTimestep := make(map[string]int)
	collectorUnit := make(map[string]schema.Unit)
	collectorData := make(map[string][]schema.Float)

	for _, metrics := range data {
		clusterMetrics.NodeCount += 1
		for metric, scopedMetrics := range metrics {
			for _, scopedMetric := range scopedMetrics {
				// Collect Info Once
				_, okTimestep := collectorTimestep[metric]
				if !okTimestep {
					collectorTimestep[metric] = scopedMetric.Timestep
				}
				_, okUnit := collectorUnit[metric]
				if !okUnit {
					collectorUnit[metric] = scopedMetric.Unit
				}
				// Collect Data
				for _, ser := range scopedMetric.Series {
					_, okData := collectorData[metric]
					// Init With Datasize > 0
					if !okData && len(ser.Data) != 0 {
						collectorData[metric] = make([]schema.Float, len(ser.Data))
					} else if !okData {
						cclog.Debugf("[SCHEMARESOLVER] clusterMetrics skip init: no data -> %s at %s; size %d", metric, ser.Hostname, len(ser.Data))
					}
					// Sum if init'd and matching size
					if okData && len(ser.Data) == len(collectorData[metric]) {
						for i, val := range ser.Data {
							if val.IsNaN() {
								continue
							} else {
								collectorData[metric][i] += val
							}
						}
					} else if okData {
						cclog.Debugf("[SCHEMARESOLVER] clusterMetrics skip sum: data diff -> %s at %s; want size %d, have size %d", metric, ser.Hostname, len(collectorData[metric]), len(ser.Data))
					}
				}
			}
		}
	}

	for metricName, data := range collectorData {
		// use ccUnits for backend normalization to "Tera"
		p_old := ccunit.NewPrefix(collectorUnit[metricName].Prefix)
		p_new := ccunit.NewPrefix("T")
		convFunc := ccunit.GetPrefixPrefixFactor(p_old, p_new)
		u_new := schema.Unit{Prefix: p_new.Prefix(), Base: collectorUnit[metricName].Base}

		roundedData := make([]schema.Float, 0)
		for _, v_old := range data {
			v_new := math.Round(convFunc(float64(v_old)).(float64)*100.0) / 100.0
			roundedData = append(roundedData, schema.Float(v_new))
		}

		cm := model.ClusterMetricWithName{
			Name:     metricName,
			Unit:     &u_new,
			Timestep: collectorTimestep[metricName],
			Data:     roundedData,
		}

		clusterMetrics.Metrics = append(clusterMetrics.Metrics, &cm)
	}

	return &clusterMetrics, nil
}

// NumberOfNodes is the resolver for the numberOfNodes field.
func (r *subClusterResolver) NumberOfNodes(ctx context.Context, obj *schema.SubCluster) (int, error) {
	nodeList, err := archive.ParseNodeList(obj.Nodes)
	if err != nil {
		return 0, err
	}
	return nodeList.NodeCount(), nil
}

// Cluster returns generated.ClusterResolver implementation.
func (r *Resolver) Cluster() generated.ClusterResolver { return &clusterResolver{r} }

// Job returns generated.JobResolver implementation.
func (r *Resolver) Job() generated.JobResolver { return &jobResolver{r} }

// MetricValue returns generated.MetricValueResolver implementation.
func (r *Resolver) MetricValue() generated.MetricValueResolver { return &metricValueResolver{r} }

// Mutation returns generated.MutationResolver implementation.
func (r *Resolver) Mutation() generated.MutationResolver { return &mutationResolver{r} }

// Node returns generated.NodeResolver implementation.
func (r *Resolver) Node() generated.NodeResolver { return &nodeResolver{r} }

// Query returns generated.QueryResolver implementation.
func (r *Resolver) Query() generated.QueryResolver { return &queryResolver{r} }

// SubCluster returns generated.SubClusterResolver implementation.
func (r *Resolver) SubCluster() generated.SubClusterResolver { return &subClusterResolver{r} }

type clusterResolver struct{ *Resolver }
type jobResolver struct{ *Resolver }
type metricValueResolver struct{ *Resolver }
type mutationResolver struct{ *Resolver }
type nodeResolver struct{ *Resolver }
type queryResolver struct{ *Resolver }
type subClusterResolver struct{ *Resolver }

-      defaultRes := slices.Max(config.Keys.EnableResampling.Resolutions)

+      resolution = resolveResolutionFromDefaultPolicy(int64(job.Duration), job.Cluster, metrics)


	if resolution == nil {
		if config.Keys.EnableResampling != nil {
			defaultRes := slices.Max(config.Keys.EnableResampling.Resolutions)
			resolution = &defaultRes
		} else {
			defaultRes := 0
			resolution = &defaultRes
		}
	}

	user := repository.GetUserFromContext(ctx)

	if resolution == nil {
		if config.Keys.EnableResampling != nil {
			resolution = resolveResolutionFromDefaultPolicy(duration, cluster, metrics)
		}
		if resolution == nil {
			defaultRes := 0
			resolution = &defaultRes
		}
	}

	user := repository.GetUserFromContext(ctx)