Implement the following plan:

# Plan: Per-User Resampling Preferences with Policy-Based Configuration

## Context

Resampling is currently configured via explicit numerical parameters in `config.json` (`minimum-points`, `resolutions`, `trigger`). The resampling algorithm isn't configurable at all from the frontend. Users should be able to control two things from the UI settings:

1. **Resampling policy** (low/medium/high) — controls how aggressively data is downsampled, replacing the explicit `resolutions`/`minimum-points`/`trigger` numbers with automatic computation based on job duration and metric frequency
2. **Resampling algorithm** (LTTB/AVERAGE/SIMPLE) — controls which downsampling method is used

Both are persisted per-user. Zoom-to-refine continues to work, dynamically recomputing resolution from the visible time window.

## Design

### Policy mechanics

A policy defines a **target datapoint count**. The resolution is computed dynamically:

| Policy | Target points | Use case |
|--------|--------------|----------|
| Low | ~200 | Fast rendering, overview |
| Medium | ~500 | Balanced (default) |
| High | ~1000 | Maximum detail |

**Resolution computation** (new helper function):
```
targetResolution = ceil(duration / targetPoints)
// Round up to nearest multiple of metric frequency
resolution = ceil(targetResolution / frequency) * frequency
// If totalPoints <= targetPoints, no resampling (resolution = 0)
```

**Example** (60s frequency, 24h job = 1440 points):
- Low (200): resolution = ceil(86400/200) = 432 → round to 480s (8×60)
- Medium (500): resolution = ceil(86400/500) = 173 → round to 180s (3×60)
- High (1000): resolution = ceil(86400/1000) = 87 → round to 120s (2×60)

**Example** (60s frequency, 2h job = 120 points):
- All policies: 120 ≤ 200, no resampling at any level

### Zoom refinement

Currently the frontend zoom uses `resampleConfig.trigger` and `resampleConfig.resolutions` to pick the next resolution level. With policies, this changes to:

- The **trigger** is derived from the policy's target points (e.g., trigger = targetPoints / 4, so ~50/125/250)
- Instead of picking from a fixed `resolutions` array, the frontend computes the new resolution dynamically: `newRes = ceil(visibleWindowDuration / targetPoints / frequency) * frequency`
- The `resampleConfig` passed to the frontend changes from `{trigger, resolutions, minimumPoints}` to `{targetPoints, trigger}` — both derived from the policy

### Config keys

- `plotConfiguration_resampleAlgo` — string: `""` (default/none), `"lttb"`, `"average"`, `"simple"`
- `plotConfiguration_resamplePolicy` — string: `""` (default/none), `"low"`, `"medium"`, `"high"`

### What replaces what

| Old (explicit config.json) | New (policy-derived) |
|---------------------------|---------------------|
| `resolutions: [600,300,60]` | Computed from policy + job duration + frequency |
| `minimum-points: 600` | Implicit: if totalPoints ≤ targetPoints, skip resampling |
| `trigger: 30` | Derived from targetPoints (targetPoints / 4) |
| No algo choice | Per-user `resampleAlgo` setting |

The global `EnableResampling` config still acts as the **on/off switch** — if not configured, no resampling happens at all. But the explicit numerical parameters become optional overrides; the policy takes precedence when set.

## Implementation Steps

### Step 1: Add policy constants and resolution computation helper

**New file: `internal/metricdispatch/resamplepolicy.go`**

```go
type ResamplePolicy string
const (
    ResamplePolicyLow    ResamplePolicy = "low"
    ResamplePolicyMedium ResamplePolicy = "medium"
    ResamplePolicyHigh   ResamplePolicy = "high"
)

func TargetPointsForPolicy(policy ResamplePolicy) int
func ComputeResolution(duration int64, frequency int64, targetPoints int) int
```

`ComputeResolution` returns 0 (no resampling) when total points ≤ target, otherwise the rounded resolution in seconds.

### Step 2: Add UIDefaults entries — `web/web.go`

Add to `PlotConfiguration` struct:
```go
ResampleAlgo   string `json:"resample-algo"`
ResamplePolicy string `json:"resample-policy"`
```

Add to `UIDefaultsMap` in `Init()`:
```go
UIDefaultsMap["plotConfiguration_resampleAlgo"] = UIDefaults.PlotConfiguration.ResampleAlgo
UIDefaultsMap["plotConfiguration_resamplePolicy"] = UIDefaults.PlotConfiguration.ResamplePolicy
```

Defaults: both `""` (empty = no preference, fall back to current behavior).

### Step 3: Backend resolvers — `internal/graph/schema.resolvers.go`

**In `JobMetrics` resolver (~line 501):**

When `resolution == nil` AND user has a resample policy:
1. Read `plotConfiguration_resamplePolicy` from user config
2. Get the job (already fetched at line 512)
3. Look up the metric frequency from cluster config
4. Call `ComputeResolution(job.Duration, frequency, targetPoints)`
5. Use the computed resolution instead of `max(resolutions)`

When `resampleAlgo == nil`:
1. Read `plotConfiguration_resampleAlgo` from user config
2. Use it as `algoName`

Fall back to existing `config.Keys.EnableResampling` logic when no user preference is set.

**In `NodeMetricsList` resolver (~line 880):**
Same pattern. Duration comes from `to - from` parameters.

### Step 4: Frontend zoom — `web/frontend/src/generic/plots/MetricPlot.svelte`

Change the `resampleConfig` context shape. Currently: `{trigger, resolutions, minimumPoints}`. New: also include `targetPoints` when policy is active.

Modify `setScale` hook (line 515-533):
- Instead of finding closest from `resampleResolutions` array, compute dynamically:
  ```js
  const visibleDuration = (u.scales.x.max - u.scales.x.min);
  const newRes = Math.ceil(visibleDuration / targetPoints / timestep) * timestep;
  ```
- Keep the trigger check: `if (numX <= trigger && timestep !== frequency)`
- The `frequency` (native metric timestep) becomes the floor — never request finer than native

This is backward-compatible: if `resampleConfig` has old-style `resolutions` array, use the existing logic.

### Step 5: Pass policy-derived config to frontend

**In route handlers** (where `Page.Resampling` is set): When a user has a policy, compute `targetPoints` and `trigger` from the policy and include them in the `ResampleConfig` passed to the template.

Key files to check: search for where `Page.Resampling` is populated.

### Step 6: Frontend settings UI — `web/frontend/src/config/user/PlotRenderOptions.svelte`

Add two new cards:

**Resample Policy card:**
- Radio buttons: Default, Low, Medium, High
- Key: `plotConfiguration_resamplePolicy`
- Help text: "Controls how many data points are shown in metric plots. Low = fast overview (~200 points), Medium = balanced (~500), High = maximum detail (~1000)."

**Resample Algorithm card:**
- Radio buttons: Default, LTTB, Average, Simple
- Key: `plotConfiguration_resampleAlgo`
- Help text: "Algorithm used when downsampling time-series data. LTTB preserves visual shape, Average smooths data, Simple picks every Nth point."

Both cards shown only when resampling is globally enabled (check `resampleConfig` via context — thread it as prop from `UserSettings.svelte`).

