	"github.com/ClusterCockpit/cc-backend/internal/config"
	"github.com/ClusterCockpit/cc-backend/internal/graph/model"
	"github.com/ClusterCockpit/cc-backend/internal/metricdispatch"
	"github.com/ClusterCockpit/cc-backend/internal/repository"
	"github.com/ClusterCockpit/cc-backend/web"

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

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

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

type InfoType map[string]any

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

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

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

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

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

	i["clusters"] = stats

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

	return i
}

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

	return i
}

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

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

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

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

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

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

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

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

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

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

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

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

	i["tagmap"] = tagMap
	return i
}

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

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

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

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

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

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

			resampling := resamplingForUser(conf)

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

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

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

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

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

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

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

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

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

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

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

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


Now add the `resamplingForUser` function. Let me find the end of the file to add it.


   555→				} else {
   556→					if user.HasAnyRole([]schema.Role{schema.RoleAdmin, schema.RoleSupport, schema.RoleManager}) {
   557→						http.Redirect(rw, r, "/monitoring/users/?user=NoUserNameFound&startTime=last30d", http.StatusPermanentRedirect)
   558→					} else {
   559→						web.RenderTemplate(rw, "message.tmpl", &web.Page{Title: "Error", MsgType: "alert-danger", Message: "Missing Access Rights", User: *user, Roles: availableRoles, Build: buildInfo})
   560→					}
   561→				}
   562→			default:
   563→				web.RenderTemplate(rw, "message.tmpl", &web.Page{Title: "Warning", MsgType: "alert-warning", Message: fmt.Sprintf("Unknown search type: %s", strings.Trim(splitSearch[0], " ")), User: *user, Roles: availableRoles, Build: buildInfo})
   564→			}
   565→		} else if len(splitSearch) == 1 {
   566→
   567→			jobid, username, project, jobname := repo.FindUserOrProjectOrJobname(user, strings.Trim(search, " "))
   568→
   569→			if jobid != "" {
   570→				http.Redirect(rw, r, "/monitoring/jobs/?jobId="+url.QueryEscape(jobid), http.StatusFound) // JobId (Match)
   571→			} else if username != "" {
   572→				http.Redirect(rw, r, "/monitoring/user/"+username, http.StatusFound) // User: Redirect to user page of first match
   573→			} else if project != "" {
   574→				http.Redirect(rw, r, "/monitoring/jobs/?projectMatch=eq&project="+url.QueryEscape(project), http.StatusFound) // projectId (equal)
   575→			} else if jobname != "" {
   576→				// Add Last 30 Days to migitate timeouts
   577→				untilTime := strconv.FormatInt(time.Now().Unix(), 10)
   578→				fromTime := strconv.FormatInt((time.Now().Unix() - int64(30*24*3600)), 10)
   579→
   580→				http.Redirect(rw, r, "/monitoring/jobs/?startTime="+fromTime+"-"+untilTime+"&jobName="+url.QueryEscape(jobname), http.StatusFound) // 30D Fitler + JobName (contains)
   581→			} else {
   582→				web.RenderTemplate(rw, "message.tmpl", &web.Page{Title: "Info", MsgType: "alert-info", Message: "Search without result", User: *user, Roles: availableRoles, Build: buildInfo})
   583→			}
   584→

				} else {
					if user.HasAnyRole([]schema.Role{schema.RoleAdmin, schema.RoleSupport, schema.RoleManager}) {
						http.Redirect(rw, r, "/monitoring/users/?user=NoUserNameFound&startTime=last30d", http.StatusPermanentRedirect)
					} else {
						web.RenderTemplate(rw, "message.tmpl", &web.Page{Title: "Error", MsgType: "alert-danger", Message: "Missing Access Rights", User: *user, Roles: availableRoles, Build: buildInfo})
					}
				}
			default:
				web.RenderTemplate(rw, "message.tmpl", &web.Page{Title: "Warning", MsgType: "alert-warning", Message: fmt.Sprintf("Unknown search type: %s", strings.Trim(splitSearch[0], " ")), User: *user, Roles: availableRoles, Build: buildInfo})
			}
		} else if len(splitSearch) == 1 {

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

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

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


Bash

Count lines in routes.go


591 /Users/jan/tmp/cc-backend/internal/routerconfig/routes.go


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

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


Edit

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

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

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

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

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

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


The file /Users/jan/tmp/cc-backend/internal/routerconfig/routes.go has been updated successfully.

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

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

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

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

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

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

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

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

type InfoType map[string]any

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

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

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

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

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

	i["clusters"] = stats

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

	return i
}

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

	return i
}

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

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

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

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

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

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

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

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

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

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

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

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

	i["tagmap"] = tagMap
	return i
}

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

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

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

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

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

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

			resampling := resamplingForUser(conf)

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

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

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

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

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

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

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

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

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

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

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

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

