     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 repository
     6→
     7→import (
     8→	"encoding/json"
     9→	"maps"
    10→	"sync"
    11→	"time"
    12→
    13→	"github.com/ClusterCockpit/cc-backend/internal/config"
    14→	"github.com/ClusterCockpit/cc-backend/web"
    15→	cclog "github.com/ClusterCockpit/cc-lib/v2/ccLogger"
    16→	"github.com/ClusterCockpit/cc-lib/v2/lrucache"
    17→	"github.com/ClusterCockpit/cc-lib/v2/schema"
    18→	"github.com/jmoiron/sqlx"
    19→)
    20→
    21→var (
    22→	userCfgRepoOnce     sync.Once
    23→	userCfgRepoInstance *UserCfgRepo
    24→)
    25→
    26→type UserCfgRepo struct {
    27→	DB         *sqlx.DB
    28→	Lookup     *sqlx.Stmt
    29→	uiDefaults map[string]any
    30→	cache      *lrucache.Cache
    31→	lock       sync.RWMutex
    32→}
    33→
    34→func GetUserCfgRepo() *UserCfgRepo {
    35→	userCfgRepoOnce.Do(func() {
    36→		db := GetConnection()
    37→
    38→		lookupConfigStmt, err := db.DB.Preparex(`SELECT confkey, value FROM configuration WHERE configuration.username = ?`)
    39→		if err != nil {
    40→			cclog.Fatalf("User Config: Call 'db.DB.Preparex()' failed.\nError: %s\n", err.Error())
    41→		}
    42→
    43→		userCfgRepoInstance = &UserCfgRepo{
    44→			DB:         db.DB,
    45→			Lookup:     lookupConfigStmt,
    46→			uiDefaults: web.UIDefaultsMap,
    47→			cache:      lrucache.New(1024),
    48→		}
    49→	})
    50→
    51→	return userCfgRepoInstance
    52→}
    53→
    54→// Return the personalised UI config for the currently authenticated
    55→// user or return the plain default config.
    56→func (uCfg *UserCfgRepo) GetUIConfig(user *schema.User) (map[string]any, error) {
    57→	if user == nil {
    58→		copy := make(map[string]any, len(uCfg.uiDefaults))
    59→		maps.Copy(copy, uCfg.uiDefaults)
    60→		return copy, nil
    61→	}
    62→
    63→	// Is the cache invalidated in case the options are changed?
    64→	data := uCfg.cache.Get(user.Username, func() (any, time.Duration, int) {
    65→		uiconfig := make(map[string]any, len(uCfg.uiDefaults))
    66→		maps.Copy(uiconfig, uCfg.uiDefaults)
    67→
    68→		rows, err := uCfg.Lookup.Query(user.Username)
    69→		if err != nil {
    70→			cclog.Warnf("Error while looking up user uiconfig for user '%v'", user.Username)
    71→			return err, 0, 0
    72→		}
    73→
    74→		size := 0
    75→		defer rows.Close()
    76→		for rows.Next() {
    77→			var key, rawval string
    78→			if err := rows.Scan(&key, &rawval); err != nil {
    79→				cclog.Warn("Error while scanning user uiconfig values")
    80→				return err, 0, 0
    81→			}
    82→
    83→			var val any
    84→			if err := json.Unmarshal([]byte(rawval), &val); err != nil {
    85→				cclog.Warn("Error while unmarshaling raw user uiconfig json")
    86→				return err, 0, 0
    87→			}
    88→
    89→			size += len(key)
    90→			size += len(rawval)
    91→			uiconfig[key] = val
    92→		}
    93→
    94→		// Add global ShortRunningJobsDuration setting as jobList_hideShortRunningJobs
    95→		uiconfig["jobList_hideShortRunningJobs"] = config.Keys.ShortRunningJobsDuration
    96→
    97→		return uiconfig, 24 * time.Hour, size
    98→	})
    99→	if err, ok := data.(error); ok {
   100→		cclog.Error("Error in returned dataset")
   101→		return nil, err
   102→	}
   103→
   104→	return data.(map[string]any), nil
   105→}
   106→
   107→// If the context does not have a user, update the global ui configuration
   108→// without persisting it!  If there is a (authenticated) user, update only his
   109→// configuration.
   110→func (uCfg *UserCfgRepo) UpdateConfig(
   111→	key, value string,
   112→	user *schema.User,
   113→) error {
   114→	if user == nil {
   115→		return nil
   116→	}
   117→
   118→	if _, err := uCfg.DB.Exec(`REPLACE INTO configuration (username, confkey, value) VALUES (?, ?, ?)`, user.Username, key, value); err != nil {
   119→		cclog.Warnf("Error while replacing user config in DB for user '%v'", user.Username)
   120→		return err
   121→	}
   122→
   123→	uCfg.cache.Del(user.Username)
   124→	return nil
   125→}
   126→

// 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 repository

import (
	"encoding/json"
	"maps"
	"sync"
	"time"

	"github.com/ClusterCockpit/cc-backend/internal/config"
	"github.com/ClusterCockpit/cc-backend/web"
	cclog "github.com/ClusterCockpit/cc-lib/v2/ccLogger"
	"github.com/ClusterCockpit/cc-lib/v2/lrucache"
	"github.com/ClusterCockpit/cc-lib/v2/schema"
	"github.com/jmoiron/sqlx"
)

var (
	userCfgRepoOnce     sync.Once
	userCfgRepoInstance *UserCfgRepo
)

type UserCfgRepo struct {
	DB         *sqlx.DB
	Lookup     *sqlx.Stmt
	uiDefaults map[string]any
	cache      *lrucache.Cache
	lock       sync.RWMutex
}

func GetUserCfgRepo() *UserCfgRepo {
	userCfgRepoOnce.Do(func() {
		db := GetConnection()

		lookupConfigStmt, err := db.DB.Preparex(`SELECT confkey, value FROM configuration WHERE configuration.username = ?`)
		if err != nil {
			cclog.Fatalf("User Config: Call 'db.DB.Preparex()' failed.\nError: %s\n", err.Error())
		}

		userCfgRepoInstance = &UserCfgRepo{
			DB:         db.DB,
			Lookup:     lookupConfigStmt,
			uiDefaults: web.UIDefaultsMap,
			cache:      lrucache.New(1024),
		}
	})

	return userCfgRepoInstance
}