## Files to Modify

| File | Change |
|------|--------|
| `internal/metricdispatch/resamplepolicy.go` | **NEW** — policy constants + `ComputeResolution()` helper |
| `web/web.go` | Add `ResampleAlgo`/`ResamplePolicy` to `PlotConfiguration` + UIDefaultsMap |
| `internal/graph/schema.resolvers.go` | Read user policy/algo preferences; compute resolution from policy |
| `web/frontend/src/generic/plots/MetricPlot.svelte` | Dynamic resolution computation on zoom (alongside existing array-based logic) |
| `web/frontend/src/config/user/PlotRenderOptions.svelte` | Two new settings cards |
| `web/frontend/src/config/UserSettings.svelte` | Thread `resampleConfig` context to PlotRenderOptions |

## What Does NOT Change

- GraphQL schema — `resolution: Int` and `resampleAlgo: ResampleAlgo` params already exist
- Database schema — `configuration` table already handles arbitrary key-value pairs
- cc-lib resampler — called with computed values, no API change needed
- `config.json` `EnableResampling` — still the global on/off switch; explicit values remain as fallback

## Verification

1. `go build ./cmd/cc-backend` — compiles
2. `go test ./internal/metricdispatch/...` — test `ComputeResolution()` with various durations/frequencies/policies
3. `go test ./...` — all existing tests pass
4. Manual: set policy to "low", load a long job → verify fewer datapoints in response (check `timestep` field)
5. Manual: zoom into a plot → verify it refetches at finer resolution
6. Manual: set algo to "average", verify resampled data uses averaging
7. Manual: no preferences set → verify identical behavior to current (backward compat)

If you need specific details from before exiting plan mode (like exact code snippets, error messages, or content you generated), read the full transcript at: /Users/jan/.claude/projects/-Users-jan-tmp-cc-backend/6f9f7d00-7153-46bb-97c8-ea14c48ceb2f.jsonl