+// resamplingForUser returns a ResampleConfig that incorporates the user's

+// resample policy preference. If the user has a policy set, it creates a

+// policy-derived config with targetPoints and trigger. Otherwise falls back

+func resamplingForUser(conf map[string]any) *config.ResampleConfig {

+  globalCfg := config.Keys.EnableResampling

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

+  policy := metricdispatch.ResamplePolicy(policyStr)

+  targetPoints := metricdispatch.TargetPointsForPolicy(policy)

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


Now update the frontend settings UI — add the two new settings cards to `PlotRenderOptions.svelte`.


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

    49→
    50→          {#if displayMessage && message.target == "lw"}
    51→

    52→
    53→                Update: {message.msg}
    54→
    55→

    56→          {/if}
    57→        </CardTitle>
    58→        <input type="hidden" name="key" value="plotConfiguration_lineWidth" />
    59→

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

    71→            Width of the lines in the timeseries plots.
    72→

    73→

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

    97→          {#if displayMessage && message.target == "ppr"}
    98→

    99→
   100→                Update: {message.msg}
   101→
   102→

   103→          {/if}
   104→        </CardTitle>
   105→        <input type="hidden" name="key" value="plotConfiguration_plotsPerRow" />
   106→

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

   118→            How many plots to show next to each other on pages such as
   119→            /monitoring/job/, /monitoring/system/...
   120→

   121→

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

   145→          {#if displayMessage && message.target == "bg"}
   146→

   147→
   148→                Update: {message.msg}
   149→
   150→

   151→          {/if}
   152→        </CardTitle>
   153→        <input type="hidden" name="key" value="plotConfiguration_colorBackground" />
   154→

   155→

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

   163→

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

   171→

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

   191→          {#if displayMessage && message.target == "cbm"}
   192→

   193→
   194→                Update: {message.msg}
   195→
   196→

   197→          {/if}
   198→        </CardTitle>
   199→        <input type="hidden" name="key" value="plotConfiguration_colorblindMode" />
   200→

   201→

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

   209→

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

   217→

   218→        <Button color="primary" type="submit">Submit</Button>
   219→      </form>
   220→    </Card>
   221→  </Col>
   222→</Row>

<Row cols={3} class="p-2 g-2">

  <Col>
    <Card class="h-100">
      <form
        id="line-width-form"
        method="post"
        action="/frontend/configuration/"
        class="card-body"
        onsubmit={(e) => updateSetting(e, {
          selector: "#line-width-form",
          target: "lw",
        })}
      >

        <CardTitle
          style="margin-bottom: 1em; display: flex; align-items: center;"
        >

Line Width

          {#if displayMessage && message.target == "lw"}

                Update: {message.msg}

          {/if}
        </CardTitle>
        <input type="hidden" name="key" value="plotConfiguration_lineWidth" />

          Line Width
          <input
            type="number"
            class="form-control"
            id="lwvalue"
            name="value"
            aria-describedby="lineWidthHelp"
            value={config.plotConfiguration_lineWidth}
            min="1"
          />

            Width of the lines in the timeseries plots.

        <Button color="primary" type="submit">Submit</Button>
      </form>
    </Card>
  </Col>

  <Col>
    <Card class="h-100">
      <form
        id="plots-per-row-form"
        method="post"
        action="/frontend/configuration/"
        class="card-body"
        onsubmit={(e) => updateSetting(e, {
          selector: "#plots-per-row-form",
          target: "ppr",
        })}
      >

        <CardTitle
          style="margin-bottom: 1em; display: flex; align-items: center;"
        >

Plots per Row

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

                Update: {message.msg}

          {/if}
        </CardTitle>
        <input type="hidden" name="key" value="plotConfiguration_plotsPerRow" />

          Plots per Row
          <input
            type="number"
            class="form-control"
            id="pprvalue"
            name="value"
            aria-describedby="plotsperrowHelp"
            value={config.plotConfiguration_plotsPerRow}
            min="1"
          />

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

        <Button color="primary" type="submit">Submit</Button>
      </form>
    </Card>
  </Col>

  <Col class="d-flex justify-content-between">
    <Card class="h-100" style="width: 49%;">
      <form
        id="backgrounds-form"
        method="post"
        action="/frontend/configuration/"
        class="card-body"
        onsubmit={(e) => updateSetting(e, {
          selector: "#backgrounds-form",
          target: "bg",
        })}
      >

        <CardTitle
          style="margin-bottom: 1em; display: flex; align-items: center;"
        >

Colored Backgrounds

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

                Update: {message.msg}

          {/if}
        </CardTitle>
        <input type="hidden" name="key" value="plotConfiguration_colorBackground" />

            {#if config.plotConfiguration_colorBackground}
              <input type="radio" id="colb-true-checked" name="value" value="true" checked />
            {:else}
              <input type="radio" id="colb-true" name="value" value="true" />
            {/if}
            Yes

            {#if config.plotConfiguration_colorBackground}
              <input type="radio" id="colb-false" name="value" value="false" />
            {:else}
              <input type="radio" id="colb-false-checked" name="value" value="false" checked />
            {/if}
            No

        <Button color="primary" type="submit">Submit</Button>
      </form>
    </Card>
    <Card class="h-100" style="width: 49%;">
      <form
        id="colorblindmode-form"
        method="post"
        action="/frontend/configuration/"
        class="card-body"
        onsubmit={(e) => updateSetting(e, {
          selector: "#colorblindmode-form",
          target: "cbm",
        })}
      >

        <CardTitle
          style="margin-bottom: 1em; display: flex; align-items: center;"
        >

Color Blind Mode

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

                Update: {message.msg}

          {/if}
        </CardTitle>
        <input type="hidden" name="key" value="plotConfiguration_colorblindMode" />

            {#if config?.plotConfiguration_colorblindMode}
              <input type="radio" id="cbm-true-checked" name="value" value="true" checked />
            {:else}
              <input type="radio" id="cbm-true" name="value" value="true" />
            {/if}
            Yes

            {#if config?.plotConfiguration_colorblindMode}
              <input type="radio" id="cbm-false" name="value" value="false" />
            {:else}
              <input type="radio" id="cbm-false-checked" name="value" value="false" checked />
            {/if}
            No

        <Button color="primary" type="submit">Submit</Button>
      </form>
    </Card>
  </Col>
</Row>


  import {
    Button,
    Row,
    Col,
    Card,
    CardTitle,
  } from "@sveltestrap/sveltestrap";
  import { fade } from "svelte/transition";

  /* Svelte 5 Props */
  let {
    config,
    message = $bindable(),
    displayMessage = $bindable(),
    updateSetting
  } = $props();

  import {
    Button,
    Row,
    Col,
    Card,
    CardTitle,
  } from "@sveltestrap/sveltestrap";
  import { getContext } from "svelte";
  import { fade } from "svelte/transition";

  /* Svelte 5 Props */
  let {
    config,
    message = $bindable(),
    displayMessage = $bindable(),
    updateSetting
  } = $props();

  const resampleConfig = getContext("resampling");
  const resamplingEnabled = !!resampleConfig;


The file /Users/jan/tmp/cc-backend/web/frontend/src/config/user/PlotRenderOptions.svelte has been updated successfully.

  import {
    Button,
    Row,
    Col,
    Card,
    CardTitle,
  } from "@sveltestrap/sveltestrap";
  import { fade } from "svelte/transition";

  /* Svelte 5 Props */
  let {
    config,
    message = $bindable(),
    displayMessage = $bindable(),
    updateSetting
  } = $props();

  import {
    Button,
    Row,
    Col,
    Card,
    CardTitle,
  } from "@sveltestrap/sveltestrap";
  import { getContext } from "svelte";
  import { fade } from "svelte/transition";

  /* Svelte 5 Props */
  let {
    config,
    message = $bindable(),
    displayMessage = $bindable(),
    updateSetting
  } = $props();

  const resampleConfig = getContext("resampling");
  const resamplingEnabled = !!resampleConfig;

<Row cols={3} class="p-2 g-2">

  <Col>
    <Card class="h-100">
      <form
        id="line-width-form"
        method="post"
        action="/frontend/configuration/"
        class="card-body"
        onsubmit={(e) => updateSetting(e, {
          selector: "#line-width-form",
          target: "lw",
        })}
      >

        <CardTitle
          style="margin-bottom: 1em; display: flex; align-items: center;"
        >

Line Width

          {#if displayMessage && message.target == "lw"}

                Update: {message.msg}

          {/if}
        </CardTitle>
        <input type="hidden" name="key" value="plotConfiguration_lineWidth" />

          Line Width
          <input
            type="number"
            class="form-control"
            id="lwvalue"
            name="value"
            aria-describedby="lineWidthHelp"
            value={config.plotConfiguration_lineWidth}
            min="1"
          />

            Width of the lines in the timeseries plots.

        <Button color="primary" type="submit">Submit</Button>
      </form>
    </Card>
  </Col>

  <Col>
    <Card class="h-100">
      <form
        id="plots-per-row-form"
        method="post"
        action="/frontend/configuration/"
        class="card-body"
        onsubmit={(e) => updateSetting(e, {
          selector: "#plots-per-row-form",
          target: "ppr",
        })}
      >

        <CardTitle
          style="margin-bottom: 1em; display: flex; align-items: center;"
        >

Plots per Row

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

                Update: {message.msg}

          {/if}
        </CardTitle>
        <input type="hidden" name="key" value="plotConfiguration_plotsPerRow" />

          Plots per Row
          <input
            type="number"
            class="form-control"
            id="pprvalue"
            name="value"
            aria-describedby="plotsperrowHelp"
            value={config.plotConfiguration_plotsPerRow}
            min="1"
          />

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

        <Button color="primary" type="submit">Submit</Button>
      </form>
    </Card>
  </Col>

  <Col class="d-flex justify-content-between">
    <Card class="h-100" style="width: 49%;">
      <form
        id="backgrounds-form"
        method="post"
        action="/frontend/configuration/"
        class="card-body"
        onsubmit={(e) => updateSetting(e, {
          selector: "#backgrounds-form",
          target: "bg",
        })}
      >

        <CardTitle
          style="margin-bottom: 1em; display: flex; align-items: center;"
        >

Colored Backgrounds

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

                Update: {message.msg}

          {/if}
        </CardTitle>
        <input type="hidden" name="key" value="plotConfiguration_colorBackground" />

            {#if config.plotConfiguration_colorBackground}
              <input type="radio" id="colb-true-checked" name="value" value="true" checked />
            {:else}
              <input type="radio" id="colb-true" name="value" value="true" />
            {/if}
            Yes

            {#if config.plotConfiguration_colorBackground}
              <input type="radio" id="colb-false" name="value" value="false" />
            {:else}
              <input type="radio" id="colb-false-checked" name="value" value="false" checked />
            {/if}
            No

        <Button color="primary" type="submit">Submit</Button>
      </form>
    </Card>
    <Card class="h-100" style="width: 49%;">
      <form
        id="colorblindmode-form"
        method="post"
        action="/frontend/configuration/"
        class="card-body"
        onsubmit={(e) => updateSetting(e, {
          selector: "#colorblindmode-form",
          target: "cbm",
        })}
      >

        <CardTitle
          style="margin-bottom: 1em; display: flex; align-items: center;"
        >

Color Blind Mode

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

                Update: {message.msg}

          {/if}
        </CardTitle>
        <input type="hidden" name="key" value="plotConfiguration_colorblindMode" />

            {#if config?.plotConfiguration_colorblindMode}
              <input type="radio" id="cbm-true-checked" name="value" value="true" checked />
            {:else}
              <input type="radio" id="cbm-true" name="value" value="true" />
            {/if}
            Yes

            {#if config?.plotConfiguration_colorblindMode}
              <input type="radio" id="cbm-false" name="value" value="false" />
            {:else}
              <input type="radio" id="cbm-false-checked" name="value" value="false" checked />
            {/if}
            No

        <Button color="primary" type="submit">Submit</Button>
      </form>
    </Card>
  </Col>
</Row>

+  const resampleConfig = getContext("resampling");

+  const resamplingEnabled = !!resampleConfig;


Now add the two new cards before the closing `</Row>`.


</Col>

  {#if resamplingEnabled}

  <Col>
    <Card class="h-100">
      <form
        id="resample-policy-form"
        method="post"
        action="/frontend/configuration/"
        class="card-body"
        onsubmit={(e) => updateSetting(e, {
          selector: "#resample-policy-form",
          target: "rsp",
        })}
      >
        <CardTitle
          style="margin-bottom: 1em; display: flex; align-items: center;"
        >

Resample Policy

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

                Update: {message.msg}

          {/if}
        </CardTitle>
        <input type="hidden" name="key" value="plotConfiguration_resamplePolicy" />

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

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

          {/each}

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

        <Button color="primary" type="submit">Submit</Button>
      </form>
    </Card>
  </Col>

  <Col>
    <Card class="h-100">
      <form
        id="resample-algo-form"
        method="post"
        action="/frontend/configuration/"
        class="card-body"
        onsubmit={(e) => updateSetting(e, {
          selector: "#resample-algo-form",
          target: "rsa",
        })}
      >
        <CardTitle
          style="margin-bottom: 1em; display: flex; align-items: center;"
        >

Resample Algorithm

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

                Update: {message.msg}

          {/if}
        </CardTitle>
        <input type="hidden" name="key" value="plotConfiguration_resampleAlgo" />

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

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

          {/each}

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

        <Button color="primary" type="submit">Submit</Button>
      </form>
    </Card>
  </Col>
  {/if}
</Row>


</Col>

  {#if resamplingEnabled}

  <Col>
    <Card class="h-100">
      <form
        id="resample-policy-form"
        method="post"
        action="/frontend/configuration/"
        class="card-body"
        onsubmit={(e) => updateSetting(e, {
          selector: "#resample-policy-form",
          target: "rsp",
        })}
      >
        <CardTitle
          style="margin-bottom: 1em; display: flex; align-items: center;"
        >

Resample Policy

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

                Update: {message.msg}

          {/if}
        </CardTitle>
        <input type="hidden" name="key" value="plotConfiguration_resamplePolicy" />

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

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

          {/each}

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

        <Button color="primary" type="submit">Submit</Button>
      </form>
    </Card>
  </Col>

  <Col>
    <Card class="h-100">
      <form
        id="resample-algo-form"
        method="post"
        action="/frontend/configuration/"
        class="card-body"
        onsubmit={(e) => updateSetting(e, {
          selector: "#resample-algo-form",
          target: "rsa",
        })}
      >
        <CardTitle
          style="margin-bottom: 1em; display: flex; align-items: center;"
        >

Resample Algorithm

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

                Update: {message.msg}

          {/if}
        </CardTitle>
        <input type="hidden" name="key" value="plotConfiguration_resampleAlgo" />

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

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

          {/each}

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

        <Button color="primary" type="submit">Submit</Button>
      </form>
    </Card>
  </Col>
  {/if}
</Row>

+        action="/frontend/configuration/"

+        onsubmit={(e) => updateSetting(e, {

+          selector: "#resample-policy-form",

+          style="margin-bottom: 1em; display: flex; align-items: center;"

+          {#if displayMessage && message.target == "rsp"}

+            <div style="margin-left: auto; font-size: 0.9em;">

+              <code style="color: {message.color};" out:fade>

+        <input type="hidden" name="key" value="plotConfiguration_resamplePolicy" />

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

+              <input type="radio" id="rsp-{val || 'default'}" name="value" value={JSON.stringify(val)}

+                checked={(!config.plotConfiguration_resamplePolicy && val === "") || config.plotConfiguration_resamplePolicy === val} />

+              <label for="rsp-{val || 'default'}">{label}</label>

+          <div id="resamplePolicyHelp" class="form-text">

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

+        <Button color="primary" type="submit">Submit</Button>

+          selector: "#resample-algo-form",

+          {#if displayMessage && message.target == "rsa"}

+        <input type="hidden" name="key" value="plotConfiguration_resampleAlgo" />

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

+              <input type="radio" id="rsa-{val || 'default'}" name="value" value={JSON.stringify(val)}

+                checked={(!config.plotConfiguration_resampleAlgo && val === "") || config.plotConfiguration_resampleAlgo === val} />

+              <label for="rsa-{val || 'default'}">{label}</label>

+          <div id="resampleAlgoHelp" class="form-text">

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


Now let's build and test.


Bash

Build the Go backend


(Bash completed with no output)


Backend compiles. Now run the tests.


go test ./internal/metricdispatch/... -v 2>&1

Run metricdispatch tests


=== RUN   TestDeepCopy
--- PASS: TestDeepCopy (0.00s)
=== RUN   TestDeepCopyNilStatisticsSeries
--- PASS: TestDeepCopyNilStatisticsSeries (0.00s)
=== RUN   TestDeepCopyEmptyPercentiles
--- PASS: TestDeepCopyEmptyPercentiles (0.00s)
=== RUN   TestTargetPointsForPolicy
--- PASS: TestTargetPointsForPolicy (0.00s)
=== RUN   TestComputeResolution
=== RUN   TestComputeResolution/low_24h_60s
=== RUN   TestComputeResolution/medium_24h_60s
=== RUN   TestComputeResolution/high_24h_60s
=== RUN   TestComputeResolution/low_2h_60s
=== RUN   TestComputeResolution/medium_2h_60s
=== RUN   TestComputeResolution/high_2h_60s
=== RUN   TestComputeResolution/zero_duration
=== RUN   TestComputeResolution/zero_frequency
=== RUN   TestComputeResolution/zero_target
=== RUN   TestComputeResolution/negative_duration
=== RUN   TestComputeResolution/medium_12h_30s
=== RUN   TestComputeResolution/exact_fit
--- PASS: TestComputeResolution (0.00s)
    --- PASS: TestComputeResolution/low_24h_60s (0.00s)
    --- PASS: TestComputeResolution/medium_24h_60s (0.00s)
    --- PASS: TestComputeResolution/high_24h_60s (0.00s)
    --- PASS: TestComputeResolution/low_2h_60s (0.00s)
    --- PASS: TestComputeResolution/medium_2h_60s (0.00s)
    --- PASS: TestComputeResolution/high_2h_60s (0.00s)
    --- PASS: TestComputeResolution/zero_duration (0.00s)
    --- PASS: TestComputeResolution/zero_frequency (0.00s)
    --- PASS: TestComputeResolution/zero_target (0.00s)
    --- PASS: TestComputeResolution/negative_duration (0.00s)
    --- PASS: TestComputeResolution/medium_12h_30s (0.00s)
    --- PASS: TestComputeResolution/exact_fit (0.00s)
PASS
ok  	github.com/ClusterCockpit/cc-backend/internal/metricdispatch	0.462s


All tests pass. Let me run the full test suite.


Run all Go tests


?   	github.com/ClusterCockpit/cc-backend/cmd/cc-backend	[no test files]
ok  	github.com/ClusterCockpit/cc-backend/internal/api	7.255s
?   	github.com/ClusterCockpit/cc-backend/internal/archiver	[no test files]
ok  	github.com/ClusterCockpit/cc-backend/internal/auth	0.455s
ok  	github.com/ClusterCockpit/cc-backend/internal/config	1.162s
?   	github.com/ClusterCockpit/cc-backend/internal/graph	[no test files]
?   	github.com/ClusterCockpit/cc-backend/internal/graph/generated	[no test files]
?   	github.com/ClusterCockpit/cc-backend/internal/graph/model	[no test files]
ok  	github.com/ClusterCockpit/cc-backend/internal/importer	1.558s
ok  	github.com/ClusterCockpit/cc-backend/internal/metricdispatch	1.623s
?   	github.com/ClusterCockpit/cc-backend/internal/metricstoreclient	[no test files]
ok  	github.com/ClusterCockpit/cc-backend/internal/repository	2.304s
?   	github.com/ClusterCockpit/cc-backend/internal/routerConfig	[no test files]
ok  	github.com/ClusterCockpit/cc-backend/internal/tagger	2.245s
ok  	github.com/ClusterCockpit/cc-backend/internal/taskmanager	2.449s
ok  	github.com/ClusterCockpit/cc-backend/pkg/archive	2.996s
ok  	github.com/ClusterCockpit/cc-backend/pkg/archive/parquet	2.382s
ok  	github.com/ClusterCockpit/cc-backend/pkg/metricstore	1.829s
ok  	github.com/ClusterCockpit/cc-backend/tools/archive-manager	4.020s
?   	github.com/ClusterCockpit/cc-backend/tools/archive-migration	[no test files]
?   	github.com/ClusterCockpit/cc-backend/tools/convert-pem-pubkey	[no test files]
?   	github.com/ClusterCockpit/cc-backend/tools/gen-keypair	[no test files]
ok  	github.com/ClusterCockpit/cc-backend/web	2.189s


All tests pass. Here's a summary of what was implemented:

## Changes Made

### New files
- **`internal/metricdispatch/resamplepolicy.go`** — Policy constants (`low`/`medium`/`high`) with target points (200/500/1000) and `ComputeResolution()` helper that dynamically computes resampling resolution from duration, frequency, and target points
- **`internal/metricdispatch/resamplepolicy_test.go`** — Tests covering all policies, edge cases, and the examples from the plan
- **`internal/graph/resample.go`** — Resolver helpers `resolveResolutionFromPolicy()` and `resolveResampleAlgo()` that read user preferences from the config DB

### Modified files
- **`internal/config/config.go`** — Added `TargetPoints` field to `ResampleConfig` for policy-derived configs
- **`web/web.go`** — Added `ResampleAlgo`/`ResamplePolicy` to `PlotConfiguration` struct and `UIDefaultsMap`
- **`internal/graph/schema.resolvers.go`** — `JobMetrics` and `NodeMetricsList` resolvers now check user policy/algo preferences before falling back to global config
- **`internal/routerconfig/routes.go`** — Added `resamplingForUser()` that builds a policy-derived `ResampleConfig` with `targetPoints` and `trigger` when a user has a policy set
- **`web/frontend/src/generic/plots/MetricPlot.svelte`** — Zoom handler now supports dynamic resolution computation from `targetPoints` (policy-based) alongside existing array-based logic
- **`web/frontend/src/config/user/PlotRenderOptions.svelte`** — Two new settings cards (Resample Policy and Resample Algorithm) shown only when resampling is globally enabled