// Return the personalised UI config for the currently authenticated
// user or return the plain default config.
func (uCfg *UserCfgRepo) GetUIConfig(user *schema.User) (map[string]any, error) {
	if user == nil {
		copy := make(map[string]any, len(uCfg.uiDefaults))
		maps.Copy(copy, uCfg.uiDefaults)
		return copy, nil
	}

	// Is the cache invalidated in case the options are changed?
	data := uCfg.cache.Get(user.Username, func() (any, time.Duration, int) {
		uiconfig := make(map[string]any, len(uCfg.uiDefaults))
		maps.Copy(uiconfig, uCfg.uiDefaults)

		rows, err := uCfg.Lookup.Query(user.Username)
		if err != nil {
			cclog.Warnf("Error while looking up user uiconfig for user '%v'", user.Username)
			return err, 0, 0
		}

		size := 0
		defer rows.Close()
		for rows.Next() {
			var key, rawval string
			if err := rows.Scan(&key, &rawval); err != nil {
				cclog.Warn("Error while scanning user uiconfig values")
				return err, 0, 0
			}

			var val any
			if err := json.Unmarshal([]byte(rawval), &val); err != nil {
				cclog.Warn("Error while unmarshaling raw user uiconfig json")
				return err, 0, 0
			}

			size += len(key)
			size += len(rawval)
			uiconfig[key] = val
		}

		// Add global ShortRunningJobsDuration setting as jobList_hideShortRunningJobs
		uiconfig["jobList_hideShortRunningJobs"] = config.Keys.ShortRunningJobsDuration

		return uiconfig, 24 * time.Hour, size
	})
	if err, ok := data.(error); ok {
		cclog.Error("Error in returned dataset")
		return nil, err
	}

	return data.(map[string]any), nil
}

// If the context does not have a user, update the global ui configuration
// without persisting it!  If there is a (authenticated) user, update only his
// configuration.
func (uCfg *UserCfgRepo) UpdateConfig(
	key, value string,
	user *schema.User,
) error {
	if user == nil {
		return nil
	}

	if _, err := uCfg.DB.Exec(`REPLACE INTO configuration (username, confkey, value) VALUES (?, ?, ?)`, user.Username, key, value); err != nil {
		cclog.Warnf("Error while replacing user config in DB for user '%v'", user.Username)
		return err
	}

	uCfg.cache.Del(user.Username)
	return nil
}


Now let me check one more thing - how config.tmpl passes resampleConfig to the settings page.


1→{{define "content"}}
     2→

     3→{{end}}
     4→
     5→{{define "stylesheets"}}
     6→    <link rel='stylesheet' href='/build/config.css'>
     7→{{end}}
     8→{{define "javascript"}}
     9→
    19→
    20→{{end}}

{{define "content"}}

{{end}}

{{define "stylesheets"}}
    <link rel='stylesheet' href='/build/config.css'>
{{end}}
{{define "javascript"}}

{{end}}


     1→import { mount } from 'svelte';
     2→import {} from './header.entrypoint.js'
     3→import Config from './Config.root.svelte'
     4→
     5→mount(Config, {
     6→    target: document.getElementById('svelte-app'),
     7→    props: {
     8→        isAdmin: isAdmin,
     9→        isSupport: isSupport,
    10→        isApi: isApi,
    11→        username: username,
    12→        ncontent: ncontent,
    13→        clusterNames: hClusterNames
    14→    },
    15→    context: new Map([
    16→            ['cc-config', clusterCockpitConfig],
    17→            ['resampling', resampleConfig]
    18→    ])
    19→})
    20→

import { mount } from 'svelte';
import {} from './header.entrypoint.js'
import Config from './Config.root.svelte'

mount(Config, {
    target: document.getElementById('svelte-app'),
    props: {
        isAdmin: isAdmin,
        isSupport: isSupport,
        isApi: isApi,
        username: username,
        ncontent: ncontent,
        clusterNames: hClusterNames
    },
    context: new Map([
            ['cc-config', clusterCockpitConfig],
            ['resampling', resampleConfig]
    ])
})


   290→			} else {
   291→				tags = append(tags, int(tid))
   292→			}
   293→		} else {
   294→			cclog.Warnf("Not authorized to remove tag: %d", tid)
   295→			return nil, fmt.Errorf("not authorized to remove tag: %d", tid)
   296→		}
   297→	}
   298→	return tags, nil
   299→}
   300→
   301→// UpdateConfiguration is the resolver for the updateConfiguration field.
   302→func (r *mutationResolver) UpdateConfiguration(ctx context.Context, name string, value string) (*string, error) {
   303→	if err := repository.GetUserCfgRepo().UpdateConfig(name, value, repository.GetUserFromContext(ctx)); err != nil {
   304→		cclog.Warn("Error while updating user config")
   305→		return nil, err
   306→	}
   307→
   308→	return nil, nil
   309→}

			} 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
}


Good, I have a complete picture now. Let me implement all the changes. I'll work in parallel where possible.


Write

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

import "math"

type ResamplePolicy string

const (
	ResamplePolicyLow    ResamplePolicy = "low"
	ResamplePolicyMedium ResamplePolicy = "medium"
	ResamplePolicyHigh   ResamplePolicy = "high"
)

// TargetPointsForPolicy returns the target number of data points for a given policy.
func TargetPointsForPolicy(policy ResamplePolicy) int {
	switch policy {
	case ResamplePolicyLow:
		return 200
	case ResamplePolicyMedium:
		return 500
	case ResamplePolicyHigh:
		return 1000
	default:
		return 0
	}
}

// ComputeResolution computes the resampling resolution in seconds for a given
// job duration, metric frequency, and target point count. Returns 0 if the
// total number of data points is already at or below targetPoints (no resampling needed).
func ComputeResolution(duration int64, frequency int64, targetPoints int) int {
	if frequency <= 0 || targetPoints <= 0 || duration <= 0 {
		return 0
	}

	totalPoints := duration / frequency
	if totalPoints <= int64(targetPoints) {
		return 0
	}

	targetRes := math.Ceil(float64(duration) / float64(targetPoints))
	// Round up to nearest multiple of frequency
	resolution := int(math.Ceil(targetRes/float64(frequency))) * int(frequency)

	return resolution
}