I'll start by reading the key files I need to understand before implementing.


     1→// Copyright (C) NHR@FAU, University Erlangen-Nuremberg.
     2→// All rights reserved. This file is part of cc-backend.
     3→// Use of this source code is governed by a MIT-style
     4→// license that can be found in the LICENSE file.
     5→
     6→// Package web implements the HTML templating and web frontend configuration
     7→package web
     8→
     9→import (
    10→	"embed"
    11→	"encoding/json"
    12→	"html/template"
    13→	"io/fs"
    14→	"net/http"
    15→	"strings"
    16→
    17→	"github.com/ClusterCockpit/cc-backend/internal/config"
    18→	"github.com/ClusterCockpit/cc-backend/pkg/archive"
    19→	cclog "github.com/ClusterCockpit/cc-lib/v2/ccLogger"
    20→	"github.com/ClusterCockpit/cc-lib/v2/schema"
    21→	"github.com/ClusterCockpit/cc-lib/v2/util"
    22→)
    23→
    24→type WebConfig struct {
    25→	JobList           JobListConfig     `json:"job-list"`
    26→	NodeList          NodeListConfig    `json:"node-list"`
    27→	JobView           JobViewConfig     `json:"job-view"`
    28→	MetricConfig      MetricConfig      `json:"metric-config"`
    29→	PlotConfiguration PlotConfiguration `json:"plot-configuration"`
    30→}
    31→
    32→type JobListConfig struct {
    33→	UsePaging     bool `json:"use-paging"`
    34→	ShowFootprint bool `json:"show-footprint"`
    35→}
    36→
    37→type NodeListConfig struct {
    38→	UsePaging bool `json:"use-paging"`
    39→}
    40→
    41→type JobViewConfig struct {
    42→	ShowPolarPlot bool `json:"show-polar-plot"`
    43→	ShowFootprint bool `json:"show-footprint"`
    44→	ShowRoofline  bool `json:"show-roofline"`
    45→	ShowStatTable bool `json:"show-stat-table"`
    46→}
    47→
    48→type MetricConfig struct {
    49→	JobListMetrics      []string        `json:"job-list-metrics"`
    50→	JobViewPlotMetrics  []string        `json:"job-view-plot-metrics"`
    51→	JobViewTableMetrics []string        `json:"job-view-table-metrics"`
    52→	Clusters            []ClusterConfig `json:"clusters"`
    53→}
    54→
    55→type ClusterConfig struct {
    56→	Name                string             `json:"name"`
    57→	JobListMetrics      []string           `json:"job-list-metrics"`
    58→	JobViewPlotMetrics  []string           `json:"job-view-plot-metrics"`
    59→	JobViewTableMetrics []string           `json:"job-view-table-metrics"`
    60→	SubClusters         []SubClusterConfig `json:"sub-clusters"`
    61→}
    62→
    63→type SubClusterConfig struct {
    64→	Name                string   `json:"name"`
    65→	JobListMetrics      []string `json:"job-list-metrics"`
    66→	JobViewPlotMetrics  []string `json:"job-view-plot-metrics"`
    67→	JobViewTableMetrics []string `json:"job-view-table-metrics"`
    68→}
    69→
    70→type PlotConfiguration struct {
    71→	ColorBackground bool     `json:"color-background"`
    72→	PlotsPerRow     int      `json:"plots-per-row"`
    73→	LineWidth       int      `json:"line-width"`
    74→	ColorScheme     []string `json:"color-scheme"`
    75→}
    76→
    77→var UIDefaults = WebConfig{
    78→	JobList: JobListConfig{
    79→		UsePaging:     false,
    80→		ShowFootprint: false,
    81→	},
    82→	NodeList: NodeListConfig{
    83→		UsePaging: false,
    84→	},
    85→	JobView: JobViewConfig{
    86→		ShowPolarPlot: true,
    87→		ShowFootprint: false,
    88→		ShowRoofline:  true,
    89→		ShowStatTable: true,
    90→	},
    91→	MetricConfig: MetricConfig{
    92→		JobListMetrics:      []string{"cpu_load", "flops_any", "mem_bw", "mem_used"},
    93→		JobViewPlotMetrics:  []string{"cpu_load", "flops_any", "mem_bw", "mem_used"},
    94→		JobViewTableMetrics: []string{"flops_any", "mem_bw", "mem_used"},
    95→	},
    96→	PlotConfiguration: PlotConfiguration{
    97→		ColorBackground: true,
    98→		PlotsPerRow:     3,
    99→		LineWidth:       3,
   100→		ColorScheme:     []string{"#00bfff", "#0000ff", "#ff00ff", "#ff0000", "#ff8000", "#ffff00", "#80ff00"},
   101→	},
   102→}
   103→
   104→var UIDefaultsMap map[string]any
   105→
   106→//
   107→// 	map[string]any{
   108→// 	"analysis_view_histogramMetrics":         []string{"flops_any", "mem_bw", "mem_used"},
   109→// 	"analysis_view_scatterPlotMetrics":       [][]string{{"flops_any", "mem_bw"}, {"flops_any", "cpu_load"}, {"cpu_load", "mem_bw"}},
   110→// 	"job_view_nodestats_selectedMetrics":     []string{"flops_any", "mem_bw", "mem_used"},
   111→// 	"plot_list_jobsPerPage":                  50,
   112→// 	"analysis_view_selectedTopEntity":        "user",
   113→// 	"analysis_view_selectedTopCategory":      "totalWalltime",
   114→// 	"status_view_selectedTopUserCategory":    "totalJobs",
   115→// 	"status_view_selectedTopProjectCategory": "totalJobs",
   116→// }
   117→
   118→func Init(rawConfig json.RawMessage) error {
   119→	var err error
   120→
   121→	if rawConfig != nil {
   122→		config.Validate(configSchema, rawConfig)
   123→		if err = json.Unmarshal(rawConfig, &UIDefaults); err != nil {
   124→			cclog.Warn("Error while unmarshaling raw config json")
   125→			return err
   126→		}
   127→	}
   128→
   129→	UIDefaultsMap = make(map[string]any)
   130→
   131→	UIDefaultsMap["jobList_usePaging"] = UIDefaults.JobList.UsePaging
   132→	UIDefaultsMap["jobList_showFootprint"] = UIDefaults.JobList.ShowFootprint
   133→	UIDefaultsMap["nodeList_usePaging"] = UIDefaults.NodeList.UsePaging
   134→	UIDefaultsMap["jobView_showPolarPlot"] = UIDefaults.JobView.ShowPolarPlot
   135→	UIDefaultsMap["jobView_showFootprint"] = UIDefaults.JobView.ShowFootprint
   136→	UIDefaultsMap["jobView_showRoofline"] = UIDefaults.JobView.ShowRoofline
   137→	UIDefaultsMap["jobView_showStatTable"] = UIDefaults.JobView.ShowStatTable
   138→
   139→	UIDefaultsMap["metricConfig_jobListMetrics"] = UIDefaults.MetricConfig.JobListMetrics
   140→	UIDefaultsMap["metricConfig_jobViewPlotMetrics"] = UIDefaults.MetricConfig.JobViewPlotMetrics
   141→	UIDefaultsMap["metricConfig_jobViewTableMetrics"] = UIDefaults.MetricConfig.JobViewTableMetrics
   142→
   143→	UIDefaultsMap["plotConfiguration_colorBackground"] = UIDefaults.PlotConfiguration.ColorBackground
   144→	UIDefaultsMap["plotConfiguration_plotsPerRow"] = UIDefaults.PlotConfiguration.PlotsPerRow
   145→	UIDefaultsMap["plotConfiguration_lineWidth"] = UIDefaults.PlotConfiguration.LineWidth
   146→	UIDefaultsMap["plotConfiguration_colorScheme"] = UIDefaults.PlotConfiguration.ColorScheme
   147→
   148→	for _, c := range UIDefaults.MetricConfig.Clusters {
   149→		if c.JobListMetrics != nil {
   150→			UIDefaultsMap["metricConfig_jobListMetrics:"+c.Name] = c.JobListMetrics
   151→		}
   152→		if c.JobViewPlotMetrics != nil {
   153→			UIDefaultsMap["metricConfig_jobViewPlotMetrics:"+c.Name] = c.JobViewPlotMetrics
   154→		}
   155→		if c.JobViewTableMetrics != nil {
   156→			UIDefaultsMap["metricConfig_jobViewTableMetrics:"+c.Name] = c.JobViewTableMetrics
   157→		}
   158→
   159→		for _, sc := range c.SubClusters {
   160→			suffix := strings.Join([]string{c.Name, sc.Name}, ":")
   161→			if sc.JobListMetrics != nil {
   162→				UIDefaultsMap["metricConfig_jobListMetrics:"+suffix] = sc.JobListMetrics
   163→			}
   164→			if sc.JobViewPlotMetrics != nil {
   165→				UIDefaultsMap["metricConfig_jobViewPlotMetrics:"+suffix] = sc.JobViewPlotMetrics
   166→			}
   167→			if sc.JobViewTableMetrics != nil {
   168→				UIDefaultsMap["metricConfig_jobViewTableMetrics:"+suffix] = sc.JobViewTableMetrics
   169→			}
   170→		}
   171→	}
   172→
   173→	return err
   174→}
   175→
   176→// / Go's embed is only allowed to embed files in a subdirectory of the embedding package ([see here](https://github.com/golang/go/issues/46056)).
   177→//
   178→//go:embed frontend/public/*
   179→var frontendFiles embed.FS
   180→
   181→func ServeFiles() http.Handler {
   182→	publicFiles, err := fs.Sub(frontendFiles, "frontend/public")
   183→	if err != nil {
   184→		cclog.Abortf("Serve Files: Could not find 'frontend/public' file directory.\nError: %s\n", err.Error())
   185→	}
   186→	return http.FileServer(http.FS(publicFiles))
   187→}
   188→
   189→// StaticFileExists checks whether a static file exists in the embedded frontend FS.
   190→func StaticFileExists(path string) bool {
   191→	path = strings.TrimPrefix(path, "/")
   192→	if path == "" {
   193→		return false
   194→	}
   195→	_, err := fs.Stat(frontendFiles, "frontend/public/"+path)
   196→	return err == nil
   197→}
   198→
   199→//go:embed templates/*
   200→var templateFiles embed.FS
   201→
   202→var templates map[string]*template.Template = map[string]*template.Template{}
   203→
   204→func init() {
   205→	base := template.Must(template.ParseFS(templateFiles, "templates/base.tmpl"))
   206→	if err := fs.WalkDir(templateFiles, "templates", func(path string, d fs.DirEntry, err error) error {
   207→		if err != nil {
   208→			return err
   209→		}
   210→		if d.IsDir() || path == "templates/base.tmpl" {
   211→			return nil
   212→		}
   213→
   214→		if path == "templates/404.tmpl" {
   215→			templates[strings.TrimPrefix(path, "templates/")] = template.Must(template.ParseFS(templateFiles, path))
   216→			return nil
   217→		}
   218→		if path == "templates/login.tmpl" {
   219→			if util.CheckFileExists("./var/login.tmpl") {
   220→				cclog.Info("overwrite login.tmpl with local file")
   221→				templates[strings.TrimPrefix(path, "templates/")] = template.Must(template.Must(base.Clone()).ParseFiles("./var/login.tmpl"))
   222→				return nil
   223→			}
   224→		}
   225→		if path == "templates/imprint.tmpl" {
   226→			if util.CheckFileExists("./var/imprint.tmpl") {
   227→				cclog.Info("overwrite imprint.tmpl with local file")
   228→				templates[strings.TrimPrefix(path, "templates/")] = template.Must(template.Must(base.Clone()).ParseFiles("./var/imprint.tmpl"))
   229→				return nil
   230→			}
   231→		}
   232→		if path == "templates/privacy.tmpl" {
   233→			if util.CheckFileExists("./var/privacy.tmpl") {
   234→				cclog.Info("overwrite privacy.tmpl with local file")
   235→				templates[strings.TrimPrefix(path, "templates/")] = template.Must(template.Must(base.Clone()).ParseFiles("./var/privacy.tmpl"))
   236→				return nil
   237→			}
   238→		}
   239→
   240→		templates[strings.TrimPrefix(path, "templates/")] = template.Must(template.Must(base.Clone()).ParseFS(templateFiles, path))
   241→		return nil
   242→	}); err != nil {
   243→		cclog.Abortf("Web init(): Could not find frontend template files.\nError: %s\n", err.Error())
   244→	}
   245→
   246→	_ = base
   247→}
   248→
   249→type Build struct {
   250→	Version   string
   251→	Hash      string
   252→	Buildtime string
   253→}
   254→
   255→type Page struct {
   256→	Title         string                 // Page title
   257→	MsgType       string                 // For generic use in message boxes
   258→	Message       string                 // For generic use in message boxes
   259→	User          schema.User            // Information about the currently logged in user (Full User Info)
   260→	Roles         map[string]schema.Role // Available roles for frontend render checks
   261→	Build         Build                  // Latest information about the application
   262→	Clusters      []string               // List of all cluster names
   263→	SubClusters   map[string][]string    // Map per cluster of all subClusters for use in the Header
   264→	FilterPresets map[string]any         // For pages with the Filter component, this can be used to set initial filters.
   265→	Infos         map[string]any         // For generic use (e.g. username for /monitoring/user/<id>, job id for /monitoring/job/<id>)
   266→	Config        map[string]any         // UI settings for the currently logged in user (e.g. line width, ...)
   267→	Resampling    *config.ResampleConfig // If not nil, defines resampling trigger and resolutions
   268→	Redirect      string                 // The originally requested URL, for intermediate login handling
   269→}
   270→
   271→func RenderTemplate(rw http.ResponseWriter, file string, page *Page) {
   272→	t, ok := templates[file]
   273→	if !ok {
   274→		cclog.Errorf("WEB/WEB > template '%s' not found", file)
   275→	}
   276→
   277→	if page.Clusters == nil {
   278→		page.Clusters = make([]string, 0)
   279→	}
   280→
   281→	if page.SubClusters == nil {
   282→		page.SubClusters = make(map[string][]string)
   283→		for _, cluster := range archive.Clusters {
   284→			page.Clusters = append(page.Clusters, cluster.Name)
   285→
   286→			for _, sc := range cluster.SubClusters {
   287→				page.SubClusters[cluster.Name] = append(page.SubClusters[cluster.Name], sc.Name)
   288→			}
   289→		}
   290→	}
   291→
   292→	cclog.Debugf("Page config : %v\n", page.Config)
   293→	if err := t.Execute(rw, page); err != nil {
   294→		cclog.Errorf("Template error: %s", err.Error())
   295→	}
   296→}
   297→

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

