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

	user := repository.GetUserFromContext(ctx)

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

+      resolution = resolveResolutionFromDefaultPolicy(duration, cluster, metrics)


I see line 889 still references the old code — there must be a second occurrence. Let me check.


No matches found


Good, the LSP was stale. Now I need to add `resolveResolutionFromDefaultPolicy` to `resample.go` and update `resolveResampleAlgo` to fall back to config default.


Edit

import (
	"context"
	"strings"

	"github.com/ClusterCockpit/cc-backend/internal/graph/model"
	"github.com/ClusterCockpit/cc-backend/internal/metricdispatch"
	"github.com/ClusterCockpit/cc-backend/internal/repository"
	"github.com/ClusterCockpit/cc-backend/pkg/archive"
)

import (
	"context"
	"strings"

	"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/pkg/archive"
)


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

import (
	"context"
	"strings"

	"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/pkg/archive"
)

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

import (
	"context"
	"strings"

	"github.com/ClusterCockpit/cc-backend/internal/graph/model"
	"github.com/ClusterCockpit/cc-backend/internal/metricdispatch"
	"github.com/ClusterCockpit/cc-backend/internal/repository"
	"github.com/ClusterCockpit/cc-backend/pkg/archive"
)

// resolveResolutionFromPolicy reads the user's resample policy preference and
// computes a resolution based on job duration and metric frequency. Returns nil
// if the user has no policy set.
func resolveResolutionFromPolicy(ctx context.Context, duration int64, cluster string, metrics []string) *int {
	user := repository.GetUserFromContext(ctx)
	if user == nil {
		return nil
	}

	conf, err := repository.GetUserCfgRepo().GetUIConfig(user)
	if err != nil {
		return nil
	}

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

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

	// Find the smallest metric frequency across the requested metrics
	frequency := smallestFrequency(cluster, metrics)
	if frequency <= 0 {
		return nil
	}

	res := metricdispatch.ComputeResolution(duration, int64(frequency), targetPoints)
	return &res
}

// resolveResampleAlgo returns the resampling algorithm name to use, checking
// the explicit GraphQL parameter first, then the user's preference.
func resolveResampleAlgo(ctx context.Context, resampleAlgo *model.ResampleAlgo) string {
	if resampleAlgo != nil {
		return strings.ToLower(resampleAlgo.String())
	}

	user := repository.GetUserFromContext(ctx)
	if user == nil {
		return ""
	}

	conf, err := repository.GetUserCfgRepo().GetUIConfig(user)
	if err != nil {
		return ""
	}

	algoVal, ok := conf["plotConfiguration_resampleAlgo"]
	if !ok {
		return ""
	}
	algoStr, ok := algoVal.(string)
	if !ok {
		return ""
	}

	return algoStr
}

// smallestFrequency returns the smallest metric timestep (in seconds) among the
// requested metrics for the given cluster. Falls back to 0 if nothing is found.
func smallestFrequency(cluster string, metrics []string) int {
	cl := archive.GetCluster(cluster)
	if cl == nil {
		return 0
	}

	minFreq := 0
	for _, mc := range cl.MetricConfig {
		if len(metrics) > 0 {
			found := false
			for _, m := range metrics {
				if mc.Name == m {
					found = true
					break
				}
			}
			if !found {
				continue
			}
		}
		if minFreq == 0 || mc.Timestep < minFreq {
			minFreq = mc.Timestep
		}
	}

	return minFreq
}


	// Fall back to global default algo
	if config.Keys.EnableResampling != nil && config.Keys.EnableResampling.DefaultAlgo != "" {
		return config.Keys.EnableResampling.DefaultAlgo
	}

	return algoStr
}

// resolveResolutionFromDefaultPolicy computes a resolution using the global
// default policy from config. Returns nil if no policy is configured.
func resolveResolutionFromDefaultPolicy(duration int64, cluster string, metrics []string) *int {
	cfg := config.Keys.EnableResampling
	if cfg == nil {
		return nil
	}

	policyStr := cfg.DefaultPolicy
	if policyStr == "" {
		policyStr = "medium"
	}

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

	frequency := smallestFrequency(cluster, metrics)
	if frequency <= 0 {
		return nil
	}

	res := metricdispatch.ComputeResolution(duration, int64(frequency), targetPoints)
	return &res
}

// smallestFrequency


	// Fall back to global default algo
	if config.Keys.EnableResampling != nil && config.Keys.EnableResampling.DefaultAlgo != "" {
		return config.Keys.EnableResampling.DefaultAlgo
	}

	return algoStr
}