File created successfully at: /Users/jan/tmp/cc-backend/internal/metricdispatch/resamplepolicy.go

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

import "math"

type ResamplePolicy string

const (
	ResamplePolicyLow    ResamplePolicy = "low"
	ResamplePolicyMedium ResamplePolicy = "medium"
	ResamplePolicyHigh   ResamplePolicy = "high"
)

// TargetPointsForPolicy returns the target number of data points for a given policy.
func TargetPointsForPolicy(policy ResamplePolicy) int {
	switch policy {
	case ResamplePolicyLow:
		return 200
	case ResamplePolicyMedium:
		return 500
	case ResamplePolicyHigh:
		return 1000
	default:
		return 0
	}
}

// ComputeResolution computes the resampling resolution in seconds for a given
// job duration, metric frequency, and target point count. Returns 0 if the
// total number of data points is already at or below targetPoints (no resampling needed).
func ComputeResolution(duration int64, frequency int64, targetPoints int) int {
	if frequency <= 0 || targetPoints <= 0 || duration <= 0 {
		return 0
	}

	totalPoints := duration / frequency
	if totalPoints <= int64(targetPoints) {
		return 0
	}

	targetRes := math.Ceil(float64(duration) / float64(targetPoints))
	// Round up to nearest multiple of frequency
	resolution := int(math.Ceil(targetRes/float64(frequency))) * int(frequency)

	return resolution
}


Now let me write the test file and update the remaining files in parallel.


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

import "testing"

func TestTargetPointsForPolicy(t *testing.T) {
	tests := []struct {
		policy ResamplePolicy
		want   int
	}{
		{ResamplePolicyLow, 200},
		{ResamplePolicyMedium, 500},
		{ResamplePolicyHigh, 1000},
		{ResamplePolicy("unknown"), 0},
		{ResamplePolicy(""), 0},
	}

	for _, tt := range tests {
		if got := TargetPointsForPolicy(tt.policy); got != tt.want {
			t.Errorf("TargetPointsForPolicy(%q) = %d, want %d", tt.policy, got, tt.want)
		}
	}
}

func TestComputeResolution(t *testing.T) {
	tests := []struct {
		name         string
		duration     int64
		frequency    int64
		targetPoints int
		want         int
	}{
		// 24h job, 60s frequency, 1440 total points
		{"low_24h_60s", 86400, 60, 200, 480},
		{"medium_24h_60s", 86400, 60, 500, 180},
		{"high_24h_60s", 86400, 60, 1000, 120},

		// 2h job, 60s frequency, 120 total points — no resampling needed
		{"low_2h_60s", 7200, 60, 200, 0},
		{"medium_2h_60s", 7200, 60, 500, 0},
		{"high_2h_60s", 7200, 60, 1000, 0},

		// Edge: zero/negative inputs
		{"zero_duration", 0, 60, 200, 0},
		{"zero_frequency", 86400, 0, 200, 0},
		{"zero_target", 86400, 60, 0, 0},
		{"negative_duration", -100, 60, 200, 0},

		// 12h job, 30s frequency, 1440 total points
		{"medium_12h_30s", 43200, 30, 500, 90},

		// Exact fit: total points == target points
		{"exact_fit", 12000, 60, 200, 0},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			got := ComputeResolution(tt.duration, tt.frequency, tt.targetPoints)
			if got != tt.want {
				t.Errorf("ComputeResolution(%d, %d, %d) = %d, want %d",
					tt.duration, tt.frequency, tt.targetPoints, got, tt.want)
			}
		})
	}
}


File created successfully at: /Users/jan/tmp/cc-backend/internal/metricdispatch/resamplepolicy_test.go

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

import "testing"

func TestTargetPointsForPolicy(t *testing.T) {
	tests := []struct {
		policy ResamplePolicy
		want   int
	}{
		{ResamplePolicyLow, 200},
		{ResamplePolicyMedium, 500},
		{ResamplePolicyHigh, 1000},
		{ResamplePolicy("unknown"), 0},
		{ResamplePolicy(""), 0},
	}

	for _, tt := range tests {
		if got := TargetPointsForPolicy(tt.policy); got != tt.want {
			t.Errorf("TargetPointsForPolicy(%q) = %d, want %d", tt.policy, got, tt.want)
		}
	}
}

func TestComputeResolution(t *testing.T) {
	tests := []struct {
		name         string
		duration     int64
		frequency    int64
		targetPoints int
		want         int
	}{
		// 24h job, 60s frequency, 1440 total points
		{"low_24h_60s", 86400, 60, 200, 480},
		{"medium_24h_60s", 86400, 60, 500, 180},
		{"high_24h_60s", 86400, 60, 1000, 120},

		// 2h job, 60s frequency, 120 total points — no resampling needed
		{"low_2h_60s", 7200, 60, 200, 0},
		{"medium_2h_60s", 7200, 60, 500, 0},
		{"high_2h_60s", 7200, 60, 1000, 0},

		// Edge: zero/negative inputs
		{"zero_duration", 0, 60, 200, 0},
		{"zero_frequency", 86400, 0, 200, 0},
		{"zero_target", 86400, 60, 0, 0},
		{"negative_duration", -100, 60, 200, 0},

		// 12h job, 30s frequency, 1440 total points
		{"medium_12h_30s", 43200, 30, 500, 90},

		// Exact fit: total points == target points
		{"exact_fit", 12000, 60, 200, 0},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			got := ComputeResolution(tt.duration, tt.frequency, tt.targetPoints)
			if got != tt.want {
				t.Errorf("ComputeResolution(%d, %d, %d) = %d, want %d",
					tt.duration, tt.frequency, tt.targetPoints, got, tt.want)
			}
		})
	}
}


Edit

type PlotConfiguration struct {
	ColorBackground bool     `json:"color-background"`
	PlotsPerRow     int      `json:"plots-per-row"`
	LineWidth       int      `json:"line-width"`
	ColorScheme     []string `json:"color-scheme"`
}