// Package web implements the HTML templating and web frontend configuration
package web

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

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

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

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

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

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

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

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

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

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

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

var UIDefaultsMap map[string]any

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

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

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

	UIDefaultsMap = make(map[string]any)

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

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

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

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

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

	return err
}

// / Go's embed is only allowed to embed files in a subdirectory of the embedding package ([see here](https://github.com/golang/go/issues/46056)).
//
//go:embed frontend/public/*
var frontendFiles embed.FS

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

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

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

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

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

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

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

	_ = base
}

type Build struct {
	Version   string
	Hash      string
	Buildtime string
}

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

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

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

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

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

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


   490→
   491→	if user := repository.GetUserFromContext(ctx); user != nil &&
   492→		job.User != user.Username &&
   493→		user.HasNotRoles([]schema.Role{schema.RoleAdmin, schema.RoleSupport, schema.RoleManager}) {
   494→		return nil, errors.New("you are not allowed to see this job")
   495→	}
   496→
   497→	return job, nil
   498→}
   499→
   500→// JobMetrics is the resolver for the jobMetrics field.
   501→func (r *queryResolver) JobMetrics(ctx context.Context, id string, metrics []string, scopes []schema.MetricScope, resolution *int, resampleAlgo *model.ResampleAlgo) ([]*model.JobMetricWithName, error) {
   502→	if resolution == nil { // Load from Config
   503→		if config.Keys.EnableResampling != nil {
   504→			defaultRes := slices.Max(config.Keys.EnableResampling.Resolutions)
   505→			resolution = &defaultRes
   506→		} else { // Set 0 (Loads configured metric timestep)
   507→			defaultRes := 0
   508→			resolution = &defaultRes
   509→		}
   510→	}
   511→
   512→	job, err := r.Query().Job(ctx, id)
   513→	if err != nil {
   514→		cclog.Warn("Error while querying job for metrics")
   515→		return nil, err
   516→	}
   517→
   518→	algoName := ""
   519→	if resampleAlgo != nil {
   520→		algoName = strings.ToLower(resampleAlgo.String())
   521→	}
   522→
   523→	data, err := metricdispatch.LoadData(job, metrics, scopes, ctx, *resolution, algoName)
   524→	if err != nil {
   525→		cclog.Warn("Error while loading job data")
   526→		return nil, err
   527→	}
   528→
   529→	res := []*model.JobMetricWithName{}
   530→	for name, md := range data {
   531→		for scope, metric := range md {
   532→			res = append(res, &model.JobMetricWithName{
   533→				Name:   name,
   534→				Scope:  scope,
   535→				Metric: metric,
   536→			})
   537→		}
   538→	}
   539→
   540→	return res, err
   541→}
   542→
   543→// JobStats is the resolver for the jobStats field.
   544→func (r *queryResolver) JobStats(ctx context.Context, id string, metrics []string) ([]*model.NamedStats, error) {
   545→	job, err := r.Query().Job(ctx, id)
   546→	if err != nil {
   547→		cclog.Warnf("Error while querying job %s for metadata", id)
   548→		return nil, err
   549→	}
   550→
   551→	data, err := metricdispatch.LoadJobStats(job, metrics, ctx)
   552→	if err != nil {
   553→		cclog.Warnf("Error while loading jobStats data for job id %s", id)
   554→		return nil, err
   555→	}
   556→
   557→	res := []*model.NamedStats{}
   558→	for name, md := range data {
   559→		res = append(res, &model.NamedStats{
   560→			Name: name,
   561→			Data: &md,
   562→		})
   563→	}
   564→
   565→	return res, err
   566→}
   567→
   568→// ScopedJobStats is the resolver for the scopedJobStats field.
   569→func (r *queryResolver) ScopedJobStats(ctx context.Context, id string, metrics []string, scopes []schema.MetricScope) ([]*model.NamedStatsWithScope, error) {

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

	return job, nil
}

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

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

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

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

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

	return res, err
}

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

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

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

	return res, err
}

