Entire CLI will link this conversation to your next commit.


Analyse what resampling options are available for metric data and how they can be configured


claude claude-code-guide Explore general-purpose Plan statusline-setup

- claude: Catch-all for any task that doesn't fit a more specific agent. FleetView's default when no agent name is typed. (Tools: *)

- claude-code-guide: Use this agent when the user asks questions ("Can Claude...", "Does Claude...", "How do I...") about: (1) Claude Code (the CLI tool) - features, hooks, slash commands, MCP servers, settings, IDE integrations, keyboard shortcuts; (2) Claude Agent SDK - building custom agents; (3) Claude API (formerly Anthropic API) - API usage, tool use, Anthropic SDK usage. **IMPORTANT:** Before spawning a new agent, check if there is already a running or recently completed claude-code-guide agent that you can continue via SendMessage. (Tools: Bash, Read, WebFetch, WebSearch)

- Explore: Read-only search agent for broad fan-out searches — when answering means sweeping many files, directories, or naming conventions and you only need the conclusion, not the file dumps. It reads excerpts rather than whole files, so it locates code; it doesn't review or audit it. Specify search breadth: "medium" for moderate exploration, "very thorough" for multiple locations and naming conventions. (Tools: All tools except Agent, ExitPlanMode, Edit, Write, NotebookEdit)

- general-purpose: General-purpose agent for researching complex questions, searching for code, and executing multi-step tasks. When you are searching for a keyword or file and are not confident that you will find the right match in the first few tries use this agent to perform the search for you. (Tools: *)

- Plan: Software architect agent for designing implementation plans. Use this when you need to plan the implementation strategy for a task. Returns step-by-step plans, identifies critical files, and considers architectural trade-offs. (Tools: All tools except Agent, ExitPlanMode, Edit, Write, NotebookEdit)

- statusline-setup: Use this agent to configure the user's Claude Code status line setting. (Tools: Read, Edit)