type PlotConfiguration struct {
	ColorBackground bool     `json:"color-background"`
	PlotsPerRow     int      `json:"plots-per-row"`
	LineWidth       int      `json:"line-width"`
	ColorScheme     []string `json:"color-scheme"`
	ResampleAlgo    string   `json:"resample-algo"`
	ResamplePolicy  string   `json:"resample-policy"`
}


The file /Users/jan/tmp/cc-backend/web/web.go has been updated successfully.

type PlotConfiguration struct {
	ColorBackground bool     `json:"color-background"`
	PlotsPerRow     int      `json:"plots-per-row"`
	LineWidth       int      `json:"line-width"`
	ColorScheme     []string `json:"color-scheme"`
	ResampleAlgo    string   `json:"resample-algo"`
	ResamplePolicy  string   `json:"resample-policy"`
}

// 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 web implements the HTML templating and web frontend configuration
package web

import (
	"embed"
	"encoding/json"
	"html/template"
	"io/fs"
	"net/http"
	"strings"

	"github.com/ClusterCockpit/cc-backend/internal/config"
	"github.com/ClusterCockpit/cc-backend/pkg/archive"
	cclog "github.com/ClusterCockpit/cc-lib/v2/ccLogger"
	"github.com/ClusterCockpit/cc-lib/v2/schema"
	"github.com/ClusterCockpit/cc-lib/v2/util"
)

type WebConfig struct {
	JobList           JobListConfig     `json:"job-list"`
	NodeList          NodeListConfig    `json:"node-list"`
	JobView           JobViewConfig     `json:"job-view"`
	MetricConfig      MetricConfig      `json:"metric-config"`
	PlotConfiguration PlotConfiguration `json:"plot-configuration"`
}

type JobListConfig struct {
	UsePaging     bool `json:"use-paging"`
	ShowFootprint bool `json:"show-footprint"`
}

type NodeListConfig struct {
	UsePaging bool `json:"use-paging"`
}

type JobViewConfig struct {
	ShowPolarPlot bool `json:"show-polar-plot"`
	ShowFootprint bool `json:"show-footprint"`
	ShowRoofline  bool `json:"show-roofline"`
	ShowStatTable bool `json:"show-stat-table"`
}

type MetricConfig struct {
	JobListMetrics      []string        `json:"job-list-metrics"`
	JobViewPlotMetrics  []string        `json:"job-view-plot-metrics"`
	JobViewTableMetrics []string        `json:"job-view-table-metrics"`
	Clusters            []ClusterConfig `json:"clusters"`
}

type ClusterConfig struct {
	Name                string             `json:"name"`
	JobListMetrics      []string           `json:"job-list-metrics"`
	JobViewPlotMetrics  []string           `json:"job-view-plot-metrics"`
	JobViewTableMetrics []string           `json:"job-view-table-metrics"`
	SubClusters         []SubClusterConfig `json:"sub-clusters"`
}

type SubClusterConfig struct {
	Name                string   `json:"name"`
	JobListMetrics      []string `json:"job-list-metrics"`
	JobViewPlotMetrics  []string `json:"job-view-plot-metrics"`
	JobViewTableMetrics []string `json:"job-view-table-metrics"`
}

type PlotConfiguration struct {
	ColorBackground bool     `json:"color-background"`
	PlotsPerRow     int      `json:"plots-per-row"`
	LineWidth       int      `json:"line-width"`
	ColorScheme     []string `json:"color-scheme"`
}

var UIDefaults = WebConfig{
	JobList: JobListConfig{
		UsePaging:     false,
		ShowFootprint: false,
	},
	NodeList: NodeListConfig{
		UsePaging: false,
	},
	JobView: JobViewConfig{
		ShowPolarPlot: true,
		ShowFootprint: false,
		ShowRoofline:  true,
		ShowStatTable: true,
	},
	MetricConfig: MetricConfig{
		JobListMetrics:      []string{"cpu_load", "flops_any", "mem_bw", "mem_used"},
		JobViewPlotMetrics:  []string{"cpu_load", "flops_any", "mem_bw", "mem_used"},
		JobViewTableMetrics: []string{"flops_any", "mem_bw", "mem_used"},
	},
	PlotConfiguration: PlotConfiguration{
		ColorBackground: true,
		PlotsPerRow:     3,
		LineWidth:       3,
		ColorScheme:     []string{"#00bfff", "#0000ff", "#ff00ff", "#ff0000", "#ff8000", "#ffff00", "#80ff00"},
	},
}

var UIDefaultsMap map[string]any

//
// 	map[string]any{
// 	"analysis_view_histogramMetrics":         []string{"flops_any", "mem_bw", "mem_used"},
// 	"analysis_view_scatterPlotMetrics":       [][]string{{"flops_any", "mem_bw"}, {"flops_any", "cpu_load"}, {"cpu_load", "mem_bw"}},
// 	"job_view_nodestats_selectedMetrics":     []string{"flops_any", "mem_bw", "mem_used"},
// 	"plot_list_jobsPerPage":                  50,
// 	"analysis_view_selectedTopEntity":        "user",
// 	"analysis_view_selectedTopCategory":      "totalWalltime",
// 	"status_view_selectedTopUserCategory":    "totalJobs",
// 	"status_view_selectedTopProjectCategory": "totalJobs",
// }