// resolveResolutionFromDefaultPolicy computes a resolution using the global
// default policy from config. Returns nil if no policy is configured.
func resolveResolutionFromDefaultPolicy(duration int64, cluster string, metrics []string) *int {
	cfg := config.Keys.EnableResampling
	if cfg == nil {
		return nil
	}

	policyStr := cfg.DefaultPolicy
	if policyStr == "" {
		policyStr = "medium"
	}

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

	frequency := smallestFrequency(cluster, metrics)
	if frequency <= 0 {
		return nil
	}

	res := metricdispatch.ComputeResolution(duration, int64(frequency), targetPoints)
	return &res
}

// smallestFrequency

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

import (
	"context"
	"strings"

	"github.com/ClusterCockpit/cc-backend/internal/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/pkg/archive"
)

// resolveResolutionFromPolicy reads the user's resample policy preference and
// computes a resolution based on job duration and metric frequency. Returns nil
// if the user has no policy set.
func resolveResolutionFromPolicy(ctx context.Context, duration int64, cluster string, metrics []string) *int {
	user := repository.GetUserFromContext(ctx)
	if user == nil {
		return nil
	}

	conf, err := repository.GetUserCfgRepo().GetUIConfig(user)
	if err != nil {
		return nil
	}

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

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

	// Find the smallest metric frequency across the requested metrics
	frequency := smallestFrequency(cluster, metrics)
	if frequency <= 0 {
		return nil
	}

	res := metricdispatch.ComputeResolution(duration, int64(frequency), targetPoints)
	return &res
}

// resolveResampleAlgo returns the resampling algorithm name to use, checking
// the explicit GraphQL parameter first, then the user's preference.
func resolveResampleAlgo(ctx context.Context, resampleAlgo *model.ResampleAlgo) string {
	if resampleAlgo != nil {
		return strings.ToLower(resampleAlgo.String())
	}

	user := repository.GetUserFromContext(ctx)
	if user == nil {
		return ""
	}

	conf, err := repository.GetUserCfgRepo().GetUIConfig(user)
	if err != nil {
		return ""
	}

	algoVal, ok := conf["plotConfiguration_resampleAlgo"]
	if !ok {
		return ""
	}
	algoStr, ok := algoVal.(string)
	if !ok {
		return ""
	}

	return algoStr
}

// smallestFrequency returns the smallest metric timestep (in seconds) among the
// requested metrics for the given cluster. Falls back to 0 if nothing is found.
func smallestFrequency(cluster string, metrics []string) int {
	cl := archive.GetCluster(cluster)
	if cl == nil {
		return 0
	}

	minFreq := 0
	for _, mc := range cl.MetricConfig {
		if len(metrics) > 0 {
			found := false
			for _, m := range metrics {
				if mc.Name == m {
					found = true
					break
				}
			}
			if !found {
				continue
			}
		}
		if minFreq == 0 || mc.Timestep < minFreq {
			minFreq = mc.Timestep
		}
	}

	return minFreq
}