- deep-research: Deep research harness — fan-out web searches, fetch sources, adversarially verify claims, synthesize a cited report. - When the user wants a deep, multi-source, fact-checked research report on any topic. BEFORE invoking, check if the question is specific enough to research directly — if underspecified (e.g., "what car to buy" without budget/use-case/region), ask 2-3 clarifying questions to narrow scope. Then pass the refined question as args, weaving the answers in.
- frontend-design:frontend-design: Guidance for distinctive, intentional visual design when building new UI or reshaping an existing one. Helps with aesthetic direction, typography, and making choices that don't read as templated defaults.
- update-config: Use this skill to configure the Claude Code harness via settings.json. Automated behaviors ("from now on when X", "each time X", "whenever X", "before/after X") require hooks configured in settings.json - the harness executes these, not Claude, so memory/preferences cannot fulfill them. Also use for: permissions ("allow X", "add permission", "move permission to"), env vars ("set X=Y"), hook troubleshooting, or any changes to settings.json/settings.local.json files. Examples: "allow npm commands", "add bq permission to global settings", "move permission to user settings", "set DEBUG=true", "when claude stops show X". For simple settings like theme/model, suggest the /config command.
- keybindings-help: Use when the user wants to customize keyboard shortcuts, rebind keys, add chord bindings, or modify ~/.claude/keybindings.json. Examples: "rebind ctrl+s", "add a chord shortcut", "change the submit key", "customize keybindings".
- verify: Verify that a code change actually does what it's supposed to by running the app and observing behavior. Use when asked to verify a PR, confirm a fix works, test a change manually, check that a feature works, or validate local changes before pushing.
- code-review: Review the current diff for correctness bugs and reuse/simplification/efficiency cleanups at the given effort level (low/medium: fewer, high-confidence findings; high→max: broader coverage, may include uncertain findings). Pass --comment to post findings as inline PR comments, or --fix to apply the findings to the working tree after the review.
- simplify: Review the changed code for reuse, simplification, efficiency, and altitude cleanups, then apply the fixes. Quality only — it does not hunt for bugs; use /code-review for that.
- fewer-permission-prompts: Scan your transcripts for common read-only Bash and MCP tool calls, then add a prioritized allowlist to project .claude/settings.json to reduce permission prompts.
- loop: Run a prompt or slash command on a recurring interval (e.g. /loop 5m /foo). Omit the interval to let the model self-pace. - When the user wants to set up a recurring task, poll for status, or run something repeatedly on an interval (e.g. "check the deploy every 5 minutes", "keep running /babysit-prs"). Do NOT invoke for one-off tasks.
- schedule: Create, update, list, or run scheduled cloud agents (routines) that execute on a cron schedule. - When the user wants to schedule a recurring cloud agent, set up automated tasks, create a cron job for Claude Code, or manage their scheduled agents/routines. Also use when the user wants a one-time scheduled run ("run this once at 3pm", "remind me to check X tomorrow").
- claude-api: Reference for the Claude API / Anthropic SDK — model ids, pricing, params, streaming, tool use, MCP, agents, caching, token counting, model migration.
TRIGGER — read BEFORE opening the target file; don't skip because it "looks like a one-liner" — whenever: the prompt names Claude/Anthropic in any form (Claude, Anthropic, Fable, Opus, Sonnet, Haiku, `anthropic`, `@anthropic-ai`, `claude-*`, `us.anthropic.*`, `[1m]`); the user asks about an LLM (pricing/model choice/limits/caching) — never answer from memory; OR the task is LLM-shaped with provider unstated (agent/MCP/tool-definition/multi-agent/RAG/LLM-judge/computer-use; generate/summarize/extract/classify/rewrite/converse over NL; debugging refusals/cutoffs/streaming/tool-calls/tokens).
SKIP only when another provider is being worked on (overrides all triggers): OpenAI/GPT/Gemini/Llama/Mistral/Cohere/Ollama named in the query; OR `grep -rE 'openai|langchain_openai|google.generativeai|genai|mistralai|cohere|ollama'` over the project hits (run this grep FIRST if no provider named — don't Read the file).
- run: Launch and drive this project's app to see a change working. Use when asked to run, start, or screenshot the app, or to confirm a change works in the real app (not just tests). First looks for a project skill that already covers launching the app; otherwise falls back to built-in patterns per project type (CLI, server, TUI, Electron, browser-driven, library).
- init: Initialize a new CLAUDE.md file with codebase documentation
- review: Review a pull request
- security-review: Complete a security review of the pending changes on the current branch

deep-research frontend-design:frontend-design update-config keybindings-help verify code-review simplify fewer-permission-prompts loop schedule claude-api run init review security-review


I'll explore the codebase to understand the resampling options for metric data.


Agent

Find resampling code and config

Prompt:
Search the cc-backend repository (at /Users/jan/prg/ClusterCockpit/cc-backend) for everything related to "resampling" or "resample" of metric data. I need a comprehensive analysis.

Find and report:
1. All Go source files that implement resampling logic (function names, file paths, line numbers, what each does)
2. The resampling algorithm(s) used (e.g. simple averaging, LTTB/largest-triangle-three-buckets, etc.)
3. How resampling is configured — config struct fields, config.json keys, default values. Look for a "resampling" or "resolutions" section in config schema.
4. Where resampling is triggered in the request/query flow (REST, GraphQL, metricstore query path)
5. Any frontend (web/frontend) code referencing resampling/resolution
6. Config schema files (look in internal/config or pkg/schema, JSON schema definitions)

Report concrete file paths, line numbers, code snippets, struct definitions, and config keys. Be thorough — grep for "resampl", "resolution", "Resolution", "lttb", "LTTB", "Resampl".


Now I have comprehensive information. Let me compile the final report:

## Comprehensive Resampling Analysis of cc-backend Repository

I've completed a thorough search of the cc-backend codebase for resampling and resolution-related code. Here's my detailed analysis:

### 1. Resampling Implementation Files

#### Core Resampling Logic:
- **`/Users/jan/prg/ClusterCockpit/cc-backend/internal/metricdispatch/resamplepolicy.go`** (lines 1-50)
  - `TargetPointsForPolicy(policy ResamplePolicy) int` - Maps policy strings to target data point counts
    - "low" → 200 points
    - "medium" → 500 points
    - "high" → 1000 points
  - `ComputeResolution(duration int64, frequency int64, targetPoints int) int` - Calculates resampling resolution in seconds
    - Formula: `resolution = ceil(ceil(duration / targetPoints) / frequency) * frequency`
    - Returns 0 if no resampling needed (total points ≤ targetPoints)

- **`/Users/jan/prg/ClusterCockpit/cc-backend/internal/metricdispatch/resamplepolicy_test.go`** - Unit tests with examples
  - 24h job @ 60s frequency with "low" policy → 480s resolution
  - 24h job @ 60s frequency with "medium" policy → 180s resolution
  - 24h job @ 60s frequency with "high" policy → 120s resolution

#### Resolution Resolution (Policy→Actual Resolution):
- **`/Users/jan/prg/ClusterCockpit/cc-backend/internal/graph/resample.go`** (lines 1-146)
  - `resolveResolutionFromPolicy(ctx, duration, cluster, metrics) *int` - Gets user's UI config preference and computes resolution
  - `resolveResolutionFromDefaultPolicy(duration, cluster, metrics) *int` - Falls back to global config default
  - `resolveResampleAlgo(ctx, resampleAlgo) string` - Resolves resampling algorithm name (explicit param → user preference → global default)
  - `smallestFrequency(cluster, metrics) int` - Finds smallest metric timestep among requested metrics

#### Data Loading & Resampling Application:
- **`/Users/jan/prg/ClusterCockpit/cc-backend/internal/metricdispatch/dataLoader.go`** (lines 1-450+)
  - `LoadData(job, metrics, scopes, ctx, resolution, resampleAlgo)` - Main entry point
    - For running jobs: fetches from metric store (line 100-128)
    - For archived jobs: loads from archive, applies resampling (lines 131-158)
    - Line 143: `resampleFn, rfErr := resampler.GetResampler(resampleAlgo)` - Gets resampler function from cc-lib
    - Line 151: `v_.Series[i].Data, timestep, err = resampleFn(v_.Series[i].Data, int64(v_.Timestep), int64(resolution))` - Applies resampling to each series
  - `LoadNodeListData(cluster, subCluster, nodes, metrics, scopes, resolution, from, to, ctx, resampleAlgo)` - Node metrics with resampling (lines 415-449)

- **`/Users/jan/prg/ClusterCockpit/cc-backend/pkg/metricstore/metricstore.go`** (lines 704-772)
  - `Read(selector, metric, from, to, resolution, resampleAlgo)` - Memory store query path
  - Line 762: `resampleFn, rfErr := resampler.GetResampler(resampleAlgo)` - Gets resampler
  - Line 766: `data, resolution, err = resampleFn(data, minfo.Frequency, resolution)` - Applies resampling

### 2. Resampling Algorithms

**Three algorithms supported** (defined in `/Users/jan/prg/ClusterCockpit/cc-backend/internal/graph/model/models_gen.go`, lines 331-387):

```go
const (
    ResampleAlgoLttb    ResampleAlgo = "LTTB"      // Largest Triangle Three Buckets
    ResampleAlgoAverage ResampleAlgo = "AVERAGE"   // Simple averaging
    ResampleAlgoSimple  ResampleAlgo = "SIMPLE"    // Simple downsampling
)
```

- **LTTB (default)**: Largest-Triangle-Three-Buckets - preserves visual data shape
- **AVERAGE**: Simple arithmetic averaging of data points
- **SIMPLE**: Basic downsampling

The actual algorithm implementations are in the `cc-lib/v2/resampler` package (external dependency, not in cc-backend repo). The cc-backend calls `resampler.GetResampler(algoName)` to get the function and applies it via:
```go
data, resolution, err = resampleFn(data, frequency, resolution)
```

### 3. Configuration

#### Configuration Structure (in `/Users/jan/prg/ClusterCockpit/cc-backend/internal/config/config.go`):

```go
type ResampleConfig struct {
    // 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"`
}
```

#### Config Schema (lines 91-106 in `/Users/jan/prg/ClusterCockpit/cc-backend/internal/config/schema.go`):
```json
"resampling": {
  "description": "Enable dynamic zoom in frontend metric plots.",
  "type": "object",
  "properties": {
    "default-policy": {
      "description": "Default resample policy when no user preference is set.",
      "type": "string",
      "enum": ["low", "medium", "high"]
    },
    "default-algo": {
      "description": "Default resample algorithm when no user preference is set.",
      "type": "string",
      "enum": ["lttb", "average", "simple"]
    }
  }
}
```

#### Default Config Values:
- **Policy**: "medium" (default, can be overridden)
- **Algorithm**: "lttb" (Largest Triangle Three Buckets)

See example configs:
- `/Users/jan/prg/ClusterCockpit/cc-backend/config.json` (lines 22-25)
- `/Users/jan/prg/ClusterCockpit/cc-backend/configs/config.json` (lines 16-19)
- `/Users/jan/prg/ClusterCockpit/cc-backend/cmd/cc-backend/init.go` (lines 26-28)

### 4. Request/Query Flow - Where Resampling Is Triggered

#### GraphQL Query Resolvers (`/Users/jan/prg/ClusterCockpit/cc-backend/internal/graph/schema.resolvers.go`):

**JobMetrics Query** (lines 501-542):
```go
func (r *queryResolver) JobMetrics(
    ctx context.Context,
    id string,
    metrics []string,
    scopes []schema.MetricScope,
    resolution *int,          // Optional explicit resolution parameter
    resampleAlgo *model.ResampleAlgo  // Optional algorithm override
) ([]*model.JobMetricWithName, error)
```
- Flow: explicit param → user policy → global config → 0 (no resampling)
- Line 524: `metricdispatch.LoadData(job, metrics, scopes, ctx, *resolution, algoName)`

**NodeMetricsList Query** (lines 886-960):
```go
func (r *queryResolver) NodeMetricsList(
    ctx context.Context,
    cluster, subCluster, stateFilter, nodeFilter string,
    scopes []schema.MetricScope,
    metrics []string,
    from, to time.Time,
    page *model.PageRequest,
    resolution *int,
    resampleAlgo *model.ResampleAlgo
) (*model.NodesResultList, error)
```
- Flow: explicit param → user policy → global config → 0 (no resampling)
- Line 923: `metricdispatch.LoadNodeListData(cluster, subCluster, nodes, metrics, scopes, *resolution, from, to, ctx, algoName)`

#### User Preference Storage:
- User UI config keys (from `/Users/jan/prg/ClusterCockpit/cc-backend/internal/graph/resample.go`):
  - `plotConfiguration_resamplePolicy` - User's preferred policy (low/medium/high)
  - `plotConfiguration_resampleAlgo` - User's preferred algorithm (lttb/average/simple)
- Retrieved via: `repository.GetUserFromContext(ctx)` → `GetUIConfig(user)`

#### Route Setup:
- `/Users/jan/prg/ClusterCockpit/cc-backend/internal/routerConfig/routes.go` (lines 596-627)
  - `resamplingForUser(conf)` function handles policy → targetPoints conversion for frontend

### 5. Frontend Code

No explicit resampling logic in frontend TypeScript/JavaScript files. The frontend:
- Sends GraphQL queries with optional `resolution` and `resampleAlgo` parameters
- Receives pre-resampled data from the backend
- User preferences stored in UI config (plotConfiguration_resamplePolicy, plotConfiguration_resampleAlgo)
- Resampling is configured at the user settings level but applied server-side

### 6. Configuration Files

**JSON Config Schema**: `/Users/jan/prg/ClusterCockpit/cc-backend/internal/config/schema.go` (lines 91-106)

**Example Configs**:
1. `/Users/jan/prg/ClusterCockpit/cc-backend/config.json` - Development config
2. `/Users/jan/prg/ClusterCockpit/cc-backend/configs/config.json` - Production example
3. `/Users/jan/prg/ClusterCockpit/cc-backend/cmd/cc-backend/init.go` - Generated default config

**Config Keys in "main" section**:
```json
"resampling": {
  "default-policy": "medium",   // Can be: "low", "medium", "high"
  "default-algo": "lttb"        // Can be: "lttb", "average", "simple"
}
```

### Summary Table

| Component | File Path | Lines | Functionality |
|-----------|-----------|-------|--------------|
| Policy → Points | `internal/metricdispatch/resamplepolicy.go` | 18-29 | Map policy to target point count |
| Resolution Calc | `internal/metricdispatch/resamplepolicy.go` | 34-49 | Calculate resolution from duration/freq/target |
| Resolution Resolution | `internal/graph/resample.go` | 21-115 | Resolve final resolution: explicit→user→global→0 |
| Data Loading (Jobs) | `internal/metricdispatch/dataLoader.go` | 86-236 | Load job data with resampling for archived jobs |
| Data Loading (Nodes) | `internal/metricdispatch/dataLoader.go` | 415-449 | Load node list data with resampling |
| Memory Store Query | `pkg/metricstore/metricstore.go` | 704-772 | Apply resampling to in-memory metric data |
| GraphQL JobMetrics | `internal/graph/schema.resolvers.go` | 501-542 | Query resolver for job metrics with resampling |
| GraphQL NodeMetricsList | `internal/graph/schema.resolvers.go` | 886-960 | Query resolver for node list with resampling |
| Model Definition | `internal/graph/model/models_gen.go` | 331-387 | ResampleAlgo enum (LTTB, AVERAGE, SIMPLE) |
| Config Schema | `internal/config/schema.go` | 91-106 | JSON schema for resampling config |
| Config Struct | `internal/config/config.go` | 120-127 | ResampleConfig struct definition |
| Config Init | `cmd/cc-backend/init.go` | 26-28 | Default resampling config |

The resampling system is **policy-driven**, allowing users and administrators to control data point density through three policies (low/medium/high) that map to specific target point counts, with configurable algorithms for how resampling is performed.

Prompt:
Search the cc-backend repository (at /Users/jan/prg/ClusterCockpit/cc-backend) for everything related to "resampling" or "resample" of metric data. I need a comprehensive analysis.

Find and report:
1. All Go source files that implement resampling logic (function names, file paths, line numbers, what each does)
2. The resampling algorithm(s) used (e.g. simple averaging, LTTB/largest-triangle-three-buckets, etc.)
3. How resampling is configured — config struct fields, config.json keys, default values. Look for a "resampling" or "resolutions" section in config schema.
4. Where resampling is triggered in the request/query flow (REST, GraphQL, metricstore query path)
5. Any frontend (web/frontend) code referencing resampling/resolution
6. Config schema files (look in internal/config or pkg/schema, JSON schema definitions)

Report concrete file paths, line numbers, code snippets, struct definitions, and config keys. Be thorough — grep for "resampl", "resolution", "Resolution", "lttb", "LTTB", "Resampl".


Let me verify a couple of the key details directly, since the branch name (`feature/526-average-resample`) suggests recent changes around the AVERAGE algorithm.


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

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

import "math"

type ResamplePolicy string

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

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

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

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

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

	return resolution
}


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			if algoStr, ok := algoVal.(string); ok && algoStr != "" {
77				return algoStr
78			}
79		}
80
81		// Fall back to global default algo
82		if config.Keys.EnableResampling != nil && config.Keys.EnableResampling.DefaultAlgo != "" {
83			return config.Keys.EnableResampling.DefaultAlgo
84		}
85
86		return ""
87	}
88
89	// resolveResolutionFromDefaultPolicy computes a resolution using the global
90	// default policy from config. Returns nil if no policy is configured.
91	func resolveResolutionFromDefaultPolicy(duration int64, cluster string, metrics []string) *int {
92		cfg := config.Keys.EnableResampling
93		if cfg == nil {
94			return nil
95		}
96
97		policyStr := cfg.DefaultPolicy
98		if policyStr == "" {
99			policyStr = "medium"
100		}
101
102		policy := metricdispatch.ResamplePolicy(policyStr)
103		targetPoints := metricdispatch.TargetPointsForPolicy(policy)
104		if targetPoints == 0 {
105			return nil
106		}
107
108		frequency := smallestFrequency(cluster, metrics)
109		if frequency <= 0 {
110			return nil
111		}
112
113		res := metricdispatch.ComputeResolution(duration, int64(frequency), targetPoints)
114		return &res
115	}
116
117	// smallestFrequency returns the smallest metric timestep (in seconds) among the
118	// requested metrics for the given cluster. Falls back to 0 if nothing is found.
119	func smallestFrequency(cluster string, metrics []string) int {
120		cl := archive.GetCluster(cluster)
121		if cl == nil {
122			return 0
123		}
124
125		minFreq := 0
126		for _, mc := range cl.MetricConfig {
127			if len(metrics) > 0 {
128				found := false
129				for _, m := range metrics {
130					if mc.Name == m {
131						found = true
132						break
133					}
134				}
135				if !found {
136					continue
137				}
138			}
139			if minFreq == 0 || mc.Timestep < minFreq {
140				minFreq = mc.Timestep
141			}
142		}
143
144		return minFreq
145	}
146

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

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


Let me confirm the config struct and key naming, since `resample.go` references `config.Keys.EnableResampling`.


Bash

grep -rn "EnableResampling\|ResampleConfig\|DefaultAlgo\|DefaultPolicy\|resampling" internal/config/config.go internal/config/schema.go


internal/config/schema.go:91:    "resampling": {
internal/config/config.go:73:	EnableResampling *ResampleConfig `json:"resampling"`
internal/config/config.go:120:type ResampleConfig struct {
internal/config/config.go:122:	DefaultPolicy string `json:"default-policy"`
internal/config/config.go:124:	DefaultAlgo string `json:"default-algo"`
internal/config/config.go:174:	if Keys.EnableResampling != nil {
internal/config/config.go:175:		policy := Keys.EnableResampling.DefaultPolicy


118	}
119
120	type ResampleConfig struct {
121		// Default resample policy when no user preference is set ("low", "medium", "high")
122		DefaultPolicy string `json:"default-policy"`
123		// Default resample algorithm when no user preference is set ("lttb", "average", "simple")
124		DefaultAlgo string `json:"default-algo"`
125		// Policy-derived target point count (set dynamically from user preference, not from config.json)
126		TargetPoints int `json:"targetPoints,omitempty"`
127	}
128
129	type NATSConfig struct {
130		SubjectJobEvent  string `json:"subject-job-event"`
131		SubjectNodeState string `json:"subject-node-state"`
132		JobConcurrency   int    `json:"job-concurrency"`
133		NodeConcurrency  int    `json:"node-concurrency"`
134	}
135
136	type IntRange struct {
137		From int `json:"from"`
138		To   int `json:"to"`
139	}
140
141	type TimeRange struct {
142		From  *time.Time `json:"from"`
143		To    *time.Time `json:"to"`
144		Range string     `json:"range,omitempty"`
145	}
146
147	type FilterRanges struct {
148		Duration  *IntRange  `json:"duration"`
149		NumNodes  *IntRange  `json:"num-nodes"`
150		StartTime *TimeRange `json:"start-time"`
151	}
152
153	var Keys ProgramConfig = ProgramConfig{
154		Addr:                      "localhost:8080",
155		EmbedStaticFiles:          true,
156		DB:                        "./var/job.db",
157		SessionMaxAge:             "168h",
158		StopJobsExceedingWalltime: 0,
159		ShortRunningJobsDuration:  5 * 60,
160		FooterLinks: FooterLinksConfig{
161			Imprint: "/imprint",
162			Privacy: "/privacy",
163		},
164	}
165
166	func Init(mainConfig json.RawMessage) {
167		Validate(configSchema, mainConfig)
168		dec := json.NewDecoder(bytes.NewReader(mainConfig))
169		dec.DisallowUnknownFields()
170		if err := dec.Decode(&Keys); err != nil {
171			cclog.Abortf("Config Init: Could not decode config file '%s'.\nError: %s\n", mainConfig, err.Error())
172		}
173
174		if Keys.EnableResampling != nil {
175			policy := Keys.EnableResampling.DefaultPolicy
176			if policy == "" {
177				policy = "medium"
178			}
179			resampler.SetMinimumRequiredPoints(targetPointsForPolicy(policy))
180		}
181	}
182
183	func targetPointsForPolicy(policy string) int {
184		switch policy {
185		case "low":
186			return 200
187		case "medium":

}

type ResampleConfig struct {
	// 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,
	FooterLinks: FooterLinksConfig{
		Imprint: "/imprint",
		Privacy: "/privacy",
	},
}

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 {
		policy := Keys.EnableResampling.DefaultPolicy
		if policy == "" {
			policy = "medium"
		}
		resampler.SetMinimumRequiredPoints(targetPointsForPolicy(policy))
	}
}

func targetPointsForPolicy(policy string) int {
	switch policy {
	case "low":
		return 200
	case "medium":


I now have a verified picture. Here's the analysis.

## Resampling of Metric Data in cc-backend

Resampling downsamples time-series metric data before sending it to the frontend, so plots stay responsive regardless of job duration or metric frequency. It's a **policy-driven** system: users/admins pick a *density policy*, the backend converts that into a concrete *resolution* (seconds per point) per request, and an *algorithm* decides how points are collapsed.

### The three configurable dimensions

**1. Algorithm** — *how* points are merged. Defined as a GraphQL enum (`internal/graph/model/models_gen.go`):

| Value | Meaning |
|-------|---------|
| `LTTB` | Largest-Triangle-Three-Buckets — preserves visual shape (default) |
| `AVERAGE` | Arithmetic mean per bucket (the focus of this branch, `feature/526-average-resample`) |
| `SIMPLE` | Plain decimation/downsampling |

Implementations live in the external `cc-lib/v2/resampler` package; cc-backend just calls `resampler.GetResampler(algoName)` and applies the returned function.

**2. Policy** — *how many* points to target (`internal/metricdispatch/resamplepolicy.go:18`):

| Policy | Target points |
|--------|---------------|
| `low` | 200 |
| `medium` | 500 |
| `high` | 1000 |

**3. Resolution** — derived, not configured directly (`resamplepolicy.go:34`):
```
totalPoints = duration / frequency
if totalPoints <= targetPoints → 0   (no resampling)
resolution  = ceil(ceil(duration/targetPoints)/frequency) * frequency
```
So resolution is rounded up to a whole multiple of the metric's native timestep. `frequency` is the *smallest* timestep among the requested metrics (`resample.go:119 smallestFrequency`).

### How it's configured

**Admin / global** — `config.json`, in the `resampling` object of the main config (`internal/config/config.go:120`, schema at `internal/config/schema.go:91`):

```json
"resampling": {
  "default-policy": "medium",   // "low" | "medium" | "high"
  "default-algo": "lttb"        // "lttb" | "average" | "simple"
}
```
Both keys are enum-validated. If the whole `resampling` object is omitted, resampling is effectively disabled (`config.Keys.EnableResampling == nil`). On startup, `config.Init` also calls `resampler.SetMinimumRequiredPoints(...)` from the default policy (`config.go:174`).

**Per-user** — stored in UI config and read at query time (`internal/graph/resample.go`):
- `plotConfiguration_resamplePolicy`
- `plotConfiguration_resampleAlgo`

**Per-request** — GraphQL `JobMetrics` / `NodeMetricsList` queries accept optional `resolution: Int` and `resampleAlgo: ResampleAlgo` arguments (`internal/graph/schema.resolvers.go`).

### Resolution precedence (request flow)

For each request the backend resolves the two knobs independently:

- **Resolution**: explicit query arg → user policy (`resolveResolutionFromPolicy`) → global default policy (`resolveResolutionFromDefaultPolicy`) → `0` (no resampling).
- **Algorithm**: explicit query arg → user pref → `config…DefaultAlgo` → `""` (`resolveResampleAlgo`, `resample.go:59`).

The resolved values flow into `metricdispatch.LoadData` / `LoadNodeListData` (`internal/metricdispatch/dataLoader.go`), which applies resampling to **archived** jobs/nodes, and into `pkg/metricstore/metricstore.go:Read` for **running** jobs served from the in-memory store. In both cases: `resampleFn(data, frequency, resolution)`.

### Notes

- The frontend contains **no resampling logic** — it only sets user preferences and passes `resolution`/`resampleAlgo` to GraphQL; all downsampling happens server-side.
- `ResampleConfig.TargetPoints` is computed at runtime (e.g. `routerConfig/routes.go resamplingForUser`), not set in `config.json`.
- A `resolution`/policy that yields `totalPoints <= targetPoints` returns `0` → data is passed through untouched.

If you want, I can dig into the actual `AVERAGE` algorithm behavior in `cc-lib` (the subject of this branch) or trace the `LoadData` resampling loop in detail.


We analyzed how metric resampling works in cc-backend: configurable by algorithm (LTTB/AVERAGE/SIMPLE), policy (low/medium/high), and resolution, set globally, per-user, or per-request. Next, I can examine the AVERAGE algorithm in cc-lib that this branch adds. (disable recaps in /config)


With the current branch the frontend does not show any jobs in the job list. There is a comsole error: Uncaught TypeError: t.resampleConfig.resolutions is not iterable (cannot read property undefined)
    at jobs.js:65:121
    at Wn (jobs.js:1:26455)
    at gt (jobs.js:1:16991)
    at bt (jobs.js:1:17041)
    at Un (jobs.js:1:28557)
    at jobs.js:65:278
    at Wn (jobs.js:1:26455)
    at gt (jobs.js:1:16991)
    at bt (jobs.js:1:17041)
    at Un (jobs.js:1:28557). Fix the problem.


The error shows the frontend expects `resampleConfig.resolutions` (an array), but the new backend config uses `default-policy`/`default-algo`. Let me find the frontend code and how `resampleConfig` is passed to it.


cd /Users/jan/prg/ClusterCockpit/cc-backend; grep -rn "resampleConfig\|resolutions\|resampleResolution\|resamplePolicy\|resampleAlgo" web/frontend/src web/templates --include=*.svelte --include=*.js --include=*.tmpl -l


(eval):1: no matches found: --include=*.svelte


cd /Users/jan/prg/ClusterCockpit/cc-backend; grep -rn "resampleConfig\|resolutions\|resampleResolution\|resamplePolicy\|resampleAlgo" web/ | grep -v node_modules | grep -v "public/build"


web/web.go:175:	UIDefaultsMap["plotConfiguration_resampleAlgo"] = UIDefaults.PlotConfiguration.ResampleAlgo
web/web.go:176:	UIDefaultsMap["plotConfiguration_resamplePolicy"] = UIDefaults.PlotConfiguration.ResamplePolicy
web/web.go:297:	Resampling    *config.ResampleConfig // If not nil, defines resampling trigger and resolutions
web/frontend/src/jobs.entrypoint.js:14:            ['resampling', resampleConfig]
web/frontend/src/systems.entrypoint.js:16:            ['resampling', resampleConfig]
web/frontend/src/job.entrypoint.js:15:            ['resampling', resampleConfig],
web/frontend/src/Systems.root.svelte:72:  const resampleConfig = $derived(thisInit ? getContext("resampling") : null);
web/frontend/src/Systems.root.svelte:73:  const resampleResolutions = $derived(resampleConfig ? [...resampleConfig.resolutions] : []);
web/frontend/src/Systems.root.svelte:74:  const resampleDefault = $derived(resampleConfig ? Math.max(...resampleConfig.resolutions) : 0);
web/frontend/src/Systems.root.svelte:162:<Row cols={{ xs: 2, lg: !displayNodeOverview ? (resampleConfig ? 6 : 5) : 5 }} class="mb-3">
web/frontend/src/Systems.root.svelte:179:      {#if resampleConfig}
web/frontend/src/Systems.root.svelte:185:              {#each resampleResolutions as res}
web/frontend/src/job/Metric.svelte:46:  const resampleConfig = getContext("resampling") || null;
web/frontend/src/job/Metric.svelte:47:  const resampleDefault = resampleConfig ? Math.max(...resampleConfig.resolutions) : 0;
web/frontend/src/user.entrypoint.js:13:            ['resampling', resampleConfig]
web/frontend/src/config/admin/Options.svelte:18:  const resampleConfig = getContext("resampling");
web/frontend/src/config/admin/Options.svelte:75:{#if resampleConfig}
web/frontend/src/config/admin/Options.svelte:80:
Triggered at {resampleConfig.trigger} datapoints.

web/frontend/src/config/admin/Options.svelte:81:
Configured resolutions: {resampleConfig.resolutions}

web/frontend/src/config.entrypoint.js:17:            ['resampling', resampleConfig]
web/templates/config.tmpl:16:        const resampleConfig = {{ .Resampling }};
web/frontend/src/generic/plots/MetricPlot.svelte:54:  const resampleConfig = getContext("resampling");
web/frontend/src/generic/plots/MetricPlot.svelte:76:  const resampleTrigger = $derived(resampleConfig?.trigger ? Number(resampleConfig.trigger) : (resampleConfig?.targetPoints ? Math.floor(resampleConfig.targetPoints / 4) : null));
web/frontend/src/generic/plots/MetricPlot.svelte:77:  const resampleResolutions = $derived(resampleConfig?.resolutions ? [...resampleConfig.resolutions] : null);
web/frontend/src/generic/plots/MetricPlot.svelte:78:  const resampleMinimum = $derived(resampleConfig?.resolutions ? Math.min(...resampleConfig.resolutions) : null);
web/frontend/src/generic/plots/MetricPlot.svelte:79:  const resampleTargetPoints = $derived(resampleConfig?.targetPoints ? Number(resampleConfig.targetPoints) : null);
web/frontend/src/generic/plots/MetricPlot.svelte:460:            if (resampleConfig && !forNode) {
web/frontend/src/generic/plots/MetricPlot.svelte:516:            if (resampleConfig && !forNode && key === 'x') {
web/frontend/src/generic/plots/MetricPlot.svelte:520:                if (resampleTargetPoints && !resampleResolutions) {
web/frontend/src/generic/plots/MetricPlot.svelte:526:                } else if (resampleResolutions) {
web/frontend/src/generic/plots/MetricPlot.svelte:529:                  newRes = resampleResolutions.reduce(function(prev, curr) {
web/templates/monitoring/systems.tmpl:13:        const resampleConfig = {{ .Resampling }};
web/frontend/src/config/user/PlotRenderOptions.svelte:30:  const resampleConfig = getContext("resampling");
web/frontend/src/config/user/PlotRenderOptions.svelte:251:        <input type="hidden" name="key" value="plotConfiguration_resamplePolicy" />
web/frontend/src/config/user/PlotRenderOptions.svelte:256:                checked={(!config?.plotConfiguration_resamplePolicy && val === "") || config?.plotConfiguration_resamplePolicy === val} />
web/frontend/src/config/user/PlotRenderOptions.svelte:260:

web/frontend/src/config/user/PlotRenderOptions.svelte:294:        <input type="hidden" name="key" value="plotConfiguration_resampleAlgo" />
web/frontend/src/config/user/PlotRenderOptions.svelte:299:                checked={(!config?.plotConfiguration_resampleAlgo && val === "") || config?.plotConfiguration_resampleAlgo === val} />
web/frontend/src/config/user/PlotRenderOptions.svelte:303:

web/frontend/src/generic/JobList.svelte:107:  const resampleConfig = $derived(initialized ? getContext("resampling") : null);
web/frontend/src/generic/JobList.svelte:294:            <JobListRow {triggerMetricRefresh} {job} {metrics} {plotWidth} {showFootprint} {globalMetrics} {clusterInfos} {resampleConfig}
web/frontend/src/generic/joblist/JobListRow.svelte:16:  - `resampleConfig [Obj]`: Includes the backend supplied resampling info
web/frontend/src/generic/joblist/JobListRow.svelte:41:    resampleConfig
web/frontend/src/generic/joblist/JobListRow.svelte:87:  const resampleDefault = $derived(resampleConfig ? Math.max(...resampleConfig.resolutions) : 0);
web/templates/monitoring/user.tmpl:13:        const resampleConfig = {{ .Resampling }};
web/templates/monitoring/job.tmpl:17:        const resampleConfig = {{ .Resampling }};
web/templates/monitoring/jobs.tmpl:15:        const resampleConfig = {{ .Resampling }};


The backend's `ResampleConfig` was changed from `{trigger, resolutions[]}` to `{default-policy, default-algo, targetPoints}`, but much of the frontend still reads `resampleConfig.resolutions`. Let me see how `resampleDefault` is used in the affected components.


cd /Users/jan/prg/ClusterCockpit/cc-backend; grep -rn "resampleDefault\|resampleResolution\|targetPoints\|resolution\b\|resolution:" web/frontend/src/generic/joblist/JobListRow.svelte web/frontend/src/job/Metric.svelte web/frontend/src/Systems.root.svelte


web/frontend/src/generic/joblist/JobListRow.svelte:48:      jobMetrics(id: $id, metrics: $metrics, scopes: $scopes, resolution: $selectedResolution) {
web/frontend/src/generic/joblist/JobListRow.svelte:87:  const resampleDefault = $derived(resampleConfig ? Math.max(...resampleConfig.resolutions) : 0);
web/frontend/src/generic/joblist/JobListRow.svelte:98:  let selectedResolution = $derived(resampleDefault);
web/frontend/src/Systems.root.svelte:73:  const resampleResolutions = $derived(resampleConfig ? [...resampleConfig.resolutions] : []);
web/frontend/src/Systems.root.svelte:74:  const resampleDefault = $derived(resampleConfig ? Math.max(...resampleConfig.resolutions) : 0);
web/frontend/src/Systems.root.svelte:88:  let selectedResolution = $derived(resampleDefault);
web/frontend/src/Systems.root.svelte:185:              {#each resampleResolutions as res}
web/frontend/src/job/Metric.svelte:47:  const resampleDefault = resampleConfig ? Math.max(...resampleConfig.resolutions) : 0;
web/frontend/src/job/Metric.svelte:50:      singleUpdate: jobMetrics(id: $dbid, metrics: $selectedMetrics, scopes: $selectedScopes, resolution: $selectedResolution) {
web/frontend/src/job/Metric.svelte:81:  let selectedResolution = $state(resampleDefault);


With the current branch the frontend does not show any jobs in the job list. There is a comsole error: Uncaught TypeError: t.resampleConfig.resolutions is not iterable (cannot read property undefined)…


Let me check the `JobMetrics` resolver to confirm what the frontend should send for resolution under the new policy-based model.


501	func (r *queryResolver) JobMetrics(ctx context.Context, id string, metrics []string, scopes []schema.MetricScope, resolution *int, resampleAlgo *model.ResampleAlgo) ([]*model.JobMetricWithName, error) {
502		job, err := r.Query().Job(ctx, id)
503		if err != nil {
504			cclog.Warn("Error while querying job for metrics")
505			return nil, err
506		}
507
508		// Resolve resolution: explicit param > user policy > global config > 0
509		if resolution == nil {
510			resolution = resolveResolutionFromPolicy(ctx, int64(job.Duration), job.Cluster, metrics)
511		}
512		if resolution == nil {
513			if config.Keys.EnableResampling != nil {
514				resolution = resolveResolutionFromDefaultPolicy(int64(job.Duration), job.Cluster, metrics)
515			}
516			if resolution == nil {
517				defaultRes := 0
518				resolution = &defaultRes
519			}
520		}
521
522		algoName := resolveResampleAlgo(ctx, resampleAlgo)
523
524		data, err := metricdispatch.LoadData(job, metrics, scopes, ctx, *resolution, algoName)
525		if err != nil {
526			cclog.Warn("Error while loading job data")
527			return nil, err
528		}
529
530		res := []*model.JobMetricWithName{}
531		for name, md := range data {
532			for scope, metric := range md {
533				res = append(res, &model.JobMetricWithName{
534					Name:   name,
535					Scope:  scope,
536					Metric: metric,
537				})
538			}
539		}
540
541		return res, err
542	}
543
544	// JobStats is the resolver for the jobStats field.
545	func (r *queryResolver) JobStats(ctx context.Context, id string, metrics []string) ([]*model.NamedStats, error) {

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


Let me check what `.Resampling` actually serializes to the frontend now (via `resamplingForUser`).


cd /Users/jan/prg/ClusterCockpit/cc-backend; grep -rn "resamplingForUser\|Resampling" internal/routerConfig/routes.go web/web.go


web/web.go:297:	Resampling    *config.ResampleConfig // If not nil, defines resampling trigger and resolutions
internal/routerConfig/routes.go:500:			resampling := resamplingForUser(conf)
internal/routerConfig/routes.go:508:				Resampling: resampling,
internal/routerConfig/routes.go:596:// resamplingForUser returns a ResampleConfig that incorporates the user's
internal/routerConfig/routes.go:600:func resamplingForUser(conf map[string]any) *config.ResampleConfig {
internal/routerConfig/routes.go:601:	globalCfg := config.Keys.EnableResampling


596	// resamplingForUser returns a ResampleConfig that incorporates the user's
597	// resample policy preference. If the user has a policy set, it creates a
598	// policy-derived config with targetPoints and trigger. Otherwise falls back
599	// to the global config.
600	func resamplingForUser(conf map[string]any) *config.ResampleConfig {
601		globalCfg := config.Keys.EnableResampling
602
603		policyStr := ""
604		if policyVal, ok := conf["plotConfiguration_resamplePolicy"]; ok {
605			if s, ok := policyVal.(string); ok {
606				policyStr = s
607			}
608		}
609
610		// Fall back to global default policy, then to "medium"
611		if policyStr == "" && globalCfg != nil {
612			policyStr = globalCfg.DefaultPolicy
613		}
614		if policyStr == "" {
615			policyStr = "medium"
616		}
617
618		policy := metricdispatch.ResamplePolicy(policyStr)
619		targetPoints := metricdispatch.TargetPointsForPolicy(policy)
620		if targetPoints == 0 {
621			return globalCfg
622		}
623
624		return &config.ResampleConfig{
625			TargetPoints: targetPoints,
626		}
627	}
628

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

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

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

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

	return &config.ResampleConfig{
		TargetPoints: targetPoints,
	}
}


The new config only sets `targetPoints`; `resolutions`/`trigger` no longer exist. `MetricPlot.svelte` already handles the `targetPoints`-only case, but `JobListRow.svelte`, `job/Metric.svelte`, `Systems.root.svelte`, and `admin/Options.svelte` still spread `resampleConfig.resolutions` and crash. Let me read each to fix them properly.


38	    unselectJob,
39	    globalMetrics,
40	    clusterInfos,
41	    resampleConfig
42	  } = $props();
43
44	  /* Const Init */
45	  const client = getContextClient();
46	  const query = gql`
47	    query ($id: ID!, $metrics: [String!]!, $scopes: [MetricScope!]!, $selectedResolution: Int) {
48	      jobMetrics(id: $id, metrics: $metrics, scopes: $scopes, resolution: $selectedResolution) {
49	        name
50	        scope
51	        metric {
52	          unit {
53	            prefix
54	            base
55	          }
56	          timestep
57	          statisticsSeries {
58	            min
59	            mean
60	            median
61	            max
62	          }
63	          series {
64	            hostname
65	            id
66	            data
67	            statistics {
68	              min
69	              avg
70	              max
71	            }
72	          }
73	        }
74	      }
75	    }
76	  `;
77
78	  /* Var Init*/
79	  // svelte-ignore state_referenced_locally
80	  let plotSync = uPlot.sync(`jobMetricStack-${job.cluster}-${job.id}`);
81
82	  /* State Init */
83	  let zoomStates = $state({});
84	  let thresholdStates = $state({});
85
86	  /* Derived */
87	  const resampleDefault = $derived(resampleConfig ? Math.max(...resampleConfig.resolutions) : 0);
88	  const jobId = $derived(job.id);
89	  const scopes = $derived.by(() => {
90	    if (job.numNodes == 1) {
91	      if (job.numAcc >= 1) return ["core", "accelerator"];
92	      else return ["core"];
93	    } else {
94	      return ["node"];
95	    };
96	  });
97
98	  let selectedResolution = $derived(resampleDefault);
99	  let isSelected = $derived(previousSelect);
100	  let metricsQuery = $derived(queryStore({
101	      client: client,
102	      query: query,
103	      variables: { id: jobId, metrics, scopes, selectedResolution },
104	    })
105	  );
106
107	  const refinedData = $derived($metricsQuery?.data?.jobMetrics ? sortAndSelectScope(metrics, $metricsQuery.data.jobMetrics) : []);
108
109	  /* Effects */
110	  $effect(() => {
111	    if (job.state === 'running' && triggerMetricRefresh === true) {
112	      refreshMetrics();
113	    }
114	  });
115
116	  $effect(() => {
117	    if (isSelected == true && previousSelect == false) {
118	      selectJob(jobId)
119	    } else if (isSelected == false && previousSelect == true) {
120	      unselectJob(jobId)
121	    }
122	  });
123
124	  /* Functions */
125	  function handleZoom(detail, metric) {
126	    // Buffer last zoom state to allow seamless zoom on rerender
127	    // console.log('Update zoomState for/with:', metric, {...detail.lastZoomState})
128	    zoomStates[metric] = detail?.lastZoomState ? {...detail.lastZoomState} : null;
129	    // Handle to correctly reset on summed metric scope change
130	    // console.log('Update thresholdState for/with:', metric, detail.lastThreshold)
131	    thresholdStates[metric] = detail?.lastThreshold ? detail.lastThreshold : null;
132	    // Triggers GQL
133	    if (detail?.newRes) {
134	      // console.log('Update selectedResolution for/with:', metric, detail.newRes)
135	      selectedResolution = detail.newRes;
136	    }
137	  }
138
139	  function refreshMetrics() {
140	    metricsQuery = queryStore({
141	      client: client,
142	      query: query,
143	      variables: { id: jobId, metrics, scopes, selectedResolution },
144	      // requestPolicy: 'network-only' // use default cache-first for refresh
145	    });
146	  }
147
148	  function sortAndSelectScope(metricList = [], jobMetrics = []) {
149	    const pendingData = [];
150	    metricList.forEach((metricName) => {
151	      const pendingMetric = {
152	        name: metricName,

    unselectJob,
    globalMetrics,
    clusterInfos,
    resampleConfig
  } = $props();

  /* Const Init */
  const client = getContextClient();
  const query = gql`
    query ($id: ID!, $metrics: [String!]!, $scopes: [MetricScope!]!, $selectedResolution: Int) {
      jobMetrics(id: $id, metrics: $metrics, scopes: $scopes, resolution: $selectedResolution) {
        name
        scope
        metric {
          unit {
            prefix
            base
          }
          timestep
          statisticsSeries {
            min
            mean
            median
            max
          }
          series {
            hostname
            id
            data
            statistics {
              min
              avg
              max
            }
          }
        }
      }
    }
  `;

  /* Var Init*/
  // svelte-ignore state_referenced_locally
  let plotSync = uPlot.sync(`jobMetricStack-${job.cluster}-${job.id}`);

  /* State Init */
  let zoomStates = $state({});
  let thresholdStates = $state({});

  /* Derived */
  const resampleDefault = $derived(resampleConfig ? Math.max(...resampleConfig.resolutions) : 0);
  const jobId = $derived(job.id);
  const scopes = $derived.by(() => {
    if (job.numNodes == 1) {
      if (job.numAcc >= 1) return ["core", "accelerator"];
      else return ["core"];
    } else {
      return ["node"];
    };
  });

  let selectedResolution = $derived(resampleDefault);
  let isSelected = $derived(previousSelect);
  let metricsQuery = $derived(queryStore({
      client: client,
      query: query,
      variables: { id: jobId, metrics, scopes, selectedResolution },
    })
  );

  const refinedData = $derived($metricsQuery?.data?.jobMetrics ? sortAndSelectScope(metrics, $metricsQuery.data.jobMetrics) : []);

  /* Effects */
  $effect(() => {
    if (job.state === 'running' && triggerMetricRefresh === true) {
      refreshMetrics();
    }
  });

  $effect(() => {
    if (isSelected == true && previousSelect == false) {
      selectJob(jobId)
    } else if (isSelected == false && previousSelect == true) {
      unselectJob(jobId)
    }
  });

  /* Functions */
  function handleZoom(detail, metric) {
    // Buffer last zoom state to allow seamless zoom on rerender
    // console.log('Update zoomState for/with:', metric, {...detail.lastZoomState})
    zoomStates[metric] = detail?.lastZoomState ? {...detail.lastZoomState} : null;
    // Handle to correctly reset on summed metric scope change
    // console.log('Update thresholdState for/with:', metric, detail.lastThreshold)
    thresholdStates[metric] = detail?.lastThreshold ? detail.lastThreshold : null;
    // Triggers GQL
    if (detail?.newRes) {
      // console.log('Update selectedResolution for/with:', metric, detail.newRes)
      selectedResolution = detail.newRes;
    }
  }

  function refreshMetrics() {
    metricsQuery = queryStore({
      client: client,
      query: query,
      variables: { id: jobId, metrics, scopes, selectedResolution },
      // requestPolicy: 'network-only' // use default cache-first for refresh
    });
  }

  function sortAndSelectScope(metricList = [], jobMetrics = []) {
    const pendingData = [];
    metricList.forEach((metricName) => {
      const pendingMetric = {
        name: metricName,


I understand the model now: with the policy-driven backend, the frontend should send `resolution: null` initially (backend computes from policy), with zoom overriding it explicitly. Let me read the other affected components before fixing.


40	    isShared = false,
41	  } = $props();
42
43	  /* Const Init */
44	  const client = getContextClient();
45	  const statsPattern = /(.*)-stat$/;
46	  const resampleConfig = getContext("resampling") || null;
47	  const resampleDefault = resampleConfig ? Math.max(...resampleConfig.resolutions) : 0;
48	  const subQuery = gql`
49	    query ($dbid: ID!, $selectedMetrics: [String!]!, $selectedScopes: [MetricScope!]!, $selectedResolution: Int) {
50	      singleUpdate: jobMetrics(id: $dbid, metrics: $selectedMetrics, scopes: $selectedScopes, resolution: $selectedResolution) {
51	        name
52	        scope
53	        metric {
54	          unit {
55	            prefix
56	            base
57	          }
58	          timestep
59	          statisticsSeries {
60	            min
61	            mean
62	            median
63	            max
64	          }
65	          series {
66	            hostname
67	            id
68	            data
69	            statistics {
70	              min
71	              avg
72	              max
73	            }
74	          }
75	        }
76	      }
77	    }
78	  `;
79
80	  /* State Init */
81	  let selectedResolution = $state(resampleDefault);
82	  let selectedHost = $state(null);
83	  let zoomState = $state(null);
84	  let thresholdState = $state(null);
85
86	  /* Derived */
87	  let requestedScopes = $derived(presetScopes);
88	  const unit = $derived.by(() => { return (metricUnit?.prefix ? metricUnit.prefix : "") + (metricUnit?.base ? metricUnit.base : "")});
89	  const metricData = $derived(queryStore({
90	      client: client,
91	      query: subQuery,
92	      variables: {
93	        dbid: job.id,
94	        selectedMetrics: [metricName],

    isShared = false,
  } = $props();

  /* Const Init */
  const client = getContextClient();
  const statsPattern = /(.*)-stat$/;
  const resampleConfig = getContext("resampling") || null;
  const resampleDefault = resampleConfig ? Math.max(...resampleConfig.resolutions) : 0;
  const subQuery = gql`
    query ($dbid: ID!, $selectedMetrics: [String!]!, $selectedScopes: [MetricScope!]!, $selectedResolution: Int) {
      singleUpdate: jobMetrics(id: $dbid, metrics: $selectedMetrics, scopes: $selectedScopes, resolution: $selectedResolution) {
        name
        scope
        metric {
          unit {
            prefix
            base
          }
          timestep
          statisticsSeries {
            min
            mean
            median
            max
          }
          series {
            hostname
            id
            data
            statistics {
              min
              avg
              max
            }
          }
        }
      }
    }
  `;

  /* State Init */
  let selectedResolution = $state(resampleDefault);
  let selectedHost = $state(null);
  let zoomState = $state(null);
  let thresholdState = $state(null);

  /* Derived */
  let requestedScopes = $derived(presetScopes);
  const unit = $derived.by(() => { return (metricUnit?.prefix ? metricUnit.prefix : "") + (metricUnit?.base ? metricUnit.base : "")});
  const metricData = $derived(queryStore({
      client: client,
      query: subQuery,
      variables: {
        dbid: job.id,
        selectedMetrics: [metricName],


66	  /* Derived Init Return */
67	  const thisInit = $derived($initq?.data ? true : false);
68
69	  /* Derived States */
70	  const ccconfig = $derived(thisInit ? getContext("cc-config") : null);
71	  const globalMetrics = $derived(thisInit ? getContext("globalMetrics") : null);
72	  const resampleConfig = $derived(thisInit ? getContext("resampling") : null);
73	  const resampleResolutions = $derived(resampleConfig ? [...resampleConfig.resolutions] : []);
74	  const resampleDefault = $derived(resampleConfig ? Math.max(...resampleConfig.resolutions) : 0);
75	  const displayNodeOverview = $derived((displayType === 'OVERVIEW'));
76
77	  const systemMetrics = $derived(globalMetrics ? [...globalMetrics.filter((gm) => gm?.availability.find((av) => av.cluster == cluster))] : []);
78	  const systemUnits = $derived.by(() => {
79	    const pendingUnits = {};
80	    if (thisInit && systemMetrics.length > 0) {
81	      for (let sm of systemMetrics) {
82	        pendingUnits[sm.name] = (sm?.unit?.prefix ? sm.unit.prefix : "") + (sm?.unit?.base ? sm.unit.base : "")
83	      };
84	    }
85	    return {...pendingUnits};
86	  });
87
88	  let selectedResolution = $derived(resampleDefault);
89	  let to = $derived(presetTo ? presetTo : new Date(Date.now()));
90	  let from = $derived(presetFrom ? presetFrom : new Date(nowDate.setHours(nowDate.getHours() - 4)));
91
92	  let selectedMetric = $derived.by(() => {
93	    let configKey = `nodeOverview_selectedMetric`;
94	    if (cluster) configKey += `:${cluster}`;
95	    if (subCluster) configKey += `:${subCluster}`;
96
97	    if (thisInit) {
98	      if (ccconfig[configKey]) return ccconfig[configKey]
99	      else if (systemMetrics.length !== 0) return systemMetrics[0].name
100	    }

  /* Derived Init Return */
  const thisInit = $derived($initq?.data ? true : false);

  /* Derived States */
  const ccconfig = $derived(thisInit ? getContext("cc-config") : null);
  const globalMetrics = $derived(thisInit ? getContext("globalMetrics") : null);
  const resampleConfig = $derived(thisInit ? getContext("resampling") : null);
  const resampleResolutions = $derived(resampleConfig ? [...resampleConfig.resolutions] : []);
  const resampleDefault = $derived(resampleConfig ? Math.max(...resampleConfig.resolutions) : 0);
  const displayNodeOverview = $derived((displayType === 'OVERVIEW'));

  const systemMetrics = $derived(globalMetrics ? [...globalMetrics.filter((gm) => gm?.availability.find((av) => av.cluster == cluster))] : []);
  const systemUnits = $derived.by(() => {
    const pendingUnits = {};
    if (thisInit && systemMetrics.length > 0) {
      for (let sm of systemMetrics) {
        pendingUnits[sm.name] = (sm?.unit?.prefix ? sm.unit.prefix : "") + (sm?.unit?.base ? sm.unit.base : "")
      };
    }
    return {...pendingUnits};
  });

  let selectedResolution = $derived(resampleDefault);
  let to = $derived(presetTo ? presetTo : new Date(Date.now()));
  let from = $derived(presetFrom ? presetFrom : new Date(nowDate.setHours(nowDate.getHours() - 4)));

  let selectedMetric = $derived.by(() => {
    let configKey = `nodeOverview_selectedMetric`;
    if (cluster) configKey += `:${cluster}`;
    if (subCluster) configKey += `:${subCluster}`;

    if (thisInit) {
      if (ccconfig[configKey]) return ccconfig[configKey]
      else if (systemMetrics.length !== 0) return systemMetrics[0].name
    }


70	      </CardBody>
71	    </Card>
72	  </Col>
73	{/if}
74
75	{#if resampleConfig}
76	  <Col>
77	    <Card class="h-100">
78	      <CardBody>
79	        <CardTitle class="mb-3">Metric Plot Resampling Info</CardTitle>
80
Triggered at {resampleConfig.trigger} datapoints.

81
Configured resolutions: {resampleConfig.resolutions}

82	      </CardBody>
83	    </Card>
84	  </Col>
85	{/if}
86

</CardBody>
    </Card>
  </Col>
{/if}

{#if resampleConfig}
  <Col>
    <Card class="h-100">
      <CardBody>
        <CardTitle class="mb-3">Metric Plot Resampling Info</CardTitle>

Triggered at {resampleConfig.trigger} datapoints.

Configured resolutions: {resampleConfig.resolutions}

      </CardBody>
    </Card>
  </Col>
{/if}


Let me see the Systems.root resolution selector UI block (around lines 162-195).


160
161	<!-- ROW1: Tools-->
162	<Row cols={{ xs: 2, lg: !displayNodeOverview ? (resampleConfig ? 6 : 5) : 5 }} class="mb-3">
163	  {#if thisInit}
164	    <!-- List Metric Select Col-->
165	    {#if !displayNodeOverview}
166	      <Col>
167	        <InputGroup>
168	          <InputGroupText><Icon name="graph-up" /></InputGroupText>
169	          <InputGroupText class="text-capitalize">Metrics</InputGroupText>
170	          <Button
171	            outline
172	            color="primary"
173	            onclick={() => (isMetricsSelectionOpen = true)}
174	          >
175	            {selectedMetrics.length} selected
176	          </Button>
177	        </InputGroup>
178	      </Col>
179	      {#if resampleConfig}
180	        <Col>
181	          <InputGroup>
182	            <InputGroupText><Icon name="plus-slash-minus" /></InputGroupText>
183	            <InputGroupText>Resolution</InputGroupText>
184	            <Input type="select" bind:value={selectedResolution}>
185	              {#each resampleResolutions as res}
186	                <option value={res}
187	                  >{res} sec</option
188	                >
189	              {/each}
190	            </Input>
191	          </InputGroup>
192	        </Col>
193	      {/if}
194	    {/if}
195	    <!-- Node Col-->
196	    <Col class="mt-2 mt-lg-0">
197	      <InputGroup>
198	        <InputGroupText><Icon name="hdd" /></InputGroupText>
199	        <InputGroupText>Node(s)</InputGroupText>

<!-- ROW1: Tools-->
<Row cols={{ xs: 2, lg: !displayNodeOverview ? (resampleConfig ? 6 : 5) : 5 }} class="mb-3">
  {#if thisInit}
    <!-- List Metric Select Col-->
    {#if !displayNodeOverview}
      <Col>
        <InputGroup>
          <InputGroupText><Icon name="graph-up" /></InputGroupText>
          <InputGroupText class="text-capitalize">Metrics</InputGroupText>
          <Button
            outline
            color="primary"
            onclick={() => (isMetricsSelectionOpen = true)}
          >
            {selectedMetrics.length} selected
          </Button>
        </InputGroup>
      </Col>
      {#if resampleConfig}
        <Col>
          <InputGroup>
            <InputGroupText><Icon name="plus-slash-minus" /></InputGroupText>
            <InputGroupText>Resolution</InputGroupText>
            <Input type="select" bind:value={selectedResolution}>
              {#each resampleResolutions as res}
                <option value={res}
                  >{res} sec</option
                >
              {/each}
            </Input>
          </InputGroup>
        </Col>
      {/if}
    {/if}
    <!-- Node Col-->
    <Col class="mt-2 mt-lg-0">
      <InputGroup>
        <InputGroupText><Icon name="hdd" /></InputGroupText>
        <InputGroupText>Node(s)</InputGroupText>


Let me check the MetricPlot zoom logic to confirm it computes `newRes` from `targetPoints` (so zoom still works after I default resolution to null).


508	            u.ctx.moveTo(u.bbox.left, y);
509	            u.ctx.lineTo(u.bbox.left + u.bbox.width, y);
510	            u.ctx.stroke();
511	            u.ctx.restore();
512	          },
513	        ],
514	        setScale: [
515	          (u, key) => { // If ZoomResample is Configured && Not System/Node View
516	            if (resampleConfig && !forNode && key === 'x') {
517	              const numX = (u.series[0].idxs[1] - u.series[0].idxs[0])
518	              if (numX <= resampleTrigger && timestep !== resampleMinimum) {
519	                let newRes;
520	                if (resampleTargetPoints && !resampleResolutions) {
521	                  // Policy-based: compute resolution dynamically from visible window
522	                  const visibleDuration = (u.scales.x.max - u.scales.x.min);
523	                  const nativeTimestep = metricConfig?.timestep || timestep;
524	                  newRes = Math.ceil(visibleDuration / resampleTargetPoints / nativeTimestep) * nativeTimestep;
525	                  if (newRes < nativeTimestep) newRes = nativeTimestep;
526	                } else if (resampleResolutions) {
527	                  // Array-based: find closest configured resolution
528	                  const target = (numX * timestep) / resampleTrigger;
529	                  newRes = resampleResolutions.reduce(function(prev, curr) {
530	                    return (Math.abs(curr - target) < Math.abs(prev - target) ? curr : prev);
531	                  });
532	                }
533	                // Prevents non-required dispatches
534	                if (newRes && timestep !== newRes) {
535	                  onZoom({
536	                    newRes: newRes,
537	                    lastZoomState: u?.scales,
538	                    lastThreshold: thresholds?.normal
539	                  });
540	                }
541	              } else {
542	                onZoom({
543	                  lastZoomState: u?.scales,
544	                  lastThreshold: thresholds?.normal
545	                });
546	              };
547	            };
548	          },
549	        ]
550	      },
551	      scales: {
552	        x: { time: false },

            u.ctx.moveTo(u.bbox.left, y);
            u.ctx.lineTo(u.bbox.left + u.bbox.width, y);
            u.ctx.stroke();
            u.ctx.restore();
          },
        ],
        setScale: [
          (u, key) => { // If ZoomResample is Configured && Not System/Node View
            if (resampleConfig && !forNode && key === 'x') {
              const numX = (u.series[0].idxs[1] - u.series[0].idxs[0])
              if (numX <= resampleTrigger && timestep !== resampleMinimum) {
                let newRes;
                if (resampleTargetPoints && !resampleResolutions) {
                  // Policy-based: compute resolution dynamically from visible window
                  const visibleDuration = (u.scales.x.max - u.scales.x.min);
                  const nativeTimestep = metricConfig?.timestep || timestep;
                  newRes = Math.ceil(visibleDuration / resampleTargetPoints / nativeTimestep) * nativeTimestep;
                  if (newRes < nativeTimestep) newRes = nativeTimestep;
                } else if (resampleResolutions) {
                  // Array-based: find closest configured resolution
                  const target = (numX * timestep) / resampleTrigger;
                  newRes = resampleResolutions.reduce(function(prev, curr) {
                    return (Math.abs(curr - target) < Math.abs(prev - target) ? curr : prev);
                  });
                }
                // Prevents non-required dispatches
                if (newRes && timestep !== newRes) {
                  onZoom({
                    newRes: newRes,
                    lastZoomState: u?.scales,
                    lastThreshold: thresholds?.normal
                  });
                }
              } else {
                onZoom({
                  lastZoomState: u?.scales,
                  lastThreshold: thresholds?.normal
                });
              };
            };
          },
        ]
      },
      scales: {
        x: { time: false },


MetricPlot's zoom already supports the policy/targetPoints path. Now let me check how `selectedResolution` flows downstream in `Systems.root.svelte` before removing the defunct dropdown.


cd /Users/jan/prg/ClusterCockpit/cc-backend; grep -n "selectedResolution\|resolution" web/frontend/src/Systems.root.svelte


73:  const resampleResolutions = $derived(resampleConfig ? [...resampleConfig.resolutions] : []);
74:  const resampleDefault = $derived(resampleConfig ? Math.max(...resampleConfig.resolutions) : 0);
88:  let selectedResolution = $derived(resampleDefault);
184:            <Input type="select" bind:value={selectedResolution}>
276:       {selectedResolution} {hostnameFilter} {nodeStateFilter} {from} {to} {systemUnits}/>


Now I have the full picture. The backend resolves resolution from policy when the query sends `null`, and `MetricPlot` computes zoom resolutions from `targetPoints`. So all the legacy `resolutions`-array consumers should default to `null`. Let me apply the fixes.

**1. JobListRow.svelte** — the source of the job-list crash:


Edit

  /* Derived */
  const resampleDefault = $derived(resampleConfig ? Math.max(...resampleConfig.resolutions) : 0);
  const jobId = $derived(job.id);

  /* Derived */
  const jobId = $derived(job.id);


The file /Users/jan/prg/ClusterCockpit/cc-backend/web/frontend/src/generic/joblist/JobListRow.svelte has been updated successfully. (file state is current in your context — no need to Read it back)

  /* Derived */
  const resampleDefault = $derived(resampleConfig ? Math.max(...resampleConfig.resolutions) : 0);
  const jobId = $derived(job.id);

  /* Derived */
  const jobId = $derived(job.id);

    <JobInfo {job} bind:isSelected showJobSelect/>

  {#if job.monitoringStatus == 0 || job.monitoringStatus == 2}

      <Card body color="warning">Not monitored or archiving failed</Card>

  {:else if $metricsQuery.fetching}

      <Spinner secondary />

  {:else if $metricsQuery.error}

      <Card body color="danger" class="mb-3">
        {$metricsQuery.error.message.length > 500
          ? $metricsQuery.error.message.substring(0, 499) + "..."
          : $metricsQuery.error.message}
      </Card>

  {:else}
    {#if showFootprint}

        <JobFootprint
          {job}
          width={plotWidth}
          height="{plotHeight}px"
          displayTitle={false}
        />

    {/if}
    {#each refinedData as metric, i (metric?.name || i)}

        {#if metric?.availability == "none"}
          <Card body class="mx-2" color="light">

No dataset(s) returned for {metrics[i]}

Metric is not configured for cluster {job.cluster}.

          </Card>
        {:else if metric?.availability == "disabled"}
          <Card body class="mx-2" color="info">

No dataset(s) returned for {metrics[i]}

Metric has been disabled for subcluster {job.subCluster}.

          </Card>
        {:else if metric?.data}
          <MetricPlot
            onZoom={(detail) => handleZoom(detail, metric.data.name)}
            height={plotHeight}
            timestep={metric.data.metric.timestep}
            scope={metric.data.scope}
            series={metric.data.metric.series}
            statisticsSeries={metric.data.metric?.statisticsSeries}
            metric={metric.data.name}
            cluster={clusterInfos.find((c) => c.name == job.cluster)}
            subCluster={job.subCluster}
            isShared={job.shared != "none"}
            zoomState={zoomStates[metric.data.name] || null}
            thresholdState={thresholdStates[metric.data.name] || null}
            {plotSync}
          />
        {:else}
          <Card body class="mx-2" color="warning">

No dataset(s) returned for {metrics[i]}

Metric or host was not found in metric store for cluster {job.cluster}:

Identical messages in {metrics[i]} column: Metric not found.

Identical messages in job {job.jobId} row: Host not found.

          </Card>
        {/if}

    {:else}

        <Card body class="mx-2">No metrics selected for display.</Card>

    {/each}
  {/if}

-  const resampleDefault = $derived(resampleConfig ? Math.max(...resampleConfig.resolutions) : 0);


  let selectedResolution = $derived(resampleDefault);
  let isSelected = $derived(previousSelect);

  // null lets the backend resolve the resolution from the configured resample
  // policy; zoom interactions override it with an explicit value.
  let selectedResolution = $state(null);
  let isSelected = $derived(previousSelect);


  let selectedResolution = $derived(resampleDefault);
  let isSelected = $derived(previousSelect);

  // null lets the backend resolve the resolution from the configured resample
  // policy; zoom interactions override it with an explicit value.
  let selectedResolution = $state(null);
  let isSelected = $derived(previousSelect);

-  let selectedResolution = $derived(resampleDefault);

+  // null lets the backend resolve the resolution from the configured resample

+  // policy; zoom interactions override it with an explicit value.

+  let selectedResolution = $state(null);

let metricsQuery = $derived(queryStore({


**2. job/Metric.svelte:**


  const resampleConfig = getContext("resampling") || null;
  const resampleDefault = resampleConfig ? Math.max(...resampleConfig.resolutions) : 0;
  const subQuery = gql`

  const resampleConfig = getContext("resampling") || null;
  const subQuery = gql`


The file /Users/jan/prg/ClusterCockpit/cc-backend/web/frontend/src/job/Metric.svelte has been updated successfully. (file state is current in your context — no need to Read it back)

  const resampleConfig = getContext("resampling") || null;
  const subQuery = gql`