func Init(rawConfig json.RawMessage) error {
	var err error

	if rawConfig != nil {
		config.Validate(configSchema, rawConfig)
		if err = json.Unmarshal(rawConfig, &UIDefaults); err != nil {
			cclog.Warn("Error while unmarshaling raw config json")
			return err
		}
	}

	UIDefaultsMap = make(map[string]any)

	UIDefaultsMap["jobList_usePaging"] = UIDefaults.JobList.UsePaging
	UIDefaultsMap["jobList_showFootprint"] = UIDefaults.JobList.ShowFootprint
	UIDefaultsMap["nodeList_usePaging"] = UIDefaults.NodeList.UsePaging
	UIDefaultsMap["jobView_showPolarPlot"] = UIDefaults.JobView.ShowPolarPlot
	UIDefaultsMap["jobView_showFootprint"] = UIDefaults.JobView.ShowFootprint
	UIDefaultsMap["jobView_showRoofline"] = UIDefaults.JobView.ShowRoofline
	UIDefaultsMap["jobView_showStatTable"] = UIDefaults.JobView.ShowStatTable

	UIDefaultsMap["metricConfig_jobListMetrics"] = UIDefaults.MetricConfig.JobListMetrics
	UIDefaultsMap["metricConfig_jobViewPlotMetrics"] = UIDefaults.MetricConfig.JobViewPlotMetrics
	UIDefaultsMap["metricConfig_jobViewTableMetrics"] = UIDefaults.MetricConfig.JobViewTableMetrics

	UIDefaultsMap["plotConfiguration_colorBackground"] = UIDefaults.PlotConfiguration.ColorBackground
	UIDefaultsMap["plotConfiguration_plotsPerRow"] = UIDefaults.PlotConfiguration.PlotsPerRow
	UIDefaultsMap["plotConfiguration_lineWidth"] = UIDefaults.PlotConfiguration.LineWidth
	UIDefaultsMap["plotConfiguration_colorScheme"] = UIDefaults.PlotConfiguration.ColorScheme

	for _, c := range UIDefaults.MetricConfig.Clusters {
		if c.JobListMetrics != nil {
			UIDefaultsMap["metricConfig_jobListMetrics:"+c.Name] = c.JobListMetrics
		}
		if c.JobViewPlotMetrics != nil {
			UIDefaultsMap["metricConfig_jobViewPlotMetrics:"+c.Name] = c.JobViewPlotMetrics
		}
		if c.JobViewTableMetrics != nil {
			UIDefaultsMap["metricConfig_jobViewTableMetrics:"+c.Name] = c.JobViewTableMetrics
		}

		for _, sc := range c.SubClusters {
			suffix := strings.Join([]string{c.Name, sc.Name}, ":")
			if sc.JobListMetrics != nil {
				UIDefaultsMap["metricConfig_jobListMetrics:"+suffix] = sc.JobListMetrics
			}
			if sc.JobViewPlotMetrics != nil {
				UIDefaultsMap["metricConfig_jobViewPlotMetrics:"+suffix] = sc.JobViewPlotMetrics
			}
			if sc.JobViewTableMetrics != nil {
				UIDefaultsMap["metricConfig_jobViewTableMetrics:"+suffix] = sc.JobViewTableMetrics
			}
		}
	}

	return err
}

// / 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)).
//
//go:embed frontend/public/*
var frontendFiles embed.FS

func ServeFiles() http.Handler {
	publicFiles, err := fs.Sub(frontendFiles, "frontend/public")
	if err != nil {
		cclog.Abortf("Serve Files: Could not find 'frontend/public' file directory.\nError: %s\n", err.Error())
	}
	return http.FileServer(http.FS(publicFiles))
}

// StaticFileExists checks whether a static file exists in the embedded frontend FS.
func StaticFileExists(path string) bool {
	path = strings.TrimPrefix(path, "/")
	if path == "" {
		return false
	}
	_, err := fs.Stat(frontendFiles, "frontend/public/"+path)
	return err == nil
}

//go:embed templates/*
var templateFiles embed.FS

var templates map[string]*template.Template = map[string]*template.Template{}

func init() {
	base := template.Must(template.ParseFS(templateFiles, "templates/base.tmpl"))
	if err := fs.WalkDir(templateFiles, "templates", func(path string, d fs.DirEntry, err error) error {
		if err != nil {
			return err
		}
		if d.IsDir() || path == "templates/base.tmpl" {
			return nil
		}

		if path == "templates/404.tmpl" {
			templates[strings.TrimPrefix(path, "templates/")] = template.Must(template.ParseFS(templateFiles, path))
			return nil
		}
		if path == "templates/login.tmpl" {
			if util.CheckFileExists("./var/login.tmpl") {
				cclog.Info("overwrite login.tmpl with local file")
				templates[strings.TrimPrefix(path, "templates/")] = template.Must(template.Must(base.Clone()).ParseFiles("./var/login.tmpl"))
				return nil
			}
		}
		if path == "templates/imprint.tmpl" {
			if util.CheckFileExists("./var/imprint.tmpl") {
				cclog.Info("overwrite imprint.tmpl with local file")
				templates[strings.TrimPrefix(path, "templates/")] = template.Must(template.Must(base.Clone()).ParseFiles("./var/imprint.tmpl"))
				return nil
			}
		}
		if path == "templates/privacy.tmpl" {
			if util.CheckFileExists("./var/privacy.tmpl") {
				cclog.Info("overwrite privacy.tmpl with local file")
				templates[strings.TrimPrefix(path, "templates/")] = template.Must(template.Must(base.Clone()).ParseFiles("./var/privacy.tmpl"))
				return nil
			}
		}

		templates[strings.TrimPrefix(path, "templates/")] = template.Must(template.Must(base.Clone()).ParseFS(templateFiles, path))
		return nil
	}); err != nil {
		cclog.Abortf("Web init(): Could not find frontend template files.\nError: %s\n", err.Error())
	}

	_ = base
}

type Build struct {
	Version   string
	Hash      string
	Buildtime string
}

type Page struct {
	Title         string                 // Page title
	MsgType       string                 // For generic use in message boxes
	Message       string                 // For generic use in message boxes
	User          schema.User            // Information about the currently logged in user (Full User Info)
	Roles         map[string]schema.Role // Available roles for frontend render checks
	Build         Build                  // Latest information about the application
	Clusters      []string               // List of all cluster names
	SubClusters   map[string][]string    // Map per cluster of all subClusters for use in the Header
	FilterPresets map[string]any         // For pages with the Filter component, this can be used to set initial filters.
	Infos         map[string]any         // For generic use (e.g. username for /monitoring/user/<id>, job id for /monitoring/job/<id>)
	Config        map[string]any         // UI settings for the currently logged in user (e.g. line width, ...)
	Resampling    *config.ResampleConfig // If not nil, defines resampling trigger and resolutions
	Redirect      string                 // The originally requested URL, for intermediate login handling
}