// ScopedJobStats is the resolver for the scopedJobStats field.
func (r *queryResolver) ScopedJobStats(ctx context.Context, id string, metrics []string, scopes []schema.MetricScope) ([]*model.NamedStatsWithScope, error) {


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

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

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

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

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

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

    73→

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

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

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

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

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

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

   121→

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

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

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

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

   155→

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

   163→

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

   171→

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

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

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

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

   201→

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

   209→

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

   217→

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

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

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

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

Line Width

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

                Update: {message.msg}

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

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

            Width of the lines in the timeseries plots.

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

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

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

Plots per Row

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

                Update: {message.msg}

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

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

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

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

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

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

Colored Backgrounds

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

                Update: {message.msg}

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

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

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

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

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

Color Blind Mode

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

                Update: {message.msg}

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

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

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

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


     1→<!--
     2→  @component User settings wrapper
     3→
     4→  Properties:
     5→  - `username String!`: Empty string if auth. is disabled, otherwise the username as string
     6→  - `isApi Bool!`: Is currently logged in user api authority
     7→-->
     8→
     9→<script>
    10→  import { getContext } from "svelte";
    11→  import UserOptions from "./user/UserOptions.svelte";
    12→  import PlotRenderOptions from "./user/PlotRenderOptions.svelte";
    13→  import PlotColorScheme from "./user/PlotColorScheme.svelte";
    14→
    15→  /* Svelte 5 Props */
    16→  let {
    17→    username,
    18→    isApi
    19→  } = $props();
    20→
    21→  /* Const Init */
    22→  const ccconfig = getContext("cc-config");
    23→
    24→  /* State Init */
    25→  let message = $state({ msg: "", target: "", color: "#d63384" });
    26→  let displayMessage = $state(false);
    27→  let cbmode = $state(ccconfig?.plotConfiguration_colorblindMode || false);
    28→
    29→  /* Functions */
    30→  async function handleSettingSubmit(event, setting) {
    31→    event.preventDefault();
    32→
    33→    const selector = setting.selector
    34→    const target = setting.target
    35→    let form = document.querySelector(selector);
    36→    let formData = new FormData(form);
    37→    try {
    38→      const res = await fetch(form.action, { method: "POST", body: formData });
    39→      if (res.ok) {
    40→        let text = await res.text();
    41→        if (formData.get("key") === "plotConfiguration_colorblindMode") {
    42→          cbmode = JSON.parse(formData.get("value"));
    43→        }
    44→        popMessage(text, target, "#048109");
    45→      } else {
    46→        let text = await res.text();
    47→        throw new Error("Response Code " + res.status + "-> " + text);
    48→      }
    49→    } catch (err) {
    50→      popMessage(err, target, "#d63384");
    51→    }
    52→
    53→    return false;
    54→  }
    55→
    56→  function popMessage(response, restarget, rescolor) {
    57→    message = { msg: response, target: restarget, color: rescolor };
    58→    displayMessage = true;
    59→    setTimeout(function () {
    60→      displayMessage = false;
    61→    }, 3500);
    62→  }
    63→</script>
    64→
    65→<UserOptions config={ccconfig} {username} {isApi} bind:message bind:displayMessage updateSetting={(e, newSetting) => handleSettingSubmit(e, newSetting)}/>
    66→<PlotRenderOptions config={ccconfig} bind:message bind:displayMessage updateSetting={(e, newSetting) => handleSettingSubmit(e, newSetting)}/>
    67→<PlotColorScheme config={ccconfig} bind:cbmode bind:message bind:displayMessage updateSetting={(e, newSetting) => handleSettingSubmit(e, newSetting)}/>
    68→

<!--
  @component User settings wrapper

  Properties:
  - `username String!`: Empty string if auth. is disabled, otherwise the username as string
  - `isApi Bool!`: Is currently logged in user api authority
-->

<script>
  import { getContext } from "svelte";
  import UserOptions from "./user/UserOptions.svelte";
  import PlotRenderOptions from "./user/PlotRenderOptions.svelte";
  import PlotColorScheme from "./user/PlotColorScheme.svelte";

  /* Svelte 5 Props */
  let {
    username,
    isApi
  } = $props();

  /* Const Init */
  const ccconfig = getContext("cc-config");

  /* State Init */
  let message = $state({ msg: "", target: "", color: "#d63384" });
  let displayMessage = $state(false);
  let cbmode = $state(ccconfig?.plotConfiguration_colorblindMode || false);

  /* Functions */
  async function handleSettingSubmit(event, setting) {
    event.preventDefault();

    const selector = setting.selector
    const target = setting.target
    let form = document.querySelector(selector);
    let formData = new FormData(form);
    try {
      const res = await fetch(form.action, { method: "POST", body: formData });
      if (res.ok) {
        let text = await res.text();
        if (formData.get("key") === "plotConfiguration_colorblindMode") {
          cbmode = JSON.parse(formData.get("value"));
        }
        popMessage(text, target, "#048109");
      } else {
        let text = await res.text();
        throw new Error("Response Code " + res.status + "-> " + text);
      }
    } catch (err) {
      popMessage(err, target, "#d63384");
    }

    return false;
  }

  function popMessage(response, restarget, rescolor) {
    message = { msg: response, target: restarget, color: rescolor };
    displayMessage = true;
    setTimeout(function () {
      displayMessage = false;
    }, 3500);
  }
</script>

<UserOptions config={ccconfig} {username} {isApi} bind:message bind:displayMessage updateSetting={(e, newSetting) => handleSettingSubmit(e, newSetting)}/>
<PlotRenderOptions config={ccconfig} bind:message bind:displayMessage updateSetting={(e, newSetting) => handleSettingSubmit(e, newSetting)}/>
<PlotColorScheme config={ccconfig} bind:cbmode bind:message bind:displayMessage updateSetting={(e, newSetting) => handleSettingSubmit(e, newSetting)}/>


Let me read more context files in parallel.


   870→			}
   871→		}
   872→
   873→		nodeMetrics = append(nodeMetrics, host)
   874→	}
   875→
   876→	return nodeMetrics, nil
   877→}
   878→
   879→// NodeMetricsList is the resolver for the nodeMetricsList field.
   880→func (r *queryResolver) NodeMetricsList(ctx context.Context, cluster string, subCluster string, stateFilter string, nodeFilter string, scopes []schema.MetricScope, metrics []string, from time.Time, to time.Time, page *model.PageRequest, resolution *int, resampleAlgo *model.ResampleAlgo) (*model.NodesResultList, error) {
   881→	if resolution == nil { // Load from Config
   882→		if config.Keys.EnableResampling != nil {
   883→			defaultRes := slices.Max(config.Keys.EnableResampling.Resolutions)
   884→			resolution = &defaultRes
   885→		} else { // Set 0 (Loads configured metric timestep)
   886→			defaultRes := 0
   887→			resolution = &defaultRes
   888→		}
   889→	}
   890→
   891→	user := repository.GetUserFromContext(ctx)
   892→	if user != nil && !user.HasAnyRole([]schema.Role{schema.RoleAdmin, schema.RoleSupport}) {
   893→		return nil, errors.New("you need to be administrator or support staff for this query")
   894→	}
   895→
   896→	nodeRepo := repository.GetNodeRepository()
   897→	// nodes -> array hostname
   898→	nodes, nodeStateMap, metricHealthMap, countNodes, hasNextPage, nerr := nodeRepo.GetNodesForList(ctx, cluster, subCluster, stateFilter, nodeFilter, page)
   899→	if nerr != nil {
   900→		return nil, errors.New("could not retrieve node list required for resolving NodeMetricsList")
   901→	}
   902→
   903→	if metrics == nil {
   904→		for _, mc := range archive.GetCluster(cluster).MetricConfig {
   905→			metrics = append(metrics, mc.Name)
   906→		}
   907→	}
   908→
   909→	algoName := ""
   910→	if resampleAlgo != nil {
   911→		algoName = strings.ToLower(resampleAlgo.String())
   912→	}
   913→
   914→	// data -> map hostname:jobdata
   915→	data, err := metricdispatch.LoadNodeListData(cluster, subCluster, nodes, metrics, scopes, *resolution, from, to, ctx, algoName)
   916→	if err != nil {
   917→		cclog.Warn("error while loading node data (Resolver.NodeMetricsList")
   918→		return nil, err
   919→	}
   920→
   921→	nodeMetricsList := make([]*model.NodeMetrics, 0, len(data))
   922→	for _, hostname := range nodes {
   923→		host := &model.NodeMetrics{
   924→			Host:         hostname,
   925→			NodeState:    nodeStateMap[hostname],
   926→			MetricHealth: metricHealthMap[hostname],
   927→			Metrics:      make([]*model.JobMetricWithName, 0),
   928→		}
   929→		host.SubCluster, err = archive.GetSubClusterByNode(cluster, hostname)
   930→		if err != nil {
   931→			cclog.Warnf("error in nodeMetrics resolver: %s", err)
   932→		}
   933→
   934→		for metric, scopedMetrics := range data[hostname] {
   935→			for scope, scopedMetric := range scopedMetrics {
   936→				host.Metrics = append(host.Metrics, &model.JobMetricWithName{
   937→					Name:   metric,
   938→					Scope:  scope,
   939→					Metric: scopedMetric,
   940→				})
   941→			}
   942→		}
   943→
   944→		nodeMetricsList = append(nodeMetricsList, host)
   945→	}
   946→
   947→	nodeMetricsListResult := &model.NodesResultList{
   948→		Items: nodeMetricsList,
   949→		// TotalNodes depends on sum of nodes grouped on latest timestamp, see repo/node.go:357

			}
		}

		nodeMetrics = append(nodeMetrics, host)
	}

	return nodeMetrics, nil
}

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

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

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

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

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

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

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

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

		nodeMetricsList = append(nodeMetricsList, host)
	}

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


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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