+  if config.Keys.EnableResampling != nil && config.Keys.EnableResampling.DefaultAlgo != "" {

+    return config.Keys.EnableResampling.DefaultAlgo

+// resolveResolutionFromDefaultPolicy computes a resolution using the global

+// default policy from config. Returns nil if no policy is configured.

+func resolveResolutionFromDefaultPolicy(duration int64, cluster string, metrics []string) *int {

+  policy := metricdispatch.ResamplePolicy(policyStr)

+  targetPoints := metricdispatch.TargetPointsForPolicy(policy)

+  frequency := smallestFrequency(cluster, metrics)

+  res := metricdispatch.ComputeResolution(duration, int64(frequency), targetPoints)


The import error is transient — `config` is used further down but the LSP might not see it yet. Let me verify the file looks correct.


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

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

import (
	"context"
	"strings"

	"github.com/ClusterCockpit/cc-backend/internal/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/pkg/archive"
)

// resolveResolutionFromPolicy reads the user's resample policy preference and
// computes a resolution based on job duration and metric frequency. Returns nil
// if the user has no policy set.
func resolveResolutionFromPolicy(ctx context.Context, duration int64, cluster string, metrics []string) *int {
	user := repository.GetUserFromContext(ctx)
	if user == nil {
		return nil
	}

	conf, err := repository.GetUserCfgRepo().GetUIConfig(user)
	if err != nil {
		return nil
	}

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

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

	// Find the smallest metric frequency across the requested metrics
	frequency := smallestFrequency(cluster, metrics)
	if frequency <= 0 {
		return nil
	}

	res := metricdispatch.ComputeResolution(duration, int64(frequency), targetPoints)
	return &res
}

// resolveResampleAlgo returns the resampling algorithm name to use, checking
// the explicit GraphQL parameter first, then the user's preference.
func resolveResampleAlgo(ctx context.Context, resampleAlgo *model.ResampleAlgo) string {
	if resampleAlgo != nil {
		return strings.ToLower(resampleAlgo.String())
	}

	user := repository.GetUserFromContext(ctx)
	if user == nil {
		return ""
	}

	conf, err := repository.GetUserCfgRepo().GetUIConfig(user)
	if err != nil {
		return ""
	}

	algoVal, ok := conf["plotConfiguration_resampleAlgo"]
	if !ok {
		return ""
	}
	algoStr, ok := algoVal.(string)
	if !ok {
		return ""
	}

	// Fall back to global default algo
	if config.Keys.EnableResampling != nil && config.Keys.EnableResampling.DefaultAlgo != "" {
		return config.Keys.EnableResampling.DefaultAlgo
	}

	return algoStr
}

// resolveResolutionFromDefaultPolicy computes a resolution using the global
// default policy from config. Returns nil if no policy is configured.
func resolveResolutionFromDefaultPolicy(duration int64, cluster string, metrics []string) *int {
	cfg := config.Keys.EnableResampling
	if cfg == nil {
		return nil
	}

	policyStr := cfg.DefaultPolicy
	if policyStr == "" {
		policyStr = "medium"
	}

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

	frequency := smallestFrequency(cluster, metrics)
	if frequency <= 0 {
		return nil
	}

	res := metricdispatch.ComputeResolution(duration, int64(frequency), targetPoints)
	return &res
}

// smallestFrequency returns the smallest metric timestep (in seconds) among the
// requested metrics for the given cluster. Falls back to 0 if nothing is found.
func smallestFrequency(cluster string, metrics []string) int {
	cl := archive.GetCluster(cluster)
	if cl == nil {
		return 0
	}

	minFreq := 0
	for _, mc := range cl.MetricConfig {
		if len(metrics) > 0 {
			found := false
			for _, m := range metrics {
				if mc.Name == m {
					found = true
					break
				}
			}
			if !found {
				continue
			}
		}
		if minFreq == 0 || mc.Timestep < minFreq {
			minFreq = mc.Timestep
		}
	}

	return minFreq
}


The fallback logic in `resolveResampleAlgo` is wrong — I placed the config fallback before the `return algoStr`. It should only fall back when the user has no preference. Let me fix the logic:


	algoVal, ok := conf["plotConfiguration_resampleAlgo"]
	if !ok {
		return ""
	}
	algoStr, ok := algoVal.(string)
	if !ok {
		return ""
	}

	// Fall back to global default algo
	if config.Keys.EnableResampling != nil && config.Keys.EnableResampling.DefaultAlgo != "" {
		return config.Keys.EnableResampling.DefaultAlgo
	}

	return algoStr

	algoVal, ok := conf["plotConfiguration_resampleAlgo"]
	if ok {
		if algoStr, ok := algoVal.(string); ok && algoStr != "" {
			return algoStr
		}
	}

	// Fall back to global default algo
	if config.Keys.EnableResampling != nil && config.Keys.EnableResampling.DefaultAlgo != "" {
		return config.Keys.EnableResampling.DefaultAlgo
	}

	return ""


	algoVal, ok := conf["plotConfiguration_resampleAlgo"]
	if ok {
		if algoStr, ok := algoVal.(string); ok && algoStr != "" {
			return algoStr
		}
	}

	// Fall back to global default algo
	if config.Keys.EnableResampling != nil && config.Keys.EnableResampling.DefaultAlgo != "" {
		return config.Keys.EnableResampling.DefaultAlgo
	}

	return ""

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

import (
	"context"
	"strings"

	"github.com/ClusterCockpit/cc-backend/internal/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/pkg/archive"
)

// resolveResolutionFromPolicy reads the user's resample policy preference and
// computes a resolution based on job duration and metric frequency. Returns nil
// if the user has no policy set.
func resolveResolutionFromPolicy(ctx context.Context, duration int64, cluster string, metrics []string) *int {
	user := repository.GetUserFromContext(ctx)
	if user == nil {
		return nil
	}

	conf, err := repository.GetUserCfgRepo().GetUIConfig(user)
	if err != nil {
		return nil
	}

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

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

	// Find the smallest metric frequency across the requested metrics
	frequency := smallestFrequency(cluster, metrics)
	if frequency <= 0 {
		return nil
	}

	res := metricdispatch.ComputeResolution(duration, int64(frequency), targetPoints)
	return &res
}

// resolveResampleAlgo returns the resampling algorithm name to use, checking
// the explicit GraphQL parameter first, then the user's preference.
func resolveResampleAlgo(ctx context.Context, resampleAlgo *model.ResampleAlgo) string {
	if resampleAlgo != nil {
		return strings.ToLower(resampleAlgo.String())
	}

	user := repository.GetUserFromContext(ctx)
	if user == nil {
		return ""
	}

	conf, err := repository.GetUserCfgRepo().GetUIConfig(user)
	if err != nil {
		return ""
	}

	algoVal, ok := conf["plotConfiguration_resampleAlgo"]
	if !ok {
		return ""
	}
	algoStr, ok := algoVal.(string)
	if !ok {
		return ""
	}

	// Fall back to global default algo
	if config.Keys.EnableResampling != nil && config.Keys.EnableResampling.DefaultAlgo != "" {
		return config.Keys.EnableResampling.DefaultAlgo
	}

	return algoStr
}

// resolveResolutionFromDefaultPolicy computes a resolution using the global
// default policy from config. Returns nil if no policy is configured.
func resolveResolutionFromDefaultPolicy(duration int64, cluster string, metrics []string) *int {
	cfg := config.Keys.EnableResampling
	if cfg == nil {
		return nil
	}

	policyStr := cfg.DefaultPolicy
	if policyStr == "" {
		policyStr = "medium"
	}

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

	frequency := smallestFrequency(cluster, metrics)
	if frequency <= 0 {
		return nil
	}

	res := metricdispatch.ComputeResolution(duration, int64(frequency), targetPoints)
	return &res
}

// smallestFrequency returns the smallest metric timestep (in seconds) among the
// requested metrics for the given cluster. Falls back to 0 if nothing is found.
func smallestFrequency(cluster string, metrics []string) int {
	cl := archive.GetCluster(cluster)
	if cl == nil {
		return 0
	}

	minFreq := 0
	for _, mc := range cl.MetricConfig {
		if len(metrics) > 0 {
			found := false
			for _, m := range metrics {
				if mc.Name == m {
					found = true
					break
				}
			}
			if !found {
				continue
			}
		}
		if minFreq == 0 || mc.Timestep < minFreq {
			minFreq = mc.Timestep
		}
	}

	return minFreq
}

+    if algoStr, ok := algoVal.(string); ok && algoStr != "" {


Now update the config files.


  "resampling": {
    "minimum-points": 0,
    "resolutions": [600, 300, 60],
    "trigger": 300
  },

  "resampling": {
    "default-policy": "medium",
    "default-algo": "lttb"
  },


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

  "resampling": {
    "minimum-points": 0,
    "resolutions": [600, 300, 60],
    "trigger": 300
  },

  "resampling": {
    "default-policy": "medium",
    "default-algo": "lttb"
  },

}
],
"resampling": {
-    "minimum-points": 0,
-    "resolutions": [600, 300, 60],
-    "trigger": 300
+    "default-policy": "medium",
+    "default-algo": "lttb"
},
"metric-store": {
"retention-in-memory": "24h",


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

    "resampling": {
      "minimum-points": 600,
      "default-policy": "medium",
      "default-algo": "lttb"
    },


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

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

    "resampling": {
      "minimum-points": 600,
      "default-policy": "medium",
      "default-algo": "lttb"
    },


Now let me verify it compiles.


Bash

Build all packages


(Bash completed with no output)


Compiles cleanly. Let me run the relevant tests.


go test ./internal/metricdispatch/... ./internal/config/...

Run resample policy and config tests


ok  	github.com/ClusterCockpit/cc-backend/internal/metricdispatch	0.874s
ok  	github.com/ClusterCockpit/cc-backend/internal/config	0.536s


All tests pass. Here's a summary of the changes:

### Changes made

1. **`internal/config/config.go`** — Replaced `Resolutions []int` and `Trigger int` fields with `DefaultPolicy string` and `DefaultAlgo string` in `ResampleConfig`

2. **`internal/config/schema.go`** — Replaced `trigger`/`resolutions` schema properties with `default-policy` (enum: low/medium/high) and `default-algo` (enum: lttb/average/simple); removed `required` constraint

3. **`internal/routerConfig/routes.go`** — `resamplingForUser()` now checks user preference → global `DefaultPolicy` → "medium" fallback, always computing `TargetPoints` dynamically via `TargetPointsForPolicy()`

4. **`internal/graph/schema.resolvers.go`** — Both `JobMetrics()` and `NodeMetricsList()` now call `resolveResolutionFromDefaultPolicy()` instead of `slices.Max(Resolutions)`

5. **`internal/graph/resample.go`** — Added `resolveResolutionFromDefaultPolicy()` helper; updated `resolveResampleAlgo()` to fall back to `config.Keys.EnableResampling.DefaultAlgo` when no user preference is set

6. **`config.json`** and **`configs/config.json`** — Updated to new format with `default-policy` and `default-algo`


Also set MinimumPoints automatically based on the policy


Let me read the existing plan and current state of the code.


     1→# Replace file-based resampling config with policy-based approach
     2→
     3→## Context
     4→
     5→The codebase has two resampling mechanisms:
     6→1. **Old (file-based)**: `config.json` defines explicit `resolutions` array and `trigger` count. The frontend uses these to pick from a fixed set of resolution steps on zoom.
     7→2. **New (policy-based)**: Users choose a policy ("low"/"medium"/"high") mapping to target point counts (200/500/1000). Resolution is computed dynamically from `duration / targetPoints`. This is already fully implemented in the GraphQL resolvers and frontend MetricPlot zoom logic.
     8→
     9→The problem: `config.json` still uses the old `resolutions`+`trigger` schema, and falls back to it when no user policy is set. We should replace this with a default policy+algorithm in config, so the whole system is consistently policy-based.
    10→
    11→## Changes
    12→
    13→### 1. `internal/config/config.go` — Update `ResampleConfig` struct
    14→- Remove `Resolutions []int` and `Trigger int` fields
    15→- Add `DefaultPolicy string` (`json:"default-policy"`) — default resample policy ("low", "medium", "high")
    16→- Add `DefaultAlgo string` (`json:"default-algo"`) — default resample algorithm ("lttb", "average", "simple")
    17→- Keep `MinimumPoints int` — still useful for the resampler library
    18→- Keep `TargetPoints int` — used by policy-derived runtime config
    19→
    20→### 2. `internal/config/schema.go` — Update JSON schema
    21→- Replace `trigger`/`resolutions` required properties with `default-policy`
    22→- Add `default-policy` (string, enum: ["low","medium","high"]) and `default-algo` (string, enum: ["lttb","average","simple"]) properties
    23→- Remove `trigger` and `resolutions` properties
    24→- No required fields (presence of `"resampling": {}` is enough to enable it)
    25→
    26→### 3. `internal/routerConfig/routes.go` — Update `resamplingForUser()`
    27→- When falling back to globalCfg (no user policy set), use `globalCfg.DefaultPolicy` to compute `TargetPoints` and `Trigger` dynamically, same as the user-policy path
    28→- If `DefaultPolicy` is empty, use "medium" as default
    29→
    30→### 4. `internal/graph/schema.resolvers.go` — Update fallback resolution logic
    31→- In `JobMetrics()` and `NodeMetricsList()`: replace `slices.Max(config.Keys.EnableResampling.Resolutions)` fallback with policy-based computation using `config.Keys.EnableResampling.DefaultPolicy`
    32→- Use `TargetPointsForPolicy()` + `ComputeResolution()` instead of picking from a resolutions array
    33→- Also apply `DefaultAlgo` when no user algo preference is set
    34→
    35→### 5. `internal/graph/resample.go` — Update `resolveResampleAlgo()`
    36→- After checking user preference, fall back to `config.Keys.EnableResampling.DefaultAlgo` before returning ""
    37→
    38→### 6. `config.json` (local dev) — Update to new format
    39→```json
    40→"resampling": {
    41→  "default-policy": "medium",
    42→  "default-algo": "lttb"
    43→}
    44→```
    45→
    46→### 7. `configs/config.json` (example config) — Update to new format
    47→Same as above, keeping `minimum-points` if desired.
    48→
    49→### 8. Frontend: No changes needed
    50→The frontend already handles both modes:
    51→- Policy-based: uses `targetPoints` to compute resolution dynamically
    52→- Array-based: uses `resolutions` array for fixed steps
    53→
    54→Since `resamplingForUser()` already returns a `ResampleConfig` with `TargetPoints`+`Trigger` (no `Resolutions`), the frontend will use the policy path.
    55→
    56→## Files to modify
    57→1. `internal/config/config.go` (struct + Init)
    58→2. `internal/config/schema.go` (JSON schema)
    59→3. `internal/routerConfig/routes.go` (`resamplingForUser`)
    60→4. `internal/graph/schema.resolvers.go` (resolver fallbacks)
    61→5. `internal/graph/resample.go` (`resolveResampleAlgo` fallback)
    62→6. `config.json` (local config)
    63→7. `configs/config.json` (example config)
    64→
    65→## Verification
    66→1. `go build ./...` — must compile
    67→2. `go test ./internal/metricdispatch/...` — resample policy tests pass
    68→3. `go test ./internal/config/...` — if config tests exist
    69→4. Start with `./cc-backend -server -dev` and verify `/config` page shows resampling options
    70→5. Verify metric plots on job pages still support zoom/resampling
    71→

# Replace file-based resampling config with policy-based approach

## Context

The codebase has two resampling mechanisms:
1. **Old (file-based)**: `config.json` defines explicit `resolutions` array and `trigger` count. The frontend uses these to pick from a fixed set of resolution steps on zoom.
2. **New (policy-based)**: Users choose a policy ("low"/"medium"/"high") mapping to target point counts (200/500/1000). Resolution is computed dynamically from `duration / targetPoints`. This is already fully implemented in the GraphQL resolvers and frontend MetricPlot zoom logic.

The problem: `config.json` still uses the old `resolutions`+`trigger` schema, and falls back to it when no user policy is set. We should replace this with a default policy+algorithm in config, so the whole system is consistently policy-based.

## Changes

### 1. `internal/config/config.go` — Update `ResampleConfig` struct
- Remove `Resolutions []int` and `Trigger int` fields
- Add `DefaultPolicy string` (`json:"default-policy"`) — default resample policy ("low", "medium", "high")
- Add `DefaultAlgo string` (`json:"default-algo"`) — default resample algorithm ("lttb", "average", "simple")
- Keep `MinimumPoints int` — still useful for the resampler library
- Keep `TargetPoints int` — used by policy-derived runtime config

### 2. `internal/config/schema.go` — Update JSON schema
- Replace `trigger`/`resolutions` required properties with `default-policy`
- Add `default-policy` (string, enum: ["low","medium","high"]) and `default-algo` (string, enum: ["lttb","average","simple"]) properties
- Remove `trigger` and `resolutions` properties
- No required fields (presence of `"resampling": {}` is enough to enable it)

### 3. `internal/routerConfig/routes.go` — Update `resamplingForUser()`
- When falling back to globalCfg (no user policy set), use `globalCfg.DefaultPolicy` to compute `TargetPoints` and `Trigger` dynamically, same as the user-policy path
- If `DefaultPolicy` is empty, use "medium" as default

### 4. `internal/graph/schema.resolvers.go` — Update fallback resolution logic
- In `JobMetrics()` and `NodeMetricsList()`: replace `slices.Max(config.Keys.EnableResampling.Resolutions)` fallback with policy-based computation using `config.Keys.EnableResampling.DefaultPolicy`
- Use `TargetPointsForPolicy()` + `ComputeResolution()` instead of picking from a resolutions array
- Also apply `DefaultAlgo` when no user algo preference is set

### 5. `internal/graph/resample.go` — Update `resolveResampleAlgo()`
- After checking user preference, fall back to `config.Keys.EnableResampling.DefaultAlgo` before returning ""

### 6. `config.json` (local dev) — Update to new format
```json
"resampling": {
  "default-policy": "medium",
  "default-algo": "lttb"
}
```

### 7. `configs/config.json` (example config) — Update to new format
Same as above, keeping `minimum-points` if desired.

### 8. Frontend: No changes needed
The frontend already handles both modes:
- Policy-based: uses `targetPoints` to compute resolution dynamically
- Array-based: uses `resolutions` array for fixed steps

Since `resamplingForUser()` already returns a `ResampleConfig` with `TargetPoints`+`Trigger` (no `Resolutions`), the frontend will use the policy path.

## Files to modify
1. `internal/config/config.go` (struct + Init)
2. `internal/config/schema.go` (JSON schema)
3. `internal/routerConfig/routes.go` (`resamplingForUser`)
4. `internal/graph/schema.resolvers.go` (resolver fallbacks)
5. `internal/graph/resample.go` (`resolveResampleAlgo` fallback)
6. `config.json` (local config)
7. `configs/config.json` (example config)

## Verification
1. `go build ./...` — must compile
2. `go test ./internal/metricdispatch/...` — resample policy tests pass
3. `go test ./internal/config/...` — if config tests exist
4. Start with `./cc-backend -server -dev` and verify `/config` page shows resampling options
5. Verify metric plots on job pages still support zoom/resampling


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

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

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


Let me check how `MinimumPoints` and `SetMinimumRequiredPoints` are used, and what the current `TargetPointsForPolicy` returns.


/Users/jan/prg/ClusterCockpit/cc-backend/internal/config/config.go-107-
/Users/jan/prg/ClusterCockpit/cc-backend/internal/config/config.go-108-type ResampleConfig struct {
/Users/jan/prg/ClusterCockpit/cc-backend/internal/config/config.go-109-	// Minimum number of points to trigger resampling of data
internal/config/config.go:110:	MinimumPoints int `json:"minimum-points"`
/Users/jan/prg/ClusterCockpit/cc-backend/internal/config/config.go-111-	// Default resample policy when no user preference is set ("low", "medium", "high")
internal/config/config.go-112-	DefaultPolicy string `json:"default-policy"`
/Users/jan/prg/ClusterCockpit/cc-backend/internal/config/config.go-113-	// Default resample algorithm when no user preference is set ("lttb", "average", "simple")
--
internal/config/config.go-157-		cclog.Abortf("Config Init: Could not decode config file '%s'.\nError: %s\n", mainConfig, err.Error())
/Users/jan/prg/ClusterCockpit/cc-backend/internal/config/config.go-158-	}
/Users/jan/prg/ClusterCockpit/cc-backend/internal/config/config.go-159-
internal/config/config.go:160:	if Keys.EnableResampling != nil && Keys.EnableResampling.MinimumPoints > 0 {
internal/config/config.go:161:		resampler.SetMinimumRequiredPoints(Keys.EnableResampling.MinimumPoints)
/Users/jan/prg/ClusterCockpit/cc-backend/internal/config/config.go-162-	}
/Users/jan/prg/ClusterCockpit/cc-backend/internal/config/config.go-163-}


Found 1 file
internal/config/config.go


It's in the `resampler` library. Let me check the go module cache for it.


grep -r "SetMinimumRequiredPoints\|minimumRequiredPoints" $(go env GOMODCACHE)/github.com/ClusterCockpit/cc-lib/ 2>/dev/null | head -20

Find SetMinimumRequiredPoints in cc-lib


grep -r "SetMinimumRequiredPoints\|minimumRequiredPoints" $(go env GOMODCACHE)/github.com/ClusterCockpit/ 2>/dev/null | head -20

Find SetMinimumRequiredPoints in cc module cache


go doc github.com/ClusterCockpit/cc-lib/v2/resampler.SetMinimumRequiredPoints 2>&1; echo "---"; go doc github.com/ClusterCockpit/cc-lib/v2/resampler 2>&1 | head -30

Check resampler package docs


package resampler // import "github.com/ClusterCockpit/cc-lib/v2/resampler"

func SetMinimumRequiredPoints(setVal int)
---
package resampler // import "github.com/ClusterCockpit/cc-lib/v2/resampler"

Package resampler provides time-series data downsampling algorithms.

This package implements two downsampling strategies for reducing the number of
data points in time-series data while preserving important characteristics:

  - SimpleResampler: A fast, straightforward algorithm that selects every nth
    point
  - LargestTriangleThreeBucket (LTTB): A perceptually-aware algorithm that
    preserves visual characteristics by selecting points that maximize the area
    of triangles formed with neighboring points

Both algorithms are designed to work with schema.Float data and handle NaN
values appropriately. They require that the new sampling frequency is a multiple
of the old frequency.

References:
  - LTTB Algorithm: https://skemman.is/bitstream/1946/15343/3/SS_MSthesis.pdf
  - Implementation adapted from: https://github.com/haoel/downsampling

var MinimumRequiredPoints int = 1000
func AverageResampler(data []schema.Float, oldFrequency int64, newFrequency int64) ([]schema.Float, int64, error)
func LargestTriangleThreeBucket(data []schema.Float, oldFrequency int64, newFrequency int64) ([]schema.Float, int64, error)
func SetMinimumRequiredPoints(setVal int)
func SimpleResampler(data []schema.Float, oldFrequency int64, newFrequency int64) ([]schema.Float, int64, error)
type ResamplerFunc func(data []schema.Float, oldFrequency int64, newFrequency int64) ([]schema.Float, int64, error)
    func GetResampler(name string) (ResamplerFunc, error)


Now I understand. `MinimumRequiredPoints` defaults to 1000 in the library. The idea is to derive it from the policy's target points instead of requiring manual config. The mapping is straightforward: policy target points (low=200, medium=500, high=1000) can directly serve as `MinimumRequiredPoints`.

This is a simple, self-contained change. Let me update the plan.


Edit

# Replace file-based resampling config with policy-based approach

## Context

The codebase has two resampling mechanisms:
1. **Old (file-based)**: `config.json` defines explicit `resolutions` array and `trigger` count. The frontend uses these to pick from a fixed set of resolution steps on zoom.
2. **New (policy-based)**: Users choose a policy ("low"/"medium"/"high") mapping to target point counts (200/500/1000). Resolution is computed dynamically from `duration / targetPoints`. This is already fully implemented in the GraphQL resolvers and frontend MetricPlot zoom logic.

The problem: `config.json` still uses the old `resolutions`+`trigger` schema, and falls back to it when no user policy is set. We should replace this with a default policy+algorithm in config, so the whole system is consistently policy-based.

## Changes

### 1. `internal/config/config.go` — Update `ResampleConfig` struct
- Remove `Resolutions []int` and `Trigger int` fields
- Add `DefaultPolicy string` (`json:"default-policy"`) — default resample policy ("low", "medium", "high")
- Add `DefaultAlgo string` (`json:"default-algo"`) — default resample algorithm ("lttb", "average", "simple")
- Keep `MinimumPoints int` — still useful for the resampler library
- Keep `TargetPoints int` — used by policy-derived runtime config

### 2. `internal/config/schema.go` — Update JSON schema
- Replace `trigger`/`resolutions` required properties with `default-policy`
- Add `default-policy` (string, enum: ["low","medium","high"]) and `default-algo` (string, enum: ["lttb","average","simple"]) properties
- Remove `trigger` and `resolutions` properties
- No required fields (presence of `"resampling": {}` is enough to enable it)

### 3. `internal/routerConfig/routes.go` — Update `resamplingForUser()`
- When falling back to globalCfg (no user policy set), use `globalCfg.DefaultPolicy` to compute `TargetPoints` and `Trigger` dynamically, same as the user-policy path
- If `DefaultPolicy` is empty, use "medium" as default

### 4. `internal/graph/schema.resolvers.go` — Update fallback resolution logic
- In `JobMetrics()` and `NodeMetricsList()`: replace `slices.Max(config.Keys.EnableResampling.Resolutions)` fallback with policy-based computation using `config.Keys.EnableResampling.DefaultPolicy`
- Use `TargetPointsForPolicy()` + `ComputeResolution()` instead of picking from a resolutions array
- Also apply `DefaultAlgo` when no user algo preference is set

### 5. `internal/graph/resample.go` — Update `resolveResampleAlgo()`
- After checking user preference, fall back to `config.Keys.EnableResampling.DefaultAlgo` before returning ""

### 6. `config.json` (local dev) — Update to new format
```json
"resampling": {
  "default-policy": "medium",
  "default-algo": "lttb"
}
```

### 7. `configs/config.json` (example config) — Update to new format
Same as above, keeping `minimum-points` if desired.

### 8. Frontend: No changes needed
The frontend already handles both modes:
- Policy-based: uses `targetPoints` to compute resolution dynamically
- Array-based: uses `resolutions` array for fixed steps

Since `resamplingForUser()` already returns a `ResampleConfig` with `TargetPoints`+`Trigger` (no `Resolutions`), the frontend will use the policy path.

## Files to modify
1. `internal/config/config.go` (struct + Init)
2. `internal/config/schema.go` (JSON schema)
3. `internal/routerConfig/routes.go` (`resamplingForUser`)
4. `internal/graph/schema.resolvers.go` (resolver fallbacks)
5. `internal/graph/resample.go` (`resolveResampleAlgo` fallback)
6. `config.json` (local config)
7. `configs/config.json` (example config)

## Verification
1. `go build ./...` — must compile
2. `go test ./internal/metricdispatch/...` — resample policy tests pass
3. `go test ./internal/config/...` — if config tests exist
4. Start with `./cc-backend -server -dev` and verify `/config` page shows resampling options
5. Verify metric plots on job pages still support zoom/resampling

# Derive MinimumPoints automatically from policy

## Context

The previous commit replaced `resolutions`/`trigger` with `default-policy`/`default-algo` in `ResampleConfig`. The `MinimumPoints` field still exists as a manual config value (`"minimum-points"` in JSON), but it's redundant — since we now always have a policy (user pref or default), the target points from that policy are the natural minimum threshold for resampling.

The resampler library (`cc-lib/v2/resampler`) has a global `MinimumRequiredPoints` (default 1000) set via `SetMinimumRequiredPoints()`. Currently this is set in `config.Init()` only when `minimum-points > 0` in config.json.

**Goal**: Remove `MinimumPoints` from config and derive it automatically from the default policy's target points (low=200, medium=500, high=1000).

## Changes

### 1. `internal/config/config.go`
- Remove `MinimumPoints int` field from `ResampleConfig`
- In `Init()`: replace the `MinimumPoints > 0` check with policy-derived logic:
  - Use `metricdispatch.TargetPointsForPolicy()` on `DefaultPolicy` (or "medium" if empty) to get the target points
  - Call `resampler.SetMinimumRequiredPoints(targetPoints)`
- This requires importing `metricdispatch` — need to check for import cycles

**Import cycle concern**: `config` is imported by almost everything. `metricdispatch` imports `config`. So `config` cannot import `metricdispatch`.

**Solution**: Inline the policy→targetPoints mapping in `config.Init()` instead of calling `metricdispatch.TargetPointsForPolicy()`. A simple switch on `DefaultPolicy`:
```go
func targetPointsForPolicy(policy string) int {
    switch policy {
    case "low":    return 200
    case "medium": return 500
    case "high":   return 1000
    default:       return 500 // medium fallback
    }
}
```

Then in `Init()`:
```go
if Keys.EnableResampling != nil {
    policy := Keys.EnableResampling.DefaultPolicy
    if policy == "" {
        policy = "medium"
    }
    resampler.SetMinimumRequiredPoints(targetPointsForPolicy(policy))
}
```

### 2. `internal/config/schema.go`
- Remove `minimum-points` property from the `resampling` JSON schema object

### 3. `config.json` / `configs/config.json`
- Remove `"minimum-points"` key from the resampling block (it was already removed from `config.json` in the previous commit; check `configs/config.json` still has it)

## Files to modify
1. `internal/config/config.go` — remove field, add helper, update `Init()`
2. `internal/config/schema.go` — remove `minimum-points` property
3. `configs/config.json` — remove `minimum-points` from resampling block

## Verification
1. `go build ./...` — must compile
2. `go test ./internal/config/...` — config tests pass
3. `go test ./internal/metricdispatch/...` — resample policy tests still pass