func RenderTemplate(rw http.ResponseWriter, file string, page *Page) {
	t, ok := templates[file]
	if !ok {
		cclog.Errorf("WEB/WEB > template '%s' not found", file)
	}

	if page.Clusters == nil {
		page.Clusters = make([]string, 0)
	}

	if page.SubClusters == nil {
		page.SubClusters = make(map[string][]string)
		for _, cluster := range archive.Clusters {
			page.Clusters = append(page.Clusters, cluster.Name)

			for _, sc := range cluster.SubClusters {
				page.SubClusters[cluster.Name] = append(page.SubClusters[cluster.Name], sc.Name)
			}
		}
	}

	cclog.Debugf("Page config : %v\n", page.Config)
	if err := t.Execute(rw, page); err != nil {
		cclog.Errorf("Template error: %s", err.Error())
	}
}

PlotsPerRow     int      `json:"plots-per-row"`

LineWidth       int      `json:"line-width"`

+  ResampleAlgo    string   `json:"resample-algo"`

+  ResamplePolicy  string   `json:"resample-policy"`


	UIDefaultsMap["plotConfiguration_colorScheme"] = UIDefaults.PlotConfiguration.ColorScheme
	UIDefaultsMap["plotConfiguration_resampleAlgo"] = UIDefaults.PlotConfiguration.ResampleAlgo
	UIDefaultsMap["plotConfiguration_resamplePolicy"] = UIDefaults.PlotConfiguration.ResamplePolicy


	UIDefaultsMap["plotConfiguration_colorScheme"] = UIDefaults.PlotConfiguration.ColorScheme
	UIDefaultsMap["plotConfiguration_resampleAlgo"] = UIDefaults.PlotConfiguration.ResampleAlgo
	UIDefaultsMap["plotConfiguration_resamplePolicy"] = UIDefaults.PlotConfiguration.ResamplePolicy

// 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 web implements the HTML templating and web frontend configuration
package web

import (
	"embed"
	"encoding/json"
	"html/template"
	"io/fs"
	"net/http"
	"strings"

	"github.com/ClusterCockpit/cc-backend/internal/config"
	"github.com/ClusterCockpit/cc-backend/pkg/archive"
	cclog "github.com/ClusterCockpit/cc-lib/v2/ccLogger"
	"github.com/ClusterCockpit/cc-lib/v2/schema"
	"github.com/ClusterCockpit/cc-lib/v2/util"
)

type WebConfig struct {
	JobList           JobListConfig     `json:"job-list"`
	NodeList          NodeListConfig    `json:"node-list"`
	JobView           JobViewConfig     `json:"job-view"`
	MetricConfig      MetricConfig      `json:"metric-config"`
	PlotConfiguration PlotConfiguration `json:"plot-configuration"`
}

type JobListConfig struct {
	UsePaging     bool `json:"use-paging"`
	ShowFootprint bool `json:"show-footprint"`
}

type NodeListConfig struct {
	UsePaging bool `json:"use-paging"`
}

type JobViewConfig struct {
	ShowPolarPlot bool `json:"show-polar-plot"`
	ShowFootprint bool `json:"show-footprint"`
	ShowRoofline  bool `json:"show-roofline"`
	ShowStatTable bool `json:"show-stat-table"`
}

type MetricConfig struct {
	JobListMetrics      []string        `json:"job-list-metrics"`
	JobViewPlotMetrics  []string        `json:"job-view-plot-metrics"`
	JobViewTableMetrics []string        `json:"job-view-table-metrics"`
	Clusters            []ClusterConfig `json:"clusters"`
}

type ClusterConfig struct {
	Name                string             `json:"name"`
	JobListMetrics      []string           `json:"job-list-metrics"`
	JobViewPlotMetrics  []string           `json:"job-view-plot-metrics"`
	JobViewTableMetrics []string           `json:"job-view-table-metrics"`
	SubClusters         []SubClusterConfig `json:"sub-clusters"`
}

type SubClusterConfig struct {
	Name                string   `json:"name"`
	JobListMetrics      []string `json:"job-list-metrics"`
	JobViewPlotMetrics  []string `json:"job-view-plot-metrics"`
	JobViewTableMetrics []string `json:"job-view-table-metrics"`
}

type PlotConfiguration struct {
	ColorBackground bool     `json:"color-background"`
	PlotsPerRow     int      `json:"plots-per-row"`
	LineWidth       int      `json:"line-width"`
	ColorScheme     []string `json:"color-scheme"`
	ResampleAlgo    string   `json:"resample-algo"`
	ResamplePolicy  string   `json:"resample-policy"`
}

var UIDefaults = WebConfig{
	JobList: JobListConfig{
		UsePaging:     false,
		ShowFootprint: false,
	},
	NodeList: NodeListConfig{
		UsePaging: false,
	},
	JobView: JobViewConfig{
		ShowPolarPlot: true,
		ShowFootprint: false,
		ShowRoofline:  true,
		ShowStatTable: true,
	},
	MetricConfig: MetricConfig{
		JobListMetrics:      []string{"cpu_load", "flops_any", "mem_bw", "mem_used"},
		JobViewPlotMetrics:  []string{"cpu_load", "flops_any", "mem_bw", "mem_used"},
		JobViewTableMetrics: []string{"flops_any", "mem_bw", "mem_used"},
	},
	PlotConfiguration: PlotConfiguration{
		ColorBackground: true,
		PlotsPerRow:     3,
		LineWidth:       3,
		ColorScheme:     []string{"#00bfff", "#0000ff", "#ff00ff", "#ff0000", "#ff8000", "#ffff00", "#80ff00"},
	},
}

var UIDefaultsMap map[string]any

//
// 	map[string]any{
// 	"analysis_view_histogramMetrics":         []string{"flops_any", "mem_bw", "mem_used"},
// 	"analysis_view_scatterPlotMetrics":       [][]string{{"flops_any", "mem_bw"}, {"flops_any", "cpu_load"}, {"cpu_load", "mem_bw"}},
// 	"job_view_nodestats_selectedMetrics":     []string{"flops_any", "mem_bw", "mem_used"},
// 	"plot_list_jobsPerPage":                  50,
// 	"analysis_view_selectedTopEntity":        "user",
// 	"analysis_view_selectedTopCategory":      "totalWalltime",
// 	"status_view_selectedTopUserCategory":    "totalJobs",
// 	"status_view_selectedTopProjectCategory": "totalJobs",
// }