type ResampleConfig struct {
	// Minimum number of points to trigger resampling of data
	MinimumPoints int `json:"minimum-points"`
	// Array of resampling target resolutions, in seconds; Example: [600,300,60]
	Resolutions []int `json:"resolutions"`
	// Trigger next zoom level at less than this many visible datapoints
	Trigger int `json:"trigger"`
}

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

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

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

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

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

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

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


   500→
   501→            let y = u.valToPos(thresholds.normal, "y", true);
   502→            u.ctx.save();
   503→            u.ctx.lineWidth = lineWidth;
   504→            u.ctx.strokeStyle = normalLineColor;
   505→            u.ctx.setLineDash([5, 5]);
   506→            u.ctx.beginPath();
   507→            u.ctx.moveTo(u.bbox.left, y);
   508→            u.ctx.lineTo(u.bbox.left + u.bbox.width, y);
   509→            u.ctx.stroke();
   510→            u.ctx.restore();
   511→          },
   512→        ],
   513→        setScale: [
   514→          (u, key) => { // If ZoomResample is Configured && Not System/Node View
   515→            if (resampleConfig && !forNode && key === 'x') {
   516→              const numX = (u.series[0].idxs[1] - u.series[0].idxs[0])
   517→              if (numX <= resampleTrigger && timestep !== resampleMinimum) {
   518→                /* Get closest zoom level; prevents multiple iterative zoom requests for big zoom-steps (e.g. 600 -> 300 -> 120 -> 60) */
   519→                // Which resolution to theoretically request to achieve 30 or more visible data points:
   520→                const target = (numX * timestep) / resampleTrigger
   521→                // Which configured resolution actually matches the closest to theoretical target:
   522→                const closest = resampleResolutions.reduce(function(prev, curr) {
   523→                  return (Math.abs(curr - target) < Math.abs(prev - target) ? curr : prev);
   524→                });
   525→                // Prevents non-required dispatches
   526→                if (timestep !== closest) {
   527→                  // console.log('Dispatch: Zoom with Res from / to', timestep, closest)
   528→                  onZoom({
   529→                    newRes: closest,
   530→                    lastZoomState: u?.scales,
   531→                    lastThreshold: thresholds?.normal
   532→                  });
   533→                }
   534→              } else {
   535→                // console.log('Dispatch: Zoom Update States')
   536→                onZoom({
   537→                  lastZoomState: u?.scales,
   538→                  lastThreshold: thresholds?.normal
   539→                });
   540→              };
   541→            };
   542→          },
   543→        ]
   544→      },
   545→      scales: {
   546→        x: { time: false },
   547→        y: maxY ? { min: 0, max: (maxY * 1.1) } : {auto: true}, // Add some space to upper render limit
   548→      },
   549→      legend: {
   550→        // Display legend until max 12 Y-dataseries
   551→        show: series.length <= 12 || useStatsSeries,
   552→        live: series.length <= 12 || useStatsSeries,
   553→      },
   554→      cursor: {
   555→        drag: { x: true, y: true },
   556→      }
   557→    };
   558→
   559→    // Handle Render

            let y = u.valToPos(thresholds.normal, "y", true);
            u.ctx.save();
            u.ctx.lineWidth = lineWidth;
            u.ctx.strokeStyle = normalLineColor;
            u.ctx.setLineDash([5, 5]);
            u.ctx.beginPath();
            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) {
                /* Get closest zoom level; prevents multiple iterative zoom requests for big zoom-steps (e.g. 600 -> 300 -> 120 -> 60) */
                // Which resolution to theoretically request to achieve 30 or more visible data points:
                const target = (numX * timestep) / resampleTrigger
                // Which configured resolution actually matches the closest to theoretical target:
                const closest = resampleResolutions.reduce(function(prev, curr) {
                  return (Math.abs(curr - target) < Math.abs(prev - target) ? curr : prev);
                });
                // Prevents non-required dispatches
                if (timestep !== closest) {
                  // console.log('Dispatch: Zoom with Res from / to', timestep, closest)
                  onZoom({
                    newRes: closest,
                    lastZoomState: u?.scales,
                    lastThreshold: thresholds?.normal
                  });
                }
              } else {
                // console.log('Dispatch: Zoom Update States')
                onZoom({
                  lastZoomState: u?.scales,
                  lastThreshold: thresholds?.normal
                });
              };
            };
          },
        ]
      },
      scales: {
        x: { time: false },
        y: maxY ? { min: 0, max: (maxY * 1.1) } : {auto: true}, // Add some space to upper render limit
      },
      legend: {
        // Display legend until max 12 Y-dataseries
        show: series.length <= 12 || useStatsSeries,
        live: series.length <= 12 || useStatsSeries,
      },
      cursor: {
        drag: { x: true, y: true },
      }
    };

    // Handle Render


No matches found