<!--
  @component Metric plot wrapper with user scope selection; used in job detail view

  Properties:
    - `job Object`: The GQL job object
    - `metricName String`: The metrics name
    - `metricUnit Object`: The metrics GQL unit object
    - `nativeScope String`: The metrics native scope
    - `presetScopes [String]`: The preset scopes returned for this metric
    - `isShared Bool?`: If this job used shared resources; will adapt threshold indicators accordingly in downstream plots [Default: false]
-->

<script>
  import {
    getContext,
  } from "svelte";
  import {
    queryStore,
    gql,
    getContextClient
  } from "@urql/svelte";
  import {
    InputGroup,
    InputGroupText,
    Spinner,
    Card,
  } from "@sveltestrap/sveltestrap";
  import {
    minScope,
  } from "../generic/utils.js";
  import MetricPlot from "../generic/plots/MetricPlot.svelte";

  /* Svelte 5 Props */
  let {
    job,
    metricName,
    metricUnit,
    nativeScope,
    presetScopes,
    isShared = false,
  } = $props();

  /* Const Init */
  const client = getContextClient();
  const statsPattern = /(.*)-stat$/;
  const resampleConfig = getContext("resampling") || null;
  const resampleDefault = resampleConfig ? Math.max(...resampleConfig.resolutions) : 0;
  const subQuery = gql`
    query ($dbid: ID!, $selectedMetrics: [String!]!, $selectedScopes: [MetricScope!]!, $selectedResolution: Int) {
      singleUpdate: jobMetrics(id: $dbid, metrics: $selectedMetrics, scopes: $selectedScopes, resolution: $selectedResolution) {
        name
        scope
        metric {
          unit {
            prefix
            base
          }
          timestep
          statisticsSeries {
            min
            mean
            median
            max
          }
          series {
            hostname
            id
            data
            statistics {
              min
              avg
              max
            }
          }
        }
      }
    }
  `;

  /* State Init */
  let selectedResolution = $state(resampleDefault);
  let selectedHost = $state(null);
  let zoomState = $state(null);
  let thresholdState = $state(null);

  /* Derived */
  let requestedScopes = $derived(presetScopes);
  const unit = $derived.by(() => { return (metricUnit?.prefix ? metricUnit.prefix : "") + (metricUnit?.base ? metricUnit.base : "")});
  const metricData = $derived(queryStore({
      client: client,
      query: subQuery,
      variables: {
        dbid: job.id,
        selectedMetrics: [metricName],
        selectedScopes: [...requestedScopes],
        selectedResolution
      },
      // Never user network-only: causes reactive load-loop!
    })
  );

  const rawData = $derived($metricData?.data ? $metricData.data.singleUpdate.map((x) => x.metric) : []);
  const availableScopes = $derived($metricData?.data ? $metricData.data.singleUpdate.map((x) => x.scope) : presetScopes);
  let selectedScope = $derived(minScope(availableScopes));
  const patternMatches = $derived(statsPattern.exec(selectedScope));
  const selectedScopeIndex = $derived.by(() => {
    if (!patternMatches) {
      return availableScopes.findIndex((s) => s == selectedScope);
    } else {
      return availableScopes.findIndex((s) => s == patternMatches[1]);
    }
  });
  const selectedData = $derived(rawData[selectedScopeIndex]);
  const selectedSeries = $derived(rawData[selectedScopeIndex]?.series?.filter(
      (series) => selectedHost == null || series.hostname == selectedHost
    )
  );
  const statsSeries = $derived(rawData.map((rd) => rd?.statisticsSeries ? rd.statisticsSeries : null));

  /* Effect */
  $effect(() => {
    // Only triggered once
    if (selectedScope == "load-all") {
      requestedScopes = ["node", "socket", "core", "accelerator"];
    }
  });

  /* Functions */
  function handleZoom(detail) {
    // Buffer last zoom state to allow seamless zoom on rerender
    // console.log('Update zoomState with:', {...detail.lastZoomState})
    zoomState = detail?.lastZoomState ? {...detail.lastZoomState} : null;
    // Handle to correctly reset on summed metric scope change
    // console.log('Update thresholdState with:', detail.lastThreshold)
    thresholdState = detail?.lastThreshold ? detail.lastThreshold : null;
    // Triggers GQL
    if (detail?.newRes) {
      // console.log('Update selectedResolution with:', detail.newRes)
      selectedResolution = detail.newRes;
    }
  };