func Init(rawConfig json.RawMessage) error {
	var err error

	if rawConfig != nil {
		config.Validate(configSchema, rawConfig)
		if err = json.Unmarshal(rawConfig, &UIDefaults); err != nil {
			cclog.Warn("Error while unmarshaling raw config json")
			return err
		}
	}

	UIDefaultsMap = make(map[string]any)

	UIDefaultsMap["jobList_usePaging"] = UIDefaults.JobList.UsePaging
	UIDefaultsMap["jobList_showFootprint"] = UIDefaults.JobList.ShowFootprint
	UIDefaultsMap["nodeList_usePaging"] = UIDefaults.NodeList.UsePaging
	UIDefaultsMap["jobView_showPolarPlot"] = UIDefaults.JobView.ShowPolarPlot
	UIDefaultsMap["jobView_showFootprint"] = UIDefaults.JobView.ShowFootprint
	UIDefaultsMap["jobView_showRoofline"] = UIDefaults.JobView.ShowRoofline
	UIDefaultsMap["jobView_showStatTable"] = UIDefaults.JobView.ShowStatTable

	UIDefaultsMap["metricConfig_jobListMetrics"] = UIDefaults.MetricConfig.JobListMetrics
	UIDefaultsMap["metricConfig_jobViewPlotMetrics"] = UIDefaults.MetricConfig.JobViewPlotMetrics
	UIDefaultsMap["metricConfig_jobViewTableMetrics"] = UIDefaults.MetricConfig.JobViewTableMetrics

	UIDefaultsMap["plotConfiguration_colorBackground"] = UIDefaults.PlotConfiguration.ColorBackground
	UIDefaultsMap["plotConfiguration_plotsPerRow"] = UIDefaults.PlotConfiguration.PlotsPerRow
	UIDefaultsMap["plotConfiguration_lineWidth"] = UIDefaults.PlotConfiguration.LineWidth
	UIDefaultsMap["plotConfiguration_colorScheme"] = UIDefaults.PlotConfiguration.ColorScheme

	for _, c := range UIDefaults.MetricConfig.Clusters {
		if c.JobListMetrics != nil {
			UIDefaultsMap["metricConfig_jobListMetrics:"+c.Name] = c.JobListMetrics
		}
		if c.JobViewPlotMetrics != nil {
			UIDefaultsMap["metricConfig_jobViewPlotMetrics:"+c.Name] = c.JobViewPlotMetrics
		}
		if c.JobViewTableMetrics != nil {
			UIDefaultsMap["metricConfig_jobViewTableMetrics:"+c.Name] = c.JobViewTableMetrics
		}

		for _, sc := range c.SubClusters {
			suffix := strings.Join([]string{c.Name, sc.Name}, ":")
			if sc.JobListMetrics != nil {
				UIDefaultsMap["metricConfig_jobListMetrics:"+suffix] = sc.JobListMetrics
			}
			if sc.JobViewPlotMetrics != nil {
				UIDefaultsMap["metricConfig_jobViewPlotMetrics:"+suffix] = sc.JobViewPlotMetrics
			}
			if sc.JobViewTableMetrics != nil {
				UIDefaultsMap["metricConfig_jobViewTableMetrics:"+suffix] = sc.JobViewTableMetrics
			}
		}
	}

	return err
}

// / 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)).
//
//go:embed frontend/public/*
var frontendFiles embed.FS

func ServeFiles() http.Handler {
	publicFiles, err := fs.Sub(frontendFiles, "frontend/public")
	if err != nil {
		cclog.Abortf("Serve Files: Could not find 'frontend/public' file directory.\nError: %s\n", err.Error())
	}
	return http.FileServer(http.FS(publicFiles))
}

// StaticFileExists checks whether a static file exists in the embedded frontend FS.
func StaticFileExists(path string) bool {
	path = strings.TrimPrefix(path, "/")
	if path == "" {
		return false
	}
	_, err := fs.Stat(frontendFiles, "frontend/public/"+path)
	return err == nil
}

//go:embed templates/*
var templateFiles embed.FS

var templates map[string]*template.Template = map[string]*template.Template{}

func init() {
	base := template.Must(template.ParseFS(templateFiles, "templates/base.tmpl"))
	if err := fs.WalkDir(templateFiles, "templates", func(path string, d fs.DirEntry, err error) error {
		if err != nil {
			return err
		}
		if d.IsDir() || path == "templates/base.tmpl" {
			return nil
		}

		if path == "templates/404.tmpl" {
			templates[strings.TrimPrefix(path, "templates/")] = template.Must(template.ParseFS(templateFiles, path))
			return nil
		}
		if path == "templates/login.tmpl" {
			if util.CheckFileExists("./var/login.tmpl") {
				cclog.Info("overwrite login.tmpl with local file")
				templates[strings.TrimPrefix(path, "templates/")] = template.Must(template.Must(base.Clone()).ParseFiles("./var/login.tmpl"))
				return nil
			}
		}
		if path == "templates/imprint.tmpl" {
			if util.CheckFileExists("./var/imprint.tmpl") {
				cclog.Info("overwrite imprint.tmpl with local file")
				templates[strings.TrimPrefix(path, "templates/")] = template.Must(template.Must(base.Clone()).ParseFiles("./var/imprint.tmpl"))
				return nil
			}
		}
		if path == "templates/privacy.tmpl" {
			if util.CheckFileExists("./var/privacy.tmpl") {
				cclog.Info("overwrite privacy.tmpl with local file")
				templates[strings.TrimPrefix(path, "templates/")] = template.Must(template.Must(base.Clone()).ParseFiles("./var/privacy.tmpl"))
				return nil
			}
		}

		templates[strings.TrimPrefix(path, "templates/")] = template.Must(template.Must(base.Clone()).ParseFS(templateFiles, path))
		return nil
	}); err != nil {
		cclog.Abortf("Web init(): Could not find frontend template files.\nError: %s\n", err.Error())
	}

	_ = base
}

type Build struct {
	Version   string
	Hash      string
	Buildtime string
}