Found 16 files
web/frontend/src/generic/joblist/JobListRow.svelte
web/frontend/src/generic/plots/MetricPlot.svelte
web/frontend/src/job/Metric.svelte
web/frontend/src/Systems.root.svelte
web/templates/monitoring/job.tmpl
web/templates/monitoring/jobs.tmpl
web/templates/monitoring/systems.tmpl
web/templates/monitoring/user.tmpl
web/frontend/src/user.entrypoint.js
web/templates/config.tmpl
web/frontend/src/systems.entrypoint.js
web/frontend/src/jobs.entrypoint.js
web/frontend/src/job.entrypoint.js
web/frontend/src/generic/JobList.svelte
web/frontend/src/config/admin/Options.svelte
web/frontend/src/config.entrypoint.js


Let me read more key files in parallel.


     1→<!--
     2→  @component Main plot component, based on uPlot; metricdata values by time
     3→
     4→  Only width/height should change reactively.
     5→
     6→  Properties:
     7→  - `metric String`: The metric name
     8→  - `scope String?`: Scope of the displayed data [Default: node]
     9→  - `height Number?`: The plot height [Default: 300]
    10→  - `timestep Number`: The timestep used for X-axis rendering
    11→  - `series [GraphQL.Series]`: The metric data object
    12→  - `statisticsSeries [GraphQL.StatisticsSeries]?`: Min/Max/Median representation of metric data [Default: null]
    13→  - `cluster String?`: Cluster name of the parent job / data [Default: ""]
    14→  - `subCluster String`: Name of the subCluster of the parent job
    15→  - `isShared Bool?`: If this job used shared resources; for additional legend display [Default: false]
    16→  - `forNode Bool?`: If this plot is used for node data display; will render x-axis as negative time with $now as maximum [Default: false]
    17→  - `numhwthreads Number?`: Number of job HWThreads [Default: 0]
    18→  - `numaccs Number?`: Number of job Accelerators [Default: 0]
    19→  - `zoomState Object?`: The last zoom state to preserve on user zoom [Default: null]
    20→  - `thersholdState Object?`: The last threshold state to preserve on user zoom [Default: null]
    21→  - `extendedLegendData Object?`: Additional information to be rendered in an extended legend [Default: null]
    22→  - `onZoom Func`: Callback function to handle zoom-in event
    23→-->
    24→
    25→<script>
    26→  import uPlot from "uplot";
    27→  import { formatNumber, formatDurationTime } from "../units.js";
    28→  import { getContext, onMount, onDestroy } from "svelte";
    29→  import { Card, CardBody, CardHeader } from "@sveltestrap/sveltestrap";
    30→
    31→  /* Svelte 5 Props */
    32→  let {
    33→    metric,
    34→    scope = "node",
    35→    width = 0,
    36→    height = 300,
    37→    timestep,
    38→    series,
    39→    statisticsSeries = null,
    40→    cluster = "",
    41→    subCluster,
    42→    isShared = false,
    43→    forNode = false,
    44→    zoomState = null,
    45→    thresholdState = null,
    46→    extendedLegendData = null,
    47→    plotSync = null,
    48→    enableFlip = false,
    49→    onZoom
    50→  } = $props();

<!--
  @component Main plot component, based on uPlot; metricdata values by time

  Only width/height should change reactively.

  Properties:
  - `metric String`: The metric name
  - `scope String?`: Scope of the displayed data [Default: node]
  - `height Number?`: The plot height [Default: 300]
  - `timestep Number`: The timestep used for X-axis rendering
  - `series [GraphQL.Series]`: The metric data object
  - `statisticsSeries [GraphQL.StatisticsSeries]?`: Min/Max/Median representation of metric data [Default: null]
  - `cluster String?`: Cluster name of the parent job / data [Default: ""]
  - `subCluster String`: Name of the subCluster of the parent job
  - `isShared Bool?`: If this job used shared resources; for additional legend display [Default: false]
  - `forNode Bool?`: If this plot is used for node data display; will render x-axis as negative time with $now as maximum [Default: false]
  - `numhwthreads Number?`: Number of job HWThreads [Default: 0]
  - `numaccs Number?`: Number of job Accelerators [Default: 0]
  - `zoomState Object?`: The last zoom state to preserve on user zoom [Default: null]
  - `thersholdState Object?`: The last threshold state to preserve on user zoom [Default: null]
  - `extendedLegendData Object?`: Additional information to be rendered in an extended legend [Default: null]
  - `onZoom Func`: Callback function to handle zoom-in event
-->

<script>
  import uPlot from "uplot";
  import { formatNumber, formatDurationTime } from "../units.js";
  import { getContext, onMount, onDestroy } from "svelte";
  import { Card, CardBody, CardHeader } from "@sveltestrap/sveltestrap";

  /* Svelte 5 Props */
  let {
    metric,
    scope = "node",
    width = 0,
    height = 300,
    timestep,
    series,
    statisticsSeries = null,
    cluster = "",
    subCluster,
    isShared = false,
    forNode = false,
    zoomState = null,
    thresholdState = null,
    extendedLegendData = null,
    plotSync = null,
    enableFlip = false,
    onZoom
  } = $props();


     1→import { mount } from 'svelte';
     2→import {} from './header.entrypoint.js'
     3→import Job from './Job.root.svelte'
     4→
     5→mount(Job, {
     6→    target: document.getElementById('svelte-app'),
     7→    props: {
     8→        dbid: jobInfos.id,
     9→        username: username,
    10→        authlevel: authlevel,
    11→        roles: roles
    12→    },
    13→    context: new Map([
    14→            ['cc-config', clusterCockpitConfig],
    15→            ['resampling', resampleConfig],
    16→            ['emission', emission]
    17→    ])
    18→})
    19→

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

mount(Job, {
    target: document.getElementById('svelte-app'),
    props: {
        dbid: jobInfos.id,
        username: username,
        authlevel: authlevel,
        roles: roles
    },
    context: new Map([
            ['cc-config', clusterCockpitConfig],
            ['resampling', resampleConfig],
            ['emission', emission]
    ])
})


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

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

{{define "content"}}

{{end}}

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

{{end}}


Error: File does not exist. Note: your current working directory is /Users/jan/tmp/cc-backend.