</script>

<InputGroup class="mt-2">
  <InputGroupText style="min-width: 150px;">
    {metricName} ({unit})
  </InputGroupText>
  <select class="form-select" bind:value={selectedScope}>
    {#each availableScopes as scope, index}
      <option value={scope}>{scope}</option>
      {#if statsSeries[index]}
        <option value={scope + '-stat'}>stats series ({scope})</option>
      {/if}
    {/each}
    {#if requestedScopes.length == 1 && nativeScope != "node"}
      <option value={"load-all"}>Load all...</option>
    {/if}
  </select>
  {#if job.resources.length > 1}
    <select class="form-select" bind:value={selectedHost} disabled={patternMatches}>
      <option value={null}>All Hosts</option>
      {#each job.resources as { hostname }}
        <option value={hostname}>{hostname}</option>
      {/each}
    </select>
  {/if}
</InputGroup>
{#key selectedSeries}
  {#if $metricData.fetching}
    <Spinner />
  {:else if $metricData.error}
    <Card body color="danger">{$metricData.error.message}</Card>
  {:else if selectedSeries != null && !patternMatches}
    <MetricPlot
      onZoom={(detail) => handleZoom(detail)}
      cluster={job.cluster}
      subCluster={job.subCluster}
      timestep={selectedData.timestep}
      scope={selectedScope}
      metric={metricName}
      series={selectedSeries}
      {isShared}
      {zoomState}
      {thresholdState}
      enableFlip
    />
  {:else if statsSeries[selectedScopeIndex] != null && patternMatches}
    <MetricPlot
      onZoom={(detail) => handleZoom(detail)}
      cluster={job.cluster}
      subCluster={job.subCluster}
      timestep={selectedData.timestep}
      scope={selectedScope}
      metric={metricName}
      series={selectedSeries}
      {isShared}
      {zoomState}
      {thresholdState}
      statisticsSeries={statsSeries[selectedScopeIndex]}
      enableFlip
    />
  {/if}
{/key}

-  const resampleDefault = resampleConfig ? Math.max(...resampleConfig.resolutions) : 0;


  /* State Init */
  let selectedResolution = $state(resampleDefault);

  /* State Init */
  // null lets the backend resolve the resolution from the configured resample
  // policy; zoom interactions override it with an explicit value.
  let selectedResolution = $state(null);


  /* State Init */
  // null lets the backend resolve the resolution from the configured resample
  // policy; zoom interactions override it with an explicit value.
  let selectedResolution = $state(null);

<!--
  @component Metric plot wrapper with user scope selection; used in job detail view

  Properties:
    - `job Object`: The GQL job object
    - `metricName String`: The metrics name
    - `metricUnit Object`: The metrics GQL unit object
    - `nativeScope String`: The metrics native scope
    - `presetScopes [String]`: The preset scopes returned for this metric
    - `isShared Bool?`: If this job used shared resources; will adapt threshold indicators accordingly in downstream plots [Default: false]
-->

<script>
  import {
    getContext,
  } from "svelte";
  import {
    queryStore,
    gql,
    getContextClient
  } from "@urql/svelte";
  import {
    InputGroup,
    InputGroupText,
    Spinner,
    Card,
  } from "@sveltestrap/sveltestrap";
  import {
    minScope,
  } from "../generic/utils.js";
  import MetricPlot from "../generic/plots/MetricPlot.svelte";

  /* Svelte 5 Props */
  let {
    job,
    metricName,
    metricUnit,
    nativeScope,
    presetScopes,
    isShared = false,
  } = $props();

  /* Const Init */
  const client = getContextClient();
  const statsPattern = /(.*)-stat$/;
  const resampleConfig = getContext("resampling") || null;
  const subQuery = gql`
    query ($dbid: ID!, $selectedMetrics: [String!]!, $selectedScopes: [MetricScope!]!, $selectedResolution: Int) {
      singleUpdate: jobMetrics(id: $dbid, metrics: $selectedMetrics, scopes: $selectedScopes, resolution: $selectedResolution) {
        name
        scope
        metric {
          unit {
            prefix
            base
          }
          timestep
          statisticsSeries {
            min
            mean
            median
            max
          }
          series {
            hostname
            id
            data
            statistics {
              min
              avg
              max
            }
          }
        }
      }
    }
  `;

  /* State Init */
  let selectedResolution = $state(resampleDefault);
  let selectedHost = $state(null);
  let zoomState = $state(null);
  let thresholdState = $state(null);

  /* Derived */
  let requestedScopes = $derived(presetScopes);
  const unit = $derived.by(() => { return (metricUnit?.prefix ? metricUnit.prefix : "") + (metricUnit?.base ? metricUnit.base : "")});
  const metricData = $derived(queryStore({
      client: client,
      query: subQuery,
      variables: {
        dbid: job.id,
        selectedMetrics: [metricName],
        selectedScopes: [...requestedScopes],
        selectedResolution
      },
      // Never user network-only: causes reactive load-loop!
    })
  );

  const rawData = $derived($metricData?.data ? $metricData.data.singleUpdate.map((x) => x.metric) : []);
  const availableScopes = $derived($metricData?.data ? $metricData.data.singleUpdate.map((x) => x.scope) : presetScopes);
  let selectedScope = $derived(minScope(availableScopes));
  const patternMatches = $derived(statsPattern.exec(selectedScope));
  const selectedScopeIndex = $derived.by(() => {
    if (!patternMatches) {
      return availableScopes.findIndex((s) => s == selectedScope);
    } else {
      return availableScopes.findIndex((s) => s == patternMatches[1]);
    }
  });
  const selectedData = $derived(rawData[selectedScopeIndex]);
  const selectedSeries = $derived(rawData[selectedScopeIndex]?.series?.filter(
      (series) => selectedHost == null || series.hostname == selectedHost
    )
  );
  const statsSeries = $derived(rawData.map((rd) => rd?.statisticsSeries ? rd.statisticsSeries : null));

  /* Effect */
  $effect(() => {
    // Only triggered once
    if (selectedScope == "load-all") {
      requestedScopes = ["node", "socket", "core", "accelerator"];
    }
  });

  /* Functions */
  function handleZoom(detail) {
    // Buffer last zoom state to allow seamless zoom on rerender
    // console.log('Update zoomState with:', {...detail.lastZoomState})
    zoomState = detail?.lastZoomState ? {...detail.lastZoomState} : null;
    // Handle to correctly reset on summed metric scope change
    // console.log('Update thresholdState with:', detail.lastThreshold)
    thresholdState = detail?.lastThreshold ? detail.lastThreshold : null;
    // Triggers GQL
    if (detail?.newRes) {
      // console.log('Update selectedResolution with:', detail.newRes)
      selectedResolution = detail.newRes;
    }
  };
</script>

<InputGroup class="mt-2">
  <InputGroupText style="min-width: 150px;">
    {metricName} ({unit})
  </InputGroupText>
  <select class="form-select" bind:value={selectedScope}>
    {#each availableScopes as scope, index}
      <option value={scope}>{scope}</option>
      {#if statsSeries[index]}
        <option value={scope + '-stat'}>stats series ({scope})</option>
      {/if}
    {/each}
    {#if requestedScopes.length == 1 && nativeScope != "node"}
      <option value={"load-all"}>Load all...</option>
    {/if}
  </select>
  {#if job.resources.length > 1}
    <select class="form-select" bind:value={selectedHost} disabled={patternMatches}>
      <option value={null}>All Hosts</option>
      {#each job.resources as { hostname }}
        <option value={hostname}>{hostname}</option>
      {/each}
    </select>
  {/if}
</InputGroup>
{#key selectedSeries}
  {#if $metricData.fetching}
    <Spinner />
  {:else if $metricData.error}
    <Card body color="danger">{$metricData.error.message}</Card>
  {:else if selectedSeries != null && !patternMatches}
    <MetricPlot
      onZoom={(detail) => handleZoom(detail)}
      cluster={job.cluster}
      subCluster={job.subCluster}
      timestep={selectedData.timestep}
      scope={selectedScope}
      metric={metricName}
      series={selectedSeries}
      {isShared}
      {zoomState}
      {thresholdState}
      enableFlip
    />
  {:else if statsSeries[selectedScopeIndex] != null && patternMatches}
    <MetricPlot
      onZoom={(detail) => handleZoom(detail)}
      cluster={job.cluster}
      subCluster={job.subCluster}
      timestep={selectedData.timestep}
      scope={selectedScope}
      metric={metricName}
      series={selectedSeries}
      {isShared}
      {zoomState}
      {thresholdState}
      statisticsSeries={statsSeries[selectedScopeIndex]}
      enableFlip
    />
  {/if}
{/key}

-  let selectedResolution = $state(resampleDefault);


**3. Systems.root.svelte** — remove the legacy derived values, default to `null`, and remove the now-defunct resolution dropdown:


  const resampleConfig = $derived(thisInit ? getContext("resampling") : null);
  const resampleResolutions = $derived(resampleConfig ? [...resampleConfig.resolutions] : []);
  const resampleDefault = $derived(resampleConfig ? Math.max(...resampleConfig.resolutions) : 0);
  const displayNodeOverview = $derived((displayType === 'OVERVIEW'));

  const resampleConfig = $derived(thisInit ? getContext("resampling") : null);
  const displayNodeOverview = $derived((displayType === 'OVERVIEW'));