type Page struct {
	Title         string                 // Page title
	MsgType       string                 // For generic use in message boxes
	Message       string                 // For generic use in message boxes
	User          schema.User            // Information about the currently logged in user (Full User Info)
	Roles         map[string]schema.Role // Available roles for frontend render checks
	Build         Build                  // Latest information about the application
	Clusters      []string               // List of all cluster names
	SubClusters   map[string][]string    // Map per cluster of all subClusters for use in the Header
	FilterPresets map[string]any         // For pages with the Filter component, this can be used to set initial filters.
	Infos         map[string]any         // For generic use (e.g. username for /monitoring/user/<id>, job id for /monitoring/job/<id>)
	Config        map[string]any         // UI settings for the currently logged in user (e.g. line width, ...)
	Resampling    *config.ResampleConfig // If not nil, defines resampling trigger and resolutions
	Redirect      string                 // The originally requested URL, for intermediate login handling
}

func RenderTemplate(rw http.ResponseWriter, file string, page *Page) {
	t, ok := templates[file]
	if !ok {
		cclog.Errorf("WEB/WEB > template '%s' not found", file)
	}

	if page.Clusters == nil {
		page.Clusters = make([]string, 0)
	}

	if page.SubClusters == nil {
		page.SubClusters = make(map[string][]string)
		for _, cluster := range archive.Clusters {
			page.Clusters = append(page.Clusters, cluster.Name)

			for _, sc := range cluster.SubClusters {
				page.SubClusters[cluster.Name] = append(page.SubClusters[cluster.Name], sc.Name)
			}
		}
	}

	cclog.Debugf("Page config : %v\n", page.Config)
	if err := t.Execute(rw, page); err != nil {
		cclog.Errorf("Template error: %s", err.Error())
	}
}

+  UIDefaultsMap["plotConfiguration_resampleAlgo"] = UIDefaults.PlotConfiguration.ResampleAlgo

+  UIDefaultsMap["plotConfiguration_resamplePolicy"] = UIDefaults.PlotConfiguration.ResamplePolicy


Now update the resolvers. First, let me check what imports are at the top of `schema.resolvers.go`.


     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"
    21→	"github.com/ClusterCockpit/cc-backend/internal/graph/model"
    22→	"github.com/ClusterCockpit/cc-backend/internal/metricdispatch"
    23→	"github.com/ClusterCockpit/cc-backend/internal/repository"
    24→	"github.com/ClusterCockpit/cc-backend/pkg/archive"
    25→	cclog "github.com/ClusterCockpit/cc-lib/v2/ccLogger"
    26→	ccunit "github.com/ClusterCockpit/cc-lib/v2/ccUnits"
    27→	"github.com/ClusterCockpit/cc-lib/v2/schema"
    28→)
    29→
    30→// Partitions is the resolver for the partitions field.

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.


Good, `metricdispatch` and `repository` are already imported. Now update the `JobMetrics` resolver.


// JobMetrics is the resolver for the jobMetrics field.
func (r *queryResolver) JobMetrics(ctx context.Context, id string, metrics []string, scopes []schema.MetricScope, resolution *int, resampleAlgo *model.ResampleAlgo) ([]*model.JobMetricWithName, error) {
	if resolution == nil { // Load from Config
		if config.Keys.EnableResampling != nil {
			defaultRes := slices.Max(config.Keys.EnableResampling.Resolutions)
			resolution = &defaultRes
		} else { // Set 0 (Loads configured metric timestep)
			defaultRes := 0
			resolution = &defaultRes
		}
	}

	job, err := r.Query().Job(ctx, id)
	if err != nil {
		cclog.Warn("Error while querying job for metrics")
		return nil, err
	}

	algoName := ""
	if resampleAlgo != nil {
		algoName = strings.ToLower(resampleAlgo.String())
	}

// 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, 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)


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

// 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, 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)

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) {
	if resolution == nil { // Load from Config
		if config.Keys.EnableResampling != nil {
			defaultRes := slices.Max(config.Keys.EnableResampling.Resolutions)
			resolution = &defaultRes
		} else { // Set 0 (Loads configured metric timestep)
			defaultRes := 0
			resolution = &defaultRes
		}
	}

	job, err := r.Query().Job(ctx, id)
	if err != nil {
		cclog.Warn("Error while querying job for metrics")
		return nil, err
	}

	algoName := ""
	if resampleAlgo != nil {
		algoName = strings.ToLower(resampleAlgo.String())
	}

	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) {
	if resolution == nil { // Load from Config
		if config.Keys.EnableResampling != nil {
			defaultRes := slices.Max(config.Keys.EnableResampling.Resolutions)
			resolution = &defaultRes
		} else { // Set 0 (Loads configured metric timestep)
			defaultRes := 0
			resolution = &defaultRes
		}
	}

	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 := ""
	if resampleAlgo != nil {
		algoName = strings.ToLower(resampleAlgo.String())
	}

	// 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 }

-  if resolution == nil { // Load from Config

+    cclog.Warn("Error while querying job for metrics")

+  // Resolve resolution: explicit param > user policy > global config > 0

+    resolution = resolveResolutionFromPolicy(ctx, job.Duration, job.Cluster, metrics)

-    } else { // Set 0 (Loads configured metric timestep)

-    cclog.Warn("Error while querying job for metrics")

+  algoName := resolveResampleAlgo(ctx, resampleAlgo)

-    algoName = strings.ToLower(resampleAlgo.String())


Now update the `NodeMetricsList` resolver.


// NodeMetricsList is the resolver for the nodeMetricsList field.
func (r *queryResolver) NodeMetricsList(ctx context.Context, cluster string, subCluster string, stateFilter string, nodeFilter string, scopes []schema.MetricScope, metrics []string, from time.Time, to time.Time, page *model.PageRequest, resolution *int, resampleAlgo *model.ResampleAlgo) (*model.NodesResultList, error) {
	if resolution == nil { // Load from Config
		if config.Keys.EnableResampling != nil {
			defaultRes := slices.Max(config.Keys.EnableResampling.Resolutions)
			resolution = &defaultRes
		} else { // Set 0 (Loads configured metric timestep)
			defaultRes := 0
			resolution = &defaultRes
		}
	}

	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 := ""
	if resampleAlgo != nil {
		algoName = strings.ToLower(resampleAlgo.String())
	}

// 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)