54:  const resampleConfig = getContext("resampling");
76:  const resampleTrigger = $derived(resampleConfig?.trigger ? Number(resampleConfig.trigger) : null);
77:  const resampleResolutions = $derived(resampleConfig?.resolutions ? [...resampleConfig.resolutions] : null);
78:  const resampleMinimum = $derived(resampleConfig?.resolutions ? Math.min(...resampleConfig.resolutions) : null);
459:            if (resampleConfig && !forNode) {
515:            if (resampleConfig && !forNode && key === 'x') {
517:              if (numX <= resampleTrigger && timestep !== resampleMinimum) {
520:                const target = (numX * timestep) / resampleTrigger
522:                const closest = resampleResolutions.reduce(function(prev, curr) {


internal/routerConfig/routes.go:472:	userCfgRepo := repository.GetUserCfgRepo()
internal/routerConfig/routes.go:475:			conf, err := userCfgRepo.GetUIConfig(repository.GetUserFromContext(r.Context()))
internal/graph/schema.resolvers.go:303:	if err := repository.GetUserCfgRepo().UpdateConfig(name, value, repository.GetUserFromContext(ctx)); err != nil {
internal/api/rest.go:399:	if err := repository.GetUserCfgRepo().UpdateConfig(key, value, repository.GetUserFromContext(r.Context())); err != nil {
internal/repository/userConfig.go:34:func GetUserCfgRepo() *UserCfgRepo {
internal/repository/userConfig.go:56:func (uCfg *UserCfgRepo) GetUIConfig(user *schema.User) (map[string]any, error) {
internal/repository/userConfig_test.go:73:	return GetUserCfgRepo()
internal/repository/userConfig_test.go:76:func TestGetUIConfig(t *testing.T) {
internal/repository/userConfig_test.go:80:	cfg, err := r.GetUIConfig(&u)


    50→  } = $props();
    51→
    52→  /* Const Init */
    53→  const clusterCockpitConfig = getContext("cc-config");
    54→  const resampleConfig = getContext("resampling");
    55→  const lineWidth = clusterCockpitConfig.plotConfiguration_lineWidth / window.devicePixelRatio;
    56→  const cbmode = clusterCockpitConfig?.plotConfiguration_colorblindMode || false;
    57→  const renderSleepTime = 200;
    58→  const normalLineColor = "#000000";
    59→  const backgroundColors = {
    60→    normal: "rgba(255, 255, 255, 1.0)",
    61→    caution: cbmode ? "rgba(239, 230, 69, 0.3)" : "rgba(255, 128, 0, 0.3)",
    62→    alert: cbmode ? "rgba(225, 86, 44, 0.3)" : "rgba(255, 0, 0, 0.3)",
    63→  };
    64→
    65→  /* Var Init */
    66→  let timeoutId = null;
    67→
    68→  /* State Init */
    69→  let plotWrapper = $state(null);
    70→  let uplot = $state(null);
    71→
    72→  /* Derived */
    73→  const subClusterTopology = $derived(getContext("getHardwareTopology")(cluster, subCluster));
    74→  const metricConfig = $derived(getContext("getMetricConfig")(cluster, subCluster, metric));
    75→  const usesMeanStatsSeries = $derived((statisticsSeries?.mean && statisticsSeries.mean.length != 0));
    76→  const resampleTrigger = $derived(resampleConfig?.trigger ? Number(resampleConfig.trigger) : null);
    77→  const resampleResolutions = $derived(resampleConfig?.resolutions ? [...resampleConfig.resolutions] : null);
    78→  const resampleMinimum = $derived(resampleConfig?.resolutions ? Math.min(...resampleConfig.resolutions) : null);
    79→  const useStatsSeries = $derived(!!statisticsSeries); // Display Stats Series By Default if Exists
    80→  const thresholds = $derived(findJobAggregationThresholds(
    81→    subClusterTopology,
    82→    metricConfig,
    83→    scope
    84→  ));

  } = $props();

  /* Const Init */
  const clusterCockpitConfig = getContext("cc-config");
  const resampleConfig = getContext("resampling");
  const lineWidth = clusterCockpitConfig.plotConfiguration_lineWidth / window.devicePixelRatio;
  const cbmode = clusterCockpitConfig?.plotConfiguration_colorblindMode || false;
  const renderSleepTime = 200;
  const normalLineColor = "#000000";
  const backgroundColors = {
    normal: "rgba(255, 255, 255, 1.0)",
    caution: cbmode ? "rgba(239, 230, 69, 0.3)" : "rgba(255, 128, 0, 0.3)",
    alert: cbmode ? "rgba(225, 86, 44, 0.3)" : "rgba(255, 0, 0, 0.3)",
  };

  /* Var Init */
  let timeoutId = null;

  /* State Init */
  let plotWrapper = $state(null);
  let uplot = $state(null);

  /* Derived */
  const subClusterTopology = $derived(getContext("getHardwareTopology")(cluster, subCluster));
  const metricConfig = $derived(getContext("getMetricConfig")(cluster, subCluster, metric));
  const usesMeanStatsSeries = $derived((statisticsSeries?.mean && statisticsSeries.mean.length != 0));
  const resampleTrigger = $derived(resampleConfig?.trigger ? Number(resampleConfig.trigger) : null);
  const resampleResolutions = $derived(resampleConfig?.resolutions ? [...resampleConfig.resolutions] : null);
  const resampleMinimum = $derived(resampleConfig?.resolutions ? Math.min(...resampleConfig.resolutions) : null);
  const useStatsSeries = $derived(!!statisticsSeries); // Display Stats Series By Default if Exists
  const thresholds = $derived(findJobAggregationThresholds(
    subClusterTopology,
    metricConfig,
    scope
  ));


internal/routerconfig/routes.go:502:				Resampling: config.Keys.EnableResampling,


   465→		}
   466→		filterPresets["stats"] = statList
   467→	}
   468→	return filterPresets
   469→}
   470→
   471→func SetupRoutes(router chi.Router, buildInfo web.Build) {
   472→	userCfgRepo := repository.GetUserCfgRepo()
   473→	for _, route := range routes {
   474→		router.HandleFunc(route.Route, func(rw http.ResponseWriter, r *http.Request) {
   475→			conf, err := userCfgRepo.GetUIConfig(repository.GetUserFromContext(r.Context()))
   476→			if err != nil {
   477→				http.Error(rw, err.Error(), http.StatusInternalServerError)
   478→				return
   479→			}
   480→
   481→			title := route.Title
   482→			infos := route.Setup(map[string]any{}, r)
   483→			if id, ok := infos["id"]; ok {
   484→				title = strings.Replace(route.Title, "<ID>", id.(string), 1)
   485→				if sid, ok := infos["sid"]; ok { // 2nd ID element
   486→					title = strings.Replace(title, "<SID>", sid.(string), 1)
   487→				}
   488→			}
   489→
   490→			// Get User -> What if NIL?
   491→			user := repository.GetUserFromContext(r.Context())
   492→
   493→			// Get Roles
   494→			availableRoles, _ := schema.GetValidRolesMap(user)
   495→
   496→			page := web.Page{
   497→				Title:      title,
   498→				User:       *user,
   499→				Roles:      availableRoles,
   500→				Build:      buildInfo,
   501→				Config:     conf,
   502→				Resampling: config.Keys.EnableResampling,
   503→				Infos:      infos,
   504→			}
   505→
   506→			if route.Filter {
   507→				page.FilterPresets = buildFilterPresets(r.URL.Query())
   508→			}
   509→
   510→			web.RenderTemplate(rw, route.Template, &page)
   511→		})
   512→	}
   513→}
   514→
   515→func HandleSearchBar(rw http.ResponseWriter, r *http.Request, buildInfo web.Build) {
   516→	user := repository.GetUserFromContext(r.Context())
   517→	availableRoles, _ := schema.GetValidRolesMap(user)
   518→
   519→	if search := r.URL.Query().Get("searchId"); search != "" {
   520→		repo := repository.GetJobRepository()
   521→		splitSearch := strings.Split(search, ":")
   522→
   523→		if len(splitSearch) == 2 {
   524→			switch strings.Trim(splitSearch[0], " ") {

		}
		filterPresets["stats"] = statList
	}
	return filterPresets
}

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

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

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

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

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

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

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

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

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

		if len(splitSearch) == 2 {
			switch strings.Trim(splitSearch[0], " ") {