8 Commits

Author SHA1 Message Date
5d8d4e228e Merge branch 'main' into feature/526-average-resample 2026-06-17 06:30:54 +02:00
Jan Eitzinger
3bfd3d06ca Merge pull request #557 from ClusterCockpit/release/v1.5
Release/v1.5
2026-06-07 08:21:14 +02:00
9c6075ebb5 Update README to reflect main branch naming 2026-06-07 08:18:46 +02:00
af7528c8b2 Update CLAUDE.md
Entire-Checkpoint: 306db138cb4c
2026-06-07 08:16:10 +02:00
01fb4d53f1 Fix broken link in README 2026-06-07 08:12:25 +02:00
0c56591e4b Adopt config to use policy based resampler configuration
Entire-Checkpoint: 7536f551d548
2026-03-20 08:03:34 +01:00
0069c86e81 Replace explicit resampling config with policy based approach
Entire-Checkpoint: f69e38210bb1
2026-03-20 05:34:12 +01:00
c0d2d65f96 Introduce average resampler support
Fixes #526

Entire-Checkpoint: 893a1de325b5
2026-03-19 21:16:48 +01:00
29 changed files with 724 additions and 144 deletions

View File

@@ -341,6 +341,21 @@ records, archives) at scale. All code changes must prioritize maximum throughput
and minimal latency. Avoid unnecessary allocations, prefer streaming over and minimal latency. Avoid unnecessary allocations, prefer streaming over
buffering, and be mindful of lock contention. When in doubt, benchmark. buffering, and be mindful of lock contention. When in doubt, benchmark.
### Commit Message Convention
Commits must use conventional commit prefixes so goreleaser can generate the
changelog automatically. Only commits with these prefixes appear in releases:
| Prefix | Changelog group |
|---------|------------------------|
| `feat:` | New Features |
| `fix:` | Bug fixes |
| `sec:` | Security updates |
| `docs:` | Documentation updates |
Scoped variants are also recognised, e.g. `feat(api):`, `fix(deps):`.
Commits without one of these prefixes are excluded from the changelog.
### Change Impact Analysis ### Change Impact Analysis
For any significant change, you MUST: For any significant change, you MUST:

View File

@@ -1,10 +1,9 @@
# NOTE # NOTE
While we do our best to keep the master branch in a usable state, there is no guarantee the master branch works. While we do our best to keep the main branch in a usable state, there is no
Please do not use it for production! guarantee the main branch works. Please do not use it for production!
Please have a look at the [Release Please have a look at the [Release Notes](https://github.com/ClusterCockpit/cc-backend/blob/main/ReleaseNotes.md)
Notes](https://github.com/ClusterCockpit/cc-backend/blob/master/ReleaseNotes.md)
for breaking changes! for breaking changes!
# ClusterCockpit REST and GraphQL API backend # ClusterCockpit REST and GraphQL API backend
@@ -41,7 +40,7 @@ For real-time integration with HPC systems, the backend can subscribe to
state updates, providing an alternative to REST API polling. state updates, providing an alternative to REST API polling.
Completed batch jobs are stored in a file-based job archive according to Completed batch jobs are stored in a file-based job archive according to
[this specification](https://github.com/ClusterCockpit/cc-specifications/tree/master/job-archive). [this specification](https://github.com/ClusterCockpit/cc-specifications/tree/main/job-archive).
The backend supports authentication via local accounts, an external LDAP The backend supports authentication via local accounts, an external LDAP
directory, and JWT tokens. Authorization for APIs is implemented with directory, and JWT tokens. Authorization for APIs is implemented with
[JWT](https://jwt.io/) tokens created with public/private key encryption. [JWT](https://jwt.io/) tokens created with public/private key encryption.
@@ -243,73 +242,73 @@ The effective configuration is logged at startup for verification.
## Project file structure ## Project file structure
- [`.github/`](https://github.com/ClusterCockpit/cc-backend/tree/master/.github) - [`.github/`](https://github.com/ClusterCockpit/cc-backend/tree/main/.github)
GitHub Actions workflows and dependabot configuration for CI/CD. GitHub Actions workflows and dependabot configuration for CI/CD.
- [`api/`](https://github.com/ClusterCockpit/cc-backend/tree/master/api) - [`api/`](https://github.com/ClusterCockpit/cc-backend/tree/main/api)
contains the API schema files for the REST and GraphQL APIs. The REST API is contains the API schema files for the REST and GraphQL APIs. The REST API is
documented in the OpenAPI 3.0 format in documented in the OpenAPI 3.0 format in
[./api/swagger.yaml](./api/swagger.yaml). The GraphQL schema is in [./api/swagger.yaml](./api/swagger.yaml). The GraphQL schema is in
[./api/schema.graphqls](./api/schema.graphqls). [./api/schema.graphqls](./api/schema.graphqls).
- [`cmd/cc-backend`](https://github.com/ClusterCockpit/cc-backend/tree/master/cmd/cc-backend) - [`cmd/cc-backend`](https://github.com/ClusterCockpit/cc-backend/tree/main/cmd/cc-backend)
contains the main application entry point and CLI implementation. contains the main application entry point and CLI implementation.
- [`configs/`](https://github.com/ClusterCockpit/cc-backend/tree/master/configs) - [`configs/`](https://github.com/ClusterCockpit/cc-backend/tree/main/configs)
contains documentation about configuration and command line options and required contains documentation about configuration and command line options and required
environment variables. Sample configuration files are provided. environment variables. Sample configuration files are provided.
- [`init/`](https://github.com/ClusterCockpit/cc-backend/tree/master/init) - [`init/`](https://github.com/ClusterCockpit/cc-backend/tree/main/init)
contains an example of setting up systemd for production use. contains an example of setting up systemd for production use.
- [`internal/`](https://github.com/ClusterCockpit/cc-backend/tree/master/internal) - [`internal/`](https://github.com/ClusterCockpit/cc-backend/tree/main/internal)
contains library source code that is not intended for use by others. contains library source code that is not intended for use by others.
- [`api`](https://github.com/ClusterCockpit/cc-backend/tree/master/internal/api) - [`api`](https://github.com/ClusterCockpit/cc-backend/tree/main/internal/api)
REST API handlers and NATS integration REST API handlers and NATS integration
- [`archiver`](https://github.com/ClusterCockpit/cc-backend/tree/master/internal/archiver) - [`archiver`](https://github.com/ClusterCockpit/cc-backend/tree/main/internal/archiver)
Job archiving functionality Job archiving functionality
- [`auth`](https://github.com/ClusterCockpit/cc-backend/tree/master/internal/auth) - [`auth`](https://github.com/ClusterCockpit/cc-backend/tree/main/internal/auth)
Authentication (local, LDAP, OIDC) and JWT token handling Authentication (local, LDAP, OIDC) and JWT token handling
- [`config`](https://github.com/ClusterCockpit/cc-backend/tree/master/internal/config) - [`config`](https://github.com/ClusterCockpit/cc-backend/tree/main/internal/config)
Configuration management and validation Configuration management and validation
- [`graph`](https://github.com/ClusterCockpit/cc-backend/tree/master/internal/graph) - [`graph`](https://github.com/ClusterCockpit/cc-backend/tree/main/internal/graph)
GraphQL schema and resolvers GraphQL schema and resolvers
- [`importer`](https://github.com/ClusterCockpit/cc-backend/tree/master/internal/importer) - [`importer`](https://github.com/ClusterCockpit/cc-backend/tree/main/internal/importer)
Job data import and database initialization Job data import and database initialization
- [`metricdispatch`](https://github.com/ClusterCockpit/cc-backend/tree/master/internal/metricdispatch) - [`metricdispatch`](https://github.com/ClusterCockpit/cc-backend/tree/main/internal/metricdispatch)
Dispatches metric data loading to appropriate backends Dispatches metric data loading to appropriate backends
- [`repository`](https://github.com/ClusterCockpit/cc-backend/tree/master/internal/repository) - [`repository`](https://github.com/ClusterCockpit/cc-backend/tree/main/internal/repository)
Database repository layer for jobs and metadata Database repository layer for jobs and metadata
- [`routerConfig`](https://github.com/ClusterCockpit/cc-backend/tree/master/internal/routerConfig) - [`routerConfig`](https://github.com/ClusterCockpit/cc-backend/tree/main/internal/routerConfig)
HTTP router configuration and middleware HTTP router configuration and middleware
- [`tagger`](https://github.com/ClusterCockpit/cc-backend/tree/master/internal/tagger) - [`tagger`](https://github.com/ClusterCockpit/cc-backend/tree/main/internal/tagger)
Job classification and application detection Job classification and application detection
- [`taskmanager`](https://github.com/ClusterCockpit/cc-backend/tree/master/internal/taskmanager) - [`taskmanager`](https://github.com/ClusterCockpit/cc-backend/tree/main/internal/taskmanager)
Background task management and scheduled jobs Background task management and scheduled jobs
- [`metricstoreclient`](https://github.com/ClusterCockpit/cc-backend/tree/master/internal/metricstoreclient) - [`metricstoreclient`](https://github.com/ClusterCockpit/cc-backend/tree/main/internal/metricstoreclient)
Client for cc-metric-store queries Client for cc-metric-store queries
- [`pkg/`](https://github.com/ClusterCockpit/cc-backend/tree/master/pkg) - [`pkg/`](https://github.com/ClusterCockpit/cc-backend/tree/main/pkg)
contains Go packages that can be used by other projects. contains Go packages that can be used by other projects.
- [`archive`](https://github.com/ClusterCockpit/cc-backend/tree/master/pkg/archive) - [`archive`](https://github.com/ClusterCockpit/cc-backend/tree/main/pkg/archive)
Job archive backend implementations (filesystem, S3, SQLite) Job archive backend implementations (filesystem, S3, SQLite)
- [`metricstore`](https://github.com/ClusterCockpit/cc-backend/tree/master/pkg/metricstore) - [`metricstore`](https://github.com/ClusterCockpit/cc-backend/tree/main/pkg/metricstore)
In-memory metric data store with checkpointing and metric loading In-memory metric data store with checkpointing and metric loading
- [`tools/`](https://github.com/ClusterCockpit/cc-backend/tree/master/tools) - [`tools/`](https://github.com/ClusterCockpit/cc-backend/tree/main/tools)
Additional command line helper tools. Additional command line helper tools.
- [`archive-manager`](https://github.com/ClusterCockpit/cc-backend/tree/master/tools/archive-manager) - [`archive-manager`](https://github.com/ClusterCockpit/cc-backend/tree/main/tools/archive-manager)
Commands for getting infos about an existing job archive, importing jobs Commands for getting infos about an existing job archive, importing jobs
between archive backends, and converting archives between JSON and Parquet formats. between archive backends, and converting archives between JSON and Parquet formats.
- [`archive-migration`](https://github.com/ClusterCockpit/cc-backend/tree/master/tools/archive-migration) - [`archive-migration`](https://github.com/ClusterCockpit/cc-backend/tree/main/tools/archive-migration)
Tool for migrating job archives between formats. Tool for migrating job archives between formats.
- [`convert-pem-pubkey`](https://github.com/ClusterCockpit/cc-backend/tree/master/tools/convert-pem-pubkey) - [`convert-pem-pubkey`](https://github.com/ClusterCockpit/cc-backend/tree/main/tools/convert-pem-pubkey)
Tool to convert external pubkey for use in `cc-backend`. Tool to convert external pubkey for use in `cc-backend`.
- [`gen-keypair`](https://github.com/ClusterCockpit/cc-backend/tree/master/tools/gen-keypair) - [`gen-keypair`](https://github.com/ClusterCockpit/cc-backend/tree/main/tools/gen-keypair)
contains a small application to generate a compatible JWT keypair. You find contains a small application to generate a compatible JWT keypair. You find
documentation on how to use it documentation on how to use it
[here](https://github.com/ClusterCockpit/cc-backend/blob/master/docs/JWT-Handling.md). [here](https://github.com/ClusterCockpit/cc-backend/blob/main/docs/JWT-Handling.md).
- [`web/`](https://github.com/ClusterCockpit/cc-backend/tree/master/web) - [`web/`](https://github.com/ClusterCockpit/cc-backend/tree/main/web)
Server-side templates and frontend-related files: Server-side templates and frontend-related files:
- [`frontend`](https://github.com/ClusterCockpit/cc-backend/tree/master/web/frontend) - [`frontend`](https://github.com/ClusterCockpit/cc-backend/tree/main/web/frontend)
Svelte components and static assets for the frontend UI Svelte components and static assets for the frontend UI
- [`templates`](https://github.com/ClusterCockpit/cc-backend/tree/master/web/templates) - [`templates`](https://github.com/ClusterCockpit/cc-backend/tree/main/web/templates)
Server-side Go templates, including monitoring views Server-side Go templates, including monitoring views
- [`gqlgen.yml`](https://github.com/ClusterCockpit/cc-backend/blob/master/gqlgen.yml) - [`gqlgen.yml`](https://github.com/ClusterCockpit/cc-backend/blob/main/gqlgen.yml)
Configures the behaviour and generation of Configures the behaviour and generation of
[gqlgen](https://github.com/99designs/gqlgen). [gqlgen](https://github.com/99designs/gqlgen).
- [`startDemo.sh`](https://github.com/ClusterCockpit/cc-backend/blob/master/startDemo.sh) - [`startDemo.sh`](https://github.com/ClusterCockpit/cc-backend/blob/main/startDemo.sh)
is a shell script that sets up demo data, and builds and starts `cc-backend`. is a shell script that sets up demo data, and builds and starts `cc-backend`.

View File

@@ -250,6 +250,12 @@ type TimeWeights {
coreHours: [NullableFloat!]! coreHours: [NullableFloat!]!
} }
enum ResampleAlgo {
LTTB
AVERAGE
SIMPLE
}
enum Aggregate { enum Aggregate {
USER USER
PROJECT PROJECT
@@ -340,6 +346,7 @@ type Query {
metrics: [String!] metrics: [String!]
scopes: [MetricScope!] scopes: [MetricScope!]
resolution: Int resolution: Int
resampleAlgo: ResampleAlgo
): [JobMetricWithName!]! ): [JobMetricWithName!]!
jobStats(id: ID!, metrics: [String!]): [NamedStats!]! jobStats(id: ID!, metrics: [String!]): [NamedStats!]!
@@ -399,6 +406,7 @@ type Query {
to: Time! to: Time!
page: PageRequest page: PageRequest
resolution: Int resolution: Int
resampleAlgo: ResampleAlgo
): NodesResultList! ): NodesResultList!
clusterMetrics( clusterMetrics(

View File

@@ -34,12 +34,8 @@ const configString = `
"addr": "127.0.0.1:8080", "addr": "127.0.0.1:8080",
"short-running-jobs-duration": 300, "short-running-jobs-duration": 300,
"resampling": { "resampling": {
"minimum-points": 600, "default-policy": "medium",
"trigger": 300, "default-algo": "lttb"
"resolutions": [
240,
60
]
}, },
"api-allowed-ips": [ "api-allowed-ips": [
"*" "*"

View File

@@ -14,9 +14,8 @@
"target-path": "./var/nodestate-archive" "target-path": "./var/nodestate-archive"
}, },
"resampling": { "resampling": {
"minimum-points": 600, "default-policy": "medium",
"trigger": 180, "default-algo": "lttb"
"resolutions": [240, 60]
}, },
"api-subjects": { "api-subjects": {
"subject-job-event": "cc.job.event", "subject-job-event": "cc.job.event",

View File

@@ -356,7 +356,7 @@ func TestRestApi(t *testing.T) {
} }
t.Run("CheckArchive", func(t *testing.T) { t.Run("CheckArchive", func(t *testing.T) {
data, err := metricdispatch.LoadData(stoppedJob, []string{"load_one"}, []schema.MetricScope{schema.MetricScopeNode}, context.Background(), 60) data, err := metricdispatch.LoadData(stoppedJob, []string{"load_one"}, []schema.MetricScope{schema.MetricScopeNode}, context.Background(), 60, "")
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }

View File

@@ -309,7 +309,7 @@ func (api *RestAPI) getCompleteJobByID(rw http.ResponseWriter, r *http.Request)
} }
if r.URL.Query().Get("all-metrics") == "true" { if r.URL.Query().Get("all-metrics") == "true" {
data, err = metricdispatch.LoadData(job, nil, scopes, r.Context(), resolution) data, err = metricdispatch.LoadData(job, nil, scopes, r.Context(), resolution, "")
if err != nil { if err != nil {
cclog.Warnf("REST: error while loading all-metrics job data for JobID %d on %s", job.JobID, job.Cluster) cclog.Warnf("REST: error while loading all-metrics job data for JobID %d on %s", job.JobID, job.Cluster)
return return
@@ -405,7 +405,7 @@ func (api *RestAPI) getJobByID(rw http.ResponseWriter, r *http.Request) {
resolution = max(resolution, mc.Timestep) resolution = max(resolution, mc.Timestep)
} }
data, err := metricdispatch.LoadData(job, metrics, scopes, r.Context(), resolution) data, err := metricdispatch.LoadData(job, metrics, scopes, r.Context(), resolution, "")
if err != nil { if err != nil {
cclog.Warnf("REST: error while loading job data for JobID %d on %s", job.JobID, job.Cluster) cclog.Warnf("REST: error while loading job data for JobID %d on %s", job.JobID, job.Cluster)
return return
@@ -1086,7 +1086,7 @@ func (api *RestAPI) getJobMetrics(rw http.ResponseWriter, r *http.Request) {
} }
resolver := graph.GetResolverInstance() resolver := graph.GetResolverInstance()
data, err := resolver.Query().JobMetrics(r.Context(), id, metrics, scopes, nil) data, err := resolver.Query().JobMetrics(r.Context(), id, metrics, scopes, nil, nil)
if err != nil { if err != nil {
if err := json.NewEncoder(rw).Encode(Response{ if err := json.NewEncoder(rw).Encode(Response{
Error: &struct { Error: &struct {

View File

@@ -59,7 +59,7 @@ func ArchiveJob(job *schema.Job, ctx context.Context) (*schema.Job, error) {
scopes = append(scopes, schema.MetricScopeAccelerator) scopes = append(scopes, schema.MetricScopeAccelerator)
} }
jobData, err := metricdispatch.LoadData(job, allMetrics, scopes, ctx, 0) // 0 Resulotion-Value retrieves highest res (60s) jobData, err := metricdispatch.LoadData(job, allMetrics, scopes, ctx, 0, "") // 0 Resulotion-Value retrieves highest res (60s)
if err != nil { if err != nil {
cclog.Error("Error wile loading job data for archiving") cclog.Error("Error wile loading job data for archiving")
return nil, err return nil, err

View File

@@ -106,12 +106,12 @@ type NodeStateRetention struct {
} }
type ResampleConfig struct { type ResampleConfig struct {
// Minimum number of points to trigger resampling of data // Default resample policy when no user preference is set ("low", "medium", "high")
MinimumPoints int `json:"minimum-points"` DefaultPolicy string `json:"default-policy"`
// Array of resampling target resolutions, in seconds; Example: [600,300,60] // Default resample algorithm when no user preference is set ("lttb", "average", "simple")
Resolutions []int `json:"resolutions"` DefaultAlgo string `json:"default-algo"`
// Trigger next zoom level at less than this many visible datapoints // Policy-derived target point count (set dynamically from user preference, not from config.json)
Trigger int `json:"trigger"` TargetPoints int `json:"targetPoints,omitempty"`
} }
type NATSConfig struct { type NATSConfig struct {
@@ -155,7 +155,24 @@ func Init(mainConfig json.RawMessage) {
cclog.Abortf("Config Init: Could not decode config file '%s'.\nError: %s\n", mainConfig, err.Error()) cclog.Abortf("Config Init: Could not decode config file '%s'.\nError: %s\n", mainConfig, err.Error())
} }
if Keys.EnableResampling != nil && Keys.EnableResampling.MinimumPoints > 0 { if Keys.EnableResampling != nil {
resampler.SetMinimumRequiredPoints(Keys.EnableResampling.MinimumPoints) policy := Keys.EnableResampling.DefaultPolicy
if policy == "" {
policy = "medium"
}
resampler.SetMinimumRequiredPoints(targetPointsForPolicy(policy))
}
}
func targetPointsForPolicy(policy string) int {
switch policy {
case "low":
return 200
case "medium":
return 500
case "high":
return 1000
default:
return 500
} }
} }

View File

@@ -92,24 +92,18 @@ var configSchema = `
"description": "Enable dynamic zoom in frontend metric plots.", "description": "Enable dynamic zoom in frontend metric plots.",
"type": "object", "type": "object",
"properties": { "properties": {
"minimum-points": { "default-policy": {
"description": "Minimum points to trigger resampling of time-series data.", "description": "Default resample policy when no user preference is set.",
"type": "integer" "type": "string",
"enum": ["low", "medium", "high"]
}, },
"trigger": { "default-algo": {
"description": "Trigger next zoom level at less than this many visible datapoints.", "description": "Default resample algorithm when no user preference is set.",
"type": "integer" "type": "string",
}, "enum": ["lttb", "average", "simple"]
"resolutions": {
"description": "Array of resampling target resolutions, in seconds.",
"type": "array",
"items": {
"type": "integer"
} }
} }
}, },
"required": ["trigger", "resolutions"]
},
"api-subjects": { "api-subjects": {
"description": "NATS subjects configuration for subscribing to job and node events.", "description": "NATS subjects configuration for subscribing to job and node events.",
"type": "object", "type": "object",

View File

@@ -327,7 +327,7 @@ type ComplexityRoot struct {
Clusters func(childComplexity int) int Clusters func(childComplexity int) int
GlobalMetrics func(childComplexity int) int GlobalMetrics func(childComplexity int) int
Job func(childComplexity int, id string) int Job func(childComplexity int, id string) int
JobMetrics func(childComplexity int, id string, metrics []string, scopes []schema.MetricScope, resolution *int) int JobMetrics func(childComplexity int, id string, metrics []string, scopes []schema.MetricScope, resolution *int, resampleAlgo *model.ResampleAlgo) int
JobStats func(childComplexity int, id string, metrics []string) int JobStats func(childComplexity int, id string, metrics []string) int
Jobs func(childComplexity int, filter []*model.JobFilter, page *model.PageRequest, order *model.OrderByInput) int Jobs func(childComplexity int, filter []*model.JobFilter, page *model.PageRequest, order *model.OrderByInput) int
JobsFootprints func(childComplexity int, filter []*model.JobFilter, metrics []string) int JobsFootprints func(childComplexity int, filter []*model.JobFilter, metrics []string) int
@@ -335,7 +335,7 @@ type ComplexityRoot struct {
JobsStatistics func(childComplexity int, filter []*model.JobFilter, metrics []string, page *model.PageRequest, sortBy *model.SortByAggregate, groupBy *model.Aggregate, numDurationBins *string, numMetricBins *int) int JobsStatistics func(childComplexity int, filter []*model.JobFilter, metrics []string, page *model.PageRequest, sortBy *model.SortByAggregate, groupBy *model.Aggregate, numDurationBins *string, numMetricBins *int) int
Node func(childComplexity int, id string) int Node func(childComplexity int, id string) int
NodeMetrics func(childComplexity int, cluster string, nodes []string, scopes []schema.MetricScope, metrics []string, from time.Time, to time.Time) int NodeMetrics func(childComplexity int, cluster string, nodes []string, scopes []schema.MetricScope, metrics []string, from time.Time, to time.Time) int
NodeMetricsList func(childComplexity int, cluster string, subCluster string, stateFilter string, nodeFilter string, scopes []schema.MetricScope, metrics []string, from time.Time, to time.Time, page *model.PageRequest, resolution *int) int NodeMetricsList func(childComplexity int, 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) int
NodeStates func(childComplexity int, filter []*model.NodeFilter) int NodeStates func(childComplexity int, filter []*model.NodeFilter) int
NodeStatesTimed func(childComplexity int, filter []*model.NodeFilter, typeArg string) int NodeStatesTimed func(childComplexity int, filter []*model.NodeFilter, typeArg string) int
Nodes func(childComplexity int, filter []*model.NodeFilter, order *model.OrderByInput) int Nodes func(childComplexity int, filter []*model.NodeFilter, order *model.OrderByInput) int
@@ -483,7 +483,7 @@ type QueryResolver interface {
NodeStates(ctx context.Context, filter []*model.NodeFilter) ([]*model.NodeStates, error) NodeStates(ctx context.Context, filter []*model.NodeFilter) ([]*model.NodeStates, error)
NodeStatesTimed(ctx context.Context, filter []*model.NodeFilter, typeArg string) ([]*model.NodeStatesTimed, error) NodeStatesTimed(ctx context.Context, filter []*model.NodeFilter, typeArg string) ([]*model.NodeStatesTimed, error)
Job(ctx context.Context, id string) (*schema.Job, error) Job(ctx context.Context, id string) (*schema.Job, error)
JobMetrics(ctx context.Context, id string, metrics []string, scopes []schema.MetricScope, resolution *int) ([]*model.JobMetricWithName, error) JobMetrics(ctx context.Context, id string, metrics []string, scopes []schema.MetricScope, resolution *int, resampleAlgo *model.ResampleAlgo) ([]*model.JobMetricWithName, error)
JobStats(ctx context.Context, id string, metrics []string) ([]*model.NamedStats, error) JobStats(ctx context.Context, id string, metrics []string) ([]*model.NamedStats, error)
ScopedJobStats(ctx context.Context, id string, metrics []string, scopes []schema.MetricScope) ([]*model.NamedStatsWithScope, error) ScopedJobStats(ctx context.Context, id string, metrics []string, scopes []schema.MetricScope) ([]*model.NamedStatsWithScope, error)
Jobs(ctx context.Context, filter []*model.JobFilter, page *model.PageRequest, order *model.OrderByInput) (*model.JobResultList, error) Jobs(ctx context.Context, filter []*model.JobFilter, page *model.PageRequest, order *model.OrderByInput) (*model.JobResultList, error)
@@ -492,7 +492,7 @@ type QueryResolver interface {
JobsFootprints(ctx context.Context, filter []*model.JobFilter, metrics []string) (*model.Footprints, error) JobsFootprints(ctx context.Context, filter []*model.JobFilter, metrics []string) (*model.Footprints, error)
RooflineHeatmap(ctx context.Context, filter []*model.JobFilter, rows int, cols int, minX float64, minY float64, maxX float64, maxY float64) ([][]float64, error) RooflineHeatmap(ctx context.Context, filter []*model.JobFilter, rows int, cols int, minX float64, minY float64, maxX float64, maxY float64) ([][]float64, error)
NodeMetrics(ctx context.Context, cluster string, nodes []string, scopes []schema.MetricScope, metrics []string, from time.Time, to time.Time) ([]*model.NodeMetrics, error) NodeMetrics(ctx context.Context, cluster string, nodes []string, scopes []schema.MetricScope, metrics []string, from time.Time, to time.Time) ([]*model.NodeMetrics, error)
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) (*model.NodesResultList, error) 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)
ClusterMetrics(ctx context.Context, cluster string, metrics []string, from time.Time, to time.Time) (*model.ClusterMetrics, error) ClusterMetrics(ctx context.Context, cluster string, metrics []string, from time.Time, to time.Time) (*model.ClusterMetrics, error)
} }
type SubClusterResolver interface { type SubClusterResolver interface {
@@ -1666,7 +1666,7 @@ func (e *executableSchema) Complexity(ctx context.Context, typeName, field strin
return 0, false return 0, false
} }
return e.ComplexityRoot.Query.JobMetrics(childComplexity, args["id"].(string), args["metrics"].([]string), args["scopes"].([]schema.MetricScope), args["resolution"].(*int)), true return e.ComplexityRoot.Query.JobMetrics(childComplexity, args["id"].(string), args["metrics"].([]string), args["scopes"].([]schema.MetricScope), args["resolution"].(*int), args["resampleAlgo"].(*model.ResampleAlgo)), true
case "Query.jobStats": case "Query.jobStats":
if e.ComplexityRoot.Query.JobStats == nil { if e.ComplexityRoot.Query.JobStats == nil {
break break
@@ -1754,7 +1754,7 @@ func (e *executableSchema) Complexity(ctx context.Context, typeName, field strin
return 0, false return 0, false
} }
return e.ComplexityRoot.Query.NodeMetricsList(childComplexity, args["cluster"].(string), args["subCluster"].(string), args["stateFilter"].(string), args["nodeFilter"].(string), args["scopes"].([]schema.MetricScope), args["metrics"].([]string), args["from"].(time.Time), args["to"].(time.Time), args["page"].(*model.PageRequest), args["resolution"].(*int)), true return e.ComplexityRoot.Query.NodeMetricsList(childComplexity, args["cluster"].(string), args["subCluster"].(string), args["stateFilter"].(string), args["nodeFilter"].(string), args["scopes"].([]schema.MetricScope), args["metrics"].([]string), args["from"].(time.Time), args["to"].(time.Time), args["page"].(*model.PageRequest), args["resolution"].(*int), args["resampleAlgo"].(*model.ResampleAlgo)), true
case "Query.nodeStates": case "Query.nodeStates":
if e.ComplexityRoot.Query.NodeStates == nil { if e.ComplexityRoot.Query.NodeStates == nil {
break break
@@ -2525,6 +2525,12 @@ type TimeWeights {
coreHours: [NullableFloat!]! coreHours: [NullableFloat!]!
} }
enum ResampleAlgo {
LTTB
AVERAGE
SIMPLE
}
enum Aggregate { enum Aggregate {
USER USER
PROJECT PROJECT
@@ -2615,6 +2621,7 @@ type Query {
metrics: [String!] metrics: [String!]
scopes: [MetricScope!] scopes: [MetricScope!]
resolution: Int resolution: Int
resampleAlgo: ResampleAlgo
): [JobMetricWithName!]! ): [JobMetricWithName!]!
jobStats(id: ID!, metrics: [String!]): [NamedStats!]! jobStats(id: ID!, metrics: [String!]): [NamedStats!]!
@@ -2674,6 +2681,7 @@ type Query {
to: Time! to: Time!
page: PageRequest page: PageRequest
resolution: Int resolution: Int
resampleAlgo: ResampleAlgo
): NodesResultList! ): NodesResultList!
clusterMetrics( clusterMetrics(
@@ -3882,6 +3890,11 @@ func (ec *executionContext) field_Query_jobMetrics_args(ctx context.Context, raw
return nil, err return nil, err
} }
args["resolution"] = arg3 args["resolution"] = arg3
arg4, err := graphql.ProcessArgField(ctx, rawArgs, "resampleAlgo", ec.unmarshalOResampleAlgo2ᚖgithubᚗcomᚋClusterCockpitᚋccᚑbackendᚋinternalᚋgraphᚋmodelᚐResampleAlgo)
if err != nil {
return nil, err
}
args["resampleAlgo"] = arg4
return args, nil return args, nil
} }
@@ -4140,6 +4153,11 @@ func (ec *executionContext) field_Query_nodeMetricsList_args(ctx context.Context
return nil, err return nil, err
} }
args["resolution"] = arg9 args["resolution"] = arg9
arg10, err := graphql.ProcessArgField(ctx, rawArgs, "resampleAlgo", ec.unmarshalOResampleAlgo2ᚖgithubᚗcomᚋClusterCockpitᚋccᚑbackendᚋinternalᚋgraphᚋmodelᚐResampleAlgo)
if err != nil {
return nil, err
}
args["resampleAlgo"] = arg10
return args, nil return args, nil
} }
@@ -9307,7 +9325,7 @@ func (ec *executionContext) _Query_jobMetrics(ctx context.Context, field graphql
}, },
func(ctx context.Context) (any, error) { func(ctx context.Context) (any, error) {
fc := graphql.GetFieldContext(ctx) fc := graphql.GetFieldContext(ctx)
return ec.Resolvers.Query().JobMetrics(ctx, fc.Args["id"].(string), fc.Args["metrics"].([]string), fc.Args["scopes"].([]schema.MetricScope), fc.Args["resolution"].(*int)) return ec.Resolvers.Query().JobMetrics(ctx, fc.Args["id"].(string), fc.Args["metrics"].([]string), fc.Args["scopes"].([]schema.MetricScope), fc.Args["resolution"].(*int), fc.Args["resampleAlgo"].(*model.ResampleAlgo))
}, },
nil, nil,
func(ctx context.Context, selections ast.SelectionSet, v []*model.JobMetricWithName) graphql.Marshaler { func(ctx context.Context, selections ast.SelectionSet, v []*model.JobMetricWithName) graphql.Marshaler {
@@ -9703,7 +9721,7 @@ func (ec *executionContext) _Query_nodeMetricsList(ctx context.Context, field gr
}, },
func(ctx context.Context) (any, error) { func(ctx context.Context) (any, error) {
fc := graphql.GetFieldContext(ctx) fc := graphql.GetFieldContext(ctx)
return ec.Resolvers.Query().NodeMetricsList(ctx, fc.Args["cluster"].(string), fc.Args["subCluster"].(string), fc.Args["stateFilter"].(string), fc.Args["nodeFilter"].(string), fc.Args["scopes"].([]schema.MetricScope), fc.Args["metrics"].([]string), fc.Args["from"].(time.Time), fc.Args["to"].(time.Time), fc.Args["page"].(*model.PageRequest), fc.Args["resolution"].(*int)) return ec.Resolvers.Query().NodeMetricsList(ctx, fc.Args["cluster"].(string), fc.Args["subCluster"].(string), fc.Args["stateFilter"].(string), fc.Args["nodeFilter"].(string), fc.Args["scopes"].([]schema.MetricScope), fc.Args["metrics"].([]string), fc.Args["from"].(time.Time), fc.Args["to"].(time.Time), fc.Args["page"].(*model.PageRequest), fc.Args["resolution"].(*int), fc.Args["resampleAlgo"].(*model.ResampleAlgo))
}, },
nil, nil,
func(ctx context.Context, selections ast.SelectionSet, v *model.NodesResultList) graphql.Marshaler { func(ctx context.Context, selections ast.SelectionSet, v *model.NodesResultList) graphql.Marshaler {
@@ -18679,6 +18697,22 @@ func (ec *executionContext) unmarshalOPageRequest2ᚖgithubᚗcomᚋClusterCockp
return &res, graphql.ErrorOnPath(ctx, err) return &res, graphql.ErrorOnPath(ctx, err)
} }
func (ec *executionContext) unmarshalOResampleAlgo2ᚖgithubᚗcomᚋClusterCockpitᚋccᚑbackendᚋinternalᚋgraphᚋmodelᚐResampleAlgo(ctx context.Context, v any) (*model.ResampleAlgo, error) {
if v == nil {
return nil, nil
}
var res = new(model.ResampleAlgo)
err := res.UnmarshalGQL(v)
return res, graphql.ErrorOnPath(ctx, err)
}
func (ec *executionContext) marshalOResampleAlgo2ᚖgithubᚗcomᚋClusterCockpitᚋccᚑbackendᚋinternalᚋgraphᚋmodelᚐResampleAlgo(ctx context.Context, sel ast.SelectionSet, v *model.ResampleAlgo) graphql.Marshaler {
if v == nil {
return graphql.Null
}
return v
}
func (ec *executionContext) unmarshalOSchedulerState2ᚖgithubᚗcomᚋClusterCockpitᚋccᚑlibᚋv2ᚋschemaᚐSchedulerState(ctx context.Context, v any) (*schema.SchedulerState, error) { func (ec *executionContext) unmarshalOSchedulerState2ᚖgithubᚗcomᚋClusterCockpitᚋccᚑlibᚋv2ᚋschemaᚐSchedulerState(ctx context.Context, v any) (*schema.SchedulerState, error) {
if v == nil { if v == nil {
return nil, nil return nil, nil

View File

@@ -328,6 +328,63 @@ func (e Aggregate) MarshalJSON() ([]byte, error) {
return buf.Bytes(), nil return buf.Bytes(), nil
} }
type ResampleAlgo string
const (
ResampleAlgoLttb ResampleAlgo = "LTTB"
ResampleAlgoAverage ResampleAlgo = "AVERAGE"
ResampleAlgoSimple ResampleAlgo = "SIMPLE"
)
var AllResampleAlgo = []ResampleAlgo{
ResampleAlgoLttb,
ResampleAlgoAverage,
ResampleAlgoSimple,
}
func (e ResampleAlgo) IsValid() bool {
switch e {
case ResampleAlgoLttb, ResampleAlgoAverage, ResampleAlgoSimple:
return true
}
return false
}
func (e ResampleAlgo) String() string {
return string(e)
}
func (e *ResampleAlgo) UnmarshalGQL(v any) error {
str, ok := v.(string)
if !ok {
return fmt.Errorf("enums must be strings")
}
*e = ResampleAlgo(str)
if !e.IsValid() {
return fmt.Errorf("%s is not a valid ResampleAlgo", str)
}
return nil
}
func (e ResampleAlgo) MarshalGQL(w io.Writer) {
fmt.Fprint(w, strconv.Quote(e.String()))
}
func (e *ResampleAlgo) UnmarshalJSON(b []byte) error {
s, err := strconv.Unquote(string(b))
if err != nil {
return err
}
return e.UnmarshalGQL(s)
}
func (e ResampleAlgo) MarshalJSON() ([]byte, error) {
var buf bytes.Buffer
e.MarshalGQL(&buf)
return buf.Bytes(), nil
}
type SortByAggregate string type SortByAggregate string
const ( const (

145
internal/graph/resample.go Normal file
View File

@@ -0,0 +1,145 @@
// Copyright (C) NHR@FAU, University Erlangen-Nuremberg.
// All rights reserved. This file is part of cc-backend.
// Use of this source code is governed by a MIT-style
// license that can be found in the LICENSE file.
package graph
import (
"context"
"strings"
"github.com/ClusterCockpit/cc-backend/internal/config"
"github.com/ClusterCockpit/cc-backend/internal/graph/model"
"github.com/ClusterCockpit/cc-backend/internal/metricdispatch"
"github.com/ClusterCockpit/cc-backend/internal/repository"
"github.com/ClusterCockpit/cc-backend/pkg/archive"
)
// resolveResolutionFromPolicy reads the user's resample policy preference and
// computes a resolution based on job duration and metric frequency. Returns nil
// if the user has no policy set.
func resolveResolutionFromPolicy(ctx context.Context, duration int64, cluster string, metrics []string) *int {
user := repository.GetUserFromContext(ctx)
if user == nil {
return nil
}
conf, err := repository.GetUserCfgRepo().GetUIConfig(user)
if err != nil {
return nil
}
policyVal, ok := conf["plotConfiguration_resamplePolicy"]
if !ok {
return nil
}
policyStr, ok := policyVal.(string)
if !ok || policyStr == "" {
return nil
}
policy := metricdispatch.ResamplePolicy(policyStr)
targetPoints := metricdispatch.TargetPointsForPolicy(policy)
if targetPoints == 0 {
return nil
}
// Find the smallest metric frequency across the requested metrics
frequency := smallestFrequency(cluster, metrics)
if frequency <= 0 {
return nil
}
res := metricdispatch.ComputeResolution(duration, int64(frequency), targetPoints)
return &res
}
// resolveResampleAlgo returns the resampling algorithm name to use, checking
// the explicit GraphQL parameter first, then the user's preference.
func resolveResampleAlgo(ctx context.Context, resampleAlgo *model.ResampleAlgo) string {
if resampleAlgo != nil {
return strings.ToLower(resampleAlgo.String())
}
user := repository.GetUserFromContext(ctx)
if user == nil {
return ""
}
conf, err := repository.GetUserCfgRepo().GetUIConfig(user)
if err != nil {
return ""
}
algoVal, ok := conf["plotConfiguration_resampleAlgo"]
if ok {
if algoStr, ok := algoVal.(string); ok && algoStr != "" {
return algoStr
}
}
// Fall back to global default algo
if config.Keys.EnableResampling != nil && config.Keys.EnableResampling.DefaultAlgo != "" {
return config.Keys.EnableResampling.DefaultAlgo
}
return ""
}
// resolveResolutionFromDefaultPolicy computes a resolution using the global
// default policy from config. Returns nil if no policy is configured.
func resolveResolutionFromDefaultPolicy(duration int64, cluster string, metrics []string) *int {
cfg := config.Keys.EnableResampling
if cfg == nil {
return nil
}
policyStr := cfg.DefaultPolicy
if policyStr == "" {
policyStr = "medium"
}
policy := metricdispatch.ResamplePolicy(policyStr)
targetPoints := metricdispatch.TargetPointsForPolicy(policy)
if targetPoints == 0 {
return nil
}
frequency := smallestFrequency(cluster, metrics)
if frequency <= 0 {
return nil
}
res := metricdispatch.ComputeResolution(duration, int64(frequency), targetPoints)
return &res
}
// smallestFrequency returns the smallest metric timestep (in seconds) among the
// requested metrics for the given cluster. Falls back to 0 if nothing is found.
func smallestFrequency(cluster string, metrics []string) int {
cl := archive.GetCluster(cluster)
if cl == nil {
return 0
}
minFreq := 0
for _, mc := range cl.MetricConfig {
if len(metrics) > 0 {
found := false
for _, m := range metrics {
if mc.Name == m {
found = true
break
}
}
if !found {
continue
}
}
if minFreq == 0 || mc.Timestep < minFreq {
minFreq = mc.Timestep
}
}
return minFreq
}

View File

@@ -498,24 +498,30 @@ func (r *queryResolver) Job(ctx context.Context, id string) (*schema.Job, error)
} }
// JobMetrics is the resolver for the jobMetrics field. // JobMetrics is the resolver for the jobMetrics field.
func (r *queryResolver) JobMetrics(ctx context.Context, id string, metrics []string, scopes []schema.MetricScope, resolution *int) ([]*model.JobMetricWithName, error) { 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) job, err := r.Query().Job(ctx, id)
if err != nil { if err != nil {
cclog.Warn("Error while querying job for metrics") cclog.Warn("Error while querying job for metrics")
return nil, err return nil, err
} }
data, err := metricdispatch.LoadData(job, metrics, scopes, ctx, *resolution) // Resolve resolution: explicit param > user policy > global config > 0
if resolution == nil {
resolution = resolveResolutionFromPolicy(ctx, int64(job.Duration), job.Cluster, metrics)
}
if resolution == nil {
if config.Keys.EnableResampling != nil {
resolution = resolveResolutionFromDefaultPolicy(int64(job.Duration), job.Cluster, metrics)
}
if resolution == nil {
defaultRes := 0
resolution = &defaultRes
}
}
algoName := resolveResampleAlgo(ctx, resampleAlgo)
data, err := metricdispatch.LoadData(job, metrics, scopes, ctx, *resolution, algoName)
if err != nil { if err != nil {
cclog.Warn("Error while loading job data") cclog.Warn("Error while loading job data")
return nil, err return nil, err
@@ -877,12 +883,17 @@ func (r *queryResolver) NodeMetrics(ctx context.Context, cluster string, nodes [
} }
// NodeMetricsList is the resolver for the nodeMetricsList field. // 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) (*model.NodesResultList, error) { 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 // Resolve resolution: explicit param > user policy > global config > 0
duration := int64(to.Sub(from).Seconds())
if resolution == nil {
resolution = resolveResolutionFromPolicy(ctx, duration, cluster, metrics)
}
if resolution == nil {
if config.Keys.EnableResampling != nil { if config.Keys.EnableResampling != nil {
defaultRes := slices.Max(config.Keys.EnableResampling.Resolutions) resolution = resolveResolutionFromDefaultPolicy(duration, cluster, metrics)
resolution = &defaultRes }
} else { // Set 0 (Loads configured metric timestep) if resolution == nil {
defaultRes := 0 defaultRes := 0
resolution = &defaultRes resolution = &defaultRes
} }
@@ -906,8 +917,10 @@ func (r *queryResolver) NodeMetricsList(ctx context.Context, cluster string, sub
} }
} }
algoName := resolveResampleAlgo(ctx, resampleAlgo)
// data -> map hostname:jobdata // data -> map hostname:jobdata
data, err := metricdispatch.LoadNodeListData(cluster, subCluster, nodes, metrics, scopes, *resolution, from, to, ctx) data, err := metricdispatch.LoadNodeListData(cluster, subCluster, nodes, metrics, scopes, *resolution, from, to, ctx, algoName)
if err != nil { if err != nil {
cclog.Warn("error while loading node data (Resolver.NodeMetricsList") cclog.Warn("error while loading node data (Resolver.NodeMetricsList")
return nil, err return nil, err

View File

@@ -55,7 +55,7 @@ func (r *queryResolver) rooflineHeatmap(
// resolution = max(resolution, mc.Timestep) // resolution = max(resolution, mc.Timestep)
// } // }
jobdata, err := metricdispatch.LoadData(job, []string{"flops_any", "mem_bw"}, []schema.MetricScope{schema.MetricScopeNode}, ctx, 0) jobdata, err := metricdispatch.LoadData(job, []string{"flops_any", "mem_bw"}, []schema.MetricScope{schema.MetricScopeNode}, ctx, 0, "")
if err != nil { if err != nil {
cclog.Warnf("Error while loading roofline metrics for job %d", *job.ID) cclog.Warnf("Error while loading roofline metrics for job %d", *job.ID)
return nil, err return nil, err

View File

@@ -62,9 +62,10 @@ func cacheKey(
metrics []string, metrics []string,
scopes []schema.MetricScope, scopes []schema.MetricScope,
resolution int, resolution int,
resampleAlgo string,
) string { ) string {
return fmt.Sprintf("%d(%s):[%v],[%v]-%d", return fmt.Sprintf("%d(%s):[%v],[%v]-%d-%s",
*job.ID, job.State, metrics, scopes, resolution) *job.ID, job.State, metrics, scopes, resolution, resampleAlgo)
} }
// LoadData retrieves metric data for a job from the appropriate backend (memory store for running jobs, // LoadData retrieves metric data for a job from the appropriate backend (memory store for running jobs,
@@ -87,8 +88,9 @@ func LoadData(job *schema.Job,
scopes []schema.MetricScope, scopes []schema.MetricScope,
ctx context.Context, ctx context.Context,
resolution int, resolution int,
resampleAlgo string,
) (schema.JobData, error) { ) (schema.JobData, error) {
data := cache.Get(cacheKey(job, metrics, scopes, resolution), func() (_ any, ttl time.Duration, size int) { data := cache.Get(cacheKey(job, metrics, scopes, resolution, resampleAlgo), func() (_ any, ttl time.Duration, size int) {
var jd schema.JobData var jd schema.JobData
var err error var err error
@@ -136,13 +138,17 @@ func LoadData(job *schema.Job,
jd = deepCopy(jdTemp) jd = deepCopy(jdTemp)
// Resample archived data using Largest Triangle Three Bucket algorithm to reduce data points // Resample archived data to reduce data points to the requested resolution,
// to the requested resolution, improving transfer performance and client-side rendering. // improving transfer performance and client-side rendering.
resampleFn, rfErr := resampler.GetResampler(resampleAlgo)
if rfErr != nil {
return rfErr, 0, 0
}
for _, v := range jd { for _, v := range jd {
for _, v_ := range v { for _, v_ := range v {
timestep := int64(0) timestep := int64(0)
for i := 0; i < len(v_.Series); i += 1 { for i := 0; i < len(v_.Series); i += 1 {
v_.Series[i].Data, timestep, err = resampler.LargestTriangleThreeBucket(v_.Series[i].Data, int64(v_.Timestep), int64(resolution)) v_.Series[i].Data, timestep, err = resampleFn(v_.Series[i].Data, int64(v_.Timestep), int64(resolution))
if err != nil { if err != nil {
return err, 0, 0 return err, 0, 0
} }
@@ -414,6 +420,7 @@ func LoadNodeListData(
resolution int, resolution int,
from, to time.Time, from, to time.Time,
ctx context.Context, ctx context.Context,
resampleAlgo string,
) (map[string]schema.JobData, error) { ) (map[string]schema.JobData, error) {
if metrics == nil { if metrics == nil {
for _, m := range archive.GetCluster(cluster).MetricConfig { for _, m := range archive.GetCluster(cluster).MetricConfig {
@@ -428,7 +435,7 @@ func LoadNodeListData(
return nil, err return nil, err
} }
data, err := ms.LoadNodeListData(cluster, subCluster, nodes, metrics, scopes, resolution, from, to, ctx) data, err := ms.LoadNodeListData(cluster, subCluster, nodes, metrics, scopes, resolution, from, to, ctx, resampleAlgo)
if err != nil { if err != nil {
if len(data) != 0 { if len(data) != 0 {
cclog.Warnf("partial error loading node list data from metric store for cluster %s, subcluster %s: %s", cclog.Warnf("partial error loading node list data from metric store for cluster %s, subcluster %s: %s",

View File

@@ -51,7 +51,8 @@ type MetricDataRepository interface {
scopes []schema.MetricScope, scopes []schema.MetricScope,
resolution int, resolution int,
from, to time.Time, from, to time.Time,
ctx context.Context) (map[string]schema.JobData, error) ctx context.Context,
resampleAlgo string) (map[string]schema.JobData, error)
// HealthCheck evaluates the monitoring state for a set of nodes against expected metrics. // HealthCheck evaluates the monitoring state for a set of nodes against expected metrics.
HealthCheck(cluster string, HealthCheck(cluster string,

View File

@@ -0,0 +1,49 @@
// Copyright (C) NHR@FAU, University Erlangen-Nuremberg.
// All rights reserved. This file is part of cc-backend.
// Use of this source code is governed by a MIT-style
// license that can be found in the LICENSE file.
package metricdispatch
import "math"
type ResamplePolicy string
const (
ResamplePolicyLow ResamplePolicy = "low"
ResamplePolicyMedium ResamplePolicy = "medium"
ResamplePolicyHigh ResamplePolicy = "high"
)
// TargetPointsForPolicy returns the target number of data points for a given policy.
func TargetPointsForPolicy(policy ResamplePolicy) int {
switch policy {
case ResamplePolicyLow:
return 200
case ResamplePolicyMedium:
return 500
case ResamplePolicyHigh:
return 1000
default:
return 0
}
}
// ComputeResolution computes the resampling resolution in seconds for a given
// job duration, metric frequency, and target point count. Returns 0 if the
// total number of data points is already at or below targetPoints (no resampling needed).
func ComputeResolution(duration int64, frequency int64, targetPoints int) int {
if frequency <= 0 || targetPoints <= 0 || duration <= 0 {
return 0
}
totalPoints := duration / frequency
if totalPoints <= int64(targetPoints) {
return 0
}
targetRes := math.Ceil(float64(duration) / float64(targetPoints))
// Round up to nearest multiple of frequency
resolution := int(math.Ceil(targetRes/float64(frequency))) * int(frequency)
return resolution
}

View File

@@ -0,0 +1,68 @@
// Copyright (C) NHR@FAU, University Erlangen-Nuremberg.
// All rights reserved. This file is part of cc-backend.
// Use of this source code is governed by a MIT-style
// license that can be found in the LICENSE file.
package metricdispatch
import "testing"
func TestTargetPointsForPolicy(t *testing.T) {
tests := []struct {
policy ResamplePolicy
want int
}{
{ResamplePolicyLow, 200},
{ResamplePolicyMedium, 500},
{ResamplePolicyHigh, 1000},
{ResamplePolicy("unknown"), 0},
{ResamplePolicy(""), 0},
}
for _, tt := range tests {
if got := TargetPointsForPolicy(tt.policy); got != tt.want {
t.Errorf("TargetPointsForPolicy(%q) = %d, want %d", tt.policy, got, tt.want)
}
}
}
func TestComputeResolution(t *testing.T) {
tests := []struct {
name string
duration int64
frequency int64
targetPoints int
want int
}{
// 24h job, 60s frequency, 1440 total points
{"low_24h_60s", 86400, 60, 200, 480},
{"medium_24h_60s", 86400, 60, 500, 180},
{"high_24h_60s", 86400, 60, 1000, 120},
// 2h job, 60s frequency, 120 total points — no resampling needed
{"low_2h_60s", 7200, 60, 200, 0},
{"medium_2h_60s", 7200, 60, 500, 0},
{"high_2h_60s", 7200, 60, 1000, 0},
// Edge: zero/negative inputs
{"zero_duration", 0, 60, 200, 0},
{"zero_frequency", 86400, 0, 200, 0},
{"zero_target", 86400, 60, 0, 0},
{"negative_duration", -100, 60, 200, 0},
// 12h job, 30s frequency, 1440 total points
{"medium_12h_30s", 43200, 30, 500, 90},
// Exact fit: total points == target points
{"exact_fit", 12000, 60, 200, 0},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := ComputeResolution(tt.duration, tt.frequency, tt.targetPoints)
if got != tt.want {
t.Errorf("ComputeResolution(%d, %d, %d) = %d, want %d",
tt.duration, tt.frequency, tt.targetPoints, got, tt.want)
}
})
}
}

View File

@@ -617,6 +617,7 @@ func (ccms *CCMetricStore) LoadNodeListData(
resolution int, resolution int,
from, to time.Time, from, to time.Time,
ctx context.Context, ctx context.Context,
resampleAlgo string,
) (map[string]schema.JobData, error) { ) (map[string]schema.JobData, error) {
queries, assignedScope, err := ccms.buildNodeQueries(cluster, subCluster, nodes, metrics, scopes, resolution) queries, assignedScope, err := ccms.buildNodeQueries(cluster, subCluster, nodes, metrics, scopes, resolution)
if err != nil { if err != nil {

Binary file not shown.

View File

@@ -15,6 +15,7 @@ import (
"github.com/ClusterCockpit/cc-backend/internal/config" "github.com/ClusterCockpit/cc-backend/internal/config"
"github.com/ClusterCockpit/cc-backend/internal/graph/model" "github.com/ClusterCockpit/cc-backend/internal/graph/model"
"github.com/ClusterCockpit/cc-backend/internal/metricdispatch"
"github.com/ClusterCockpit/cc-backend/internal/repository" "github.com/ClusterCockpit/cc-backend/internal/repository"
"github.com/ClusterCockpit/cc-backend/web" "github.com/ClusterCockpit/cc-backend/web"
cclog "github.com/ClusterCockpit/cc-lib/v2/ccLogger" cclog "github.com/ClusterCockpit/cc-lib/v2/ccLogger"
@@ -496,13 +497,15 @@ func SetupRoutes(router chi.Router, buildInfo web.Build) {
// Get Roles // Get Roles
availableRoles, _ := schema.GetValidRolesMap(user) availableRoles, _ := schema.GetValidRolesMap(user)
resampling := resamplingForUser(conf)
page := web.Page{ page := web.Page{
Title: title, Title: title,
User: *user, User: *user,
Roles: availableRoles, Roles: availableRoles,
Build: buildInfo, Build: buildInfo,
Config: conf, Config: conf,
Resampling: config.Keys.EnableResampling, Resampling: resampling,
Infos: infos, Infos: infos,
} }
@@ -589,3 +592,36 @@ func HandleSearchBar(rw http.ResponseWriter, r *http.Request, buildInfo web.Buil
web.RenderTemplate(rw, "message.tmpl", &web.Page{Title: "Warning", MsgType: "alert-warning", Message: "Empty search", User: *user, Roles: availableRoles, Build: buildInfo}) web.RenderTemplate(rw, "message.tmpl", &web.Page{Title: "Warning", MsgType: "alert-warning", Message: "Empty search", User: *user, Roles: availableRoles, Build: buildInfo})
} }
} }
// resamplingForUser returns a ResampleConfig that incorporates the user's
// resample policy preference. If the user has a policy set, it creates a
// policy-derived config with targetPoints and trigger. Otherwise falls back
// to the global config.
func resamplingForUser(conf map[string]any) *config.ResampleConfig {
globalCfg := config.Keys.EnableResampling
policyStr := ""
if policyVal, ok := conf["plotConfiguration_resamplePolicy"]; ok {
if s, ok := policyVal.(string); ok {
policyStr = s
}
}
// Fall back to global default policy, then to "medium"
if policyStr == "" && globalCfg != nil {
policyStr = globalCfg.DefaultPolicy
}
if policyStr == "" {
policyStr = "medium"
}
policy := metricdispatch.ResamplePolicy(policyStr)
targetPoints := metricdispatch.TargetPointsForPolicy(policy)
if targetPoints == 0 {
return globalCfg
}
return &config.ResampleConfig{
TargetPoints: targetPoints,
}
}

View File

@@ -59,6 +59,7 @@ type APIQueryRequest struct {
WithStats bool `json:"with-stats"` WithStats bool `json:"with-stats"`
WithData bool `json:"with-data"` WithData bool `json:"with-data"`
WithPadding bool `json:"with-padding"` WithPadding bool `json:"with-padding"`
ResampleAlgo string `json:"resample-algo,omitempty"`
} }
// APIQueryResponse represents the response to an APIQueryRequest. // APIQueryResponse represents the response to an APIQueryRequest.
@@ -279,7 +280,7 @@ func FetchData(req APIQueryRequest) (*APIQueryResponse, error) {
for _, sel := range sels { for _, sel := range sels {
data := APIMetricData{} data := APIMetricData{}
data.Data, data.From, data.To, data.Resolution, err = ms.Read(sel, query.Metric, req.From, req.To, query.Resolution) data.Data, data.From, data.To, data.Resolution, err = ms.Read(sel, query.Metric, req.From, req.To, query.Resolution, req.ResampleAlgo)
if err != nil { if err != nil {
// Skip Error If Just Missing Host or Metric, Continue // Skip Error If Just Missing Host or Metric, Continue
// Empty Return For Metric Handled Gracefully By Frontend // Empty Return For Metric Handled Gracefully By Frontend

View File

@@ -701,7 +701,7 @@ func (m *MemoryStore) WriteToLevel(l *Level, selector []string, ts int64, metric
// If the level does not hold the metric itself, the data will be aggregated recursively from the children. // If the level does not hold the metric itself, the data will be aggregated recursively from the children.
// The second and third return value are the actual from/to for the data. Those can be different from // The second and third return value are the actual from/to for the data. Those can be different from
// the range asked for if no data was available. // the range asked for if no data was available.
func (m *MemoryStore) Read(selector util.Selector, metric string, from, to, resolution int64) ([]schema.Float, int64, int64, int64, error) { func (m *MemoryStore) Read(selector util.Selector, metric string, from, to, resolution int64, resampleAlgo string) ([]schema.Float, int64, int64, int64, error) {
if from > to { if from > to {
return nil, 0, 0, 0, errors.New("[METRICSTORE]> invalid time range") return nil, 0, 0, 0, errors.New("[METRICSTORE]> invalid time range")
} }
@@ -759,7 +759,11 @@ func (m *MemoryStore) Read(selector util.Selector, metric string, from, to, reso
} }
} }
data, resolution, err = resampler.LargestTriangleThreeBucket(data, minfo.Frequency, resolution) resampleFn, rfErr := resampler.GetResampler(resampleAlgo)
if rfErr != nil {
return nil, 0, 0, 0, rfErr
}
data, resolution, err = resampleFn(data, minfo.Frequency, resolution)
if err != nil { if err != nil {
return nil, 0, 0, 0, err return nil, 0, 0, 0, err
} }

View File

@@ -621,6 +621,7 @@ func (ccms *InternalMetricStore) LoadNodeListData(
resolution int, resolution int,
from, to time.Time, from, to time.Time,
ctx context.Context, ctx context.Context,
resampleAlgo string,
) (map[string]schema.JobData, error) { ) (map[string]schema.JobData, error) {
// Note: Order of node data is not guaranteed after this point // Note: Order of node data is not guaranteed after this point
queries, assignedScope, err := buildNodeQueries(cluster, subCluster, nodes, metrics, scopes, int64(resolution)) queries, assignedScope, err := buildNodeQueries(cluster, subCluster, nodes, metrics, scopes, int64(resolution))
@@ -642,6 +643,7 @@ func (ccms *InternalMetricStore) LoadNodeListData(
To: to.Unix(), To: to.Unix(),
WithStats: true, WithStats: true,
WithData: true, WithData: true,
ResampleAlgo: resampleAlgo,
} }
resBody, err := FetchData(req) resBody, err := FetchData(req)

View File

@@ -16,6 +16,7 @@
import Options from "./admin/Options.svelte"; import Options from "./admin/Options.svelte";
import NoticeEdit from "./admin/NoticeEdit.svelte"; import NoticeEdit from "./admin/NoticeEdit.svelte";
import RunTaggers from "./admin/RunTaggers.svelte"; import RunTaggers from "./admin/RunTaggers.svelte";
import PlotRenderOptions from "./user/PlotRenderOptions.svelte";
/* Svelte 5 Props */ /* Svelte 5 Props */
let { let {
@@ -29,6 +30,8 @@
/* State Init */ /* State Init */
let users = $state([]); let users = $state([]);
let roles = $state([]); let roles = $state([]);
let message = $state({ msg: "", target: "", color: "#d63384" });
let displayMessage = $state(false);
/* Functions */ /* Functions */
function getUserList() { function getUserList() {
@@ -52,6 +55,37 @@
getValidRoles(); getValidRoles();
} }
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();
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);
}
/* on Mount */ /* on Mount */
onMount(() => initAdmin()); onMount(() => initAdmin());
</script> </script>
@@ -73,3 +107,4 @@
<NoticeEdit {ncontent}/> <NoticeEdit {ncontent}/>
<RunTaggers /> <RunTaggers />
</Row> </Row>
<PlotRenderOptions config={ccconfig} bind:message bind:displayMessage updateSetting={(e, newSetting) => handleSettingSubmit(e, newSetting)}/>

View File

@@ -16,6 +16,7 @@
Card, Card,
CardTitle, CardTitle,
} from "@sveltestrap/sveltestrap"; } from "@sveltestrap/sveltestrap";
import { getContext } from "svelte";
import { fade } from "svelte/transition"; import { fade } from "svelte/transition";
/* Svelte 5 Props */ /* Svelte 5 Props */
@@ -25,6 +26,8 @@
displayMessage = $bindable(), displayMessage = $bindable(),
updateSetting updateSetting
} = $props(); } = $props();
const resampleConfig = getContext("resampling");
</script> </script>
<Row cols={3} class="p-2 g-2"> <Row cols={3} class="p-2 g-2">
@@ -64,7 +67,7 @@
id="lwvalue" id="lwvalue"
name="value" name="value"
aria-describedby="lineWidthHelp" aria-describedby="lineWidthHelp"
value={config.plotConfiguration_lineWidth} value={config?.plotConfiguration_lineWidth}
min="1" min="1"
/> />
<div id="lineWidthHelp" class="form-text"> <div id="lineWidthHelp" class="form-text">
@@ -111,7 +114,7 @@
id="pprvalue" id="pprvalue"
name="value" name="value"
aria-describedby="plotsperrowHelp" aria-describedby="plotsperrowHelp"
value={config.plotConfiguration_plotsPerRow} value={config?.plotConfiguration_plotsPerRow}
min="1" min="1"
/> />
<div id="plotsperrowHelp" class="form-text"> <div id="plotsperrowHelp" class="form-text">
@@ -153,7 +156,7 @@
<input type="hidden" name="key" value="plotConfiguration_colorBackground" /> <input type="hidden" name="key" value="plotConfiguration_colorBackground" />
<div class="mb-3"> <div class="mb-3">
<div> <div>
{#if config.plotConfiguration_colorBackground} {#if config?.plotConfiguration_colorBackground}
<input type="radio" id="colb-true-checked" name="value" value="true" checked /> <input type="radio" id="colb-true-checked" name="value" value="true" checked />
{:else} {:else}
<input type="radio" id="colb-true" name="value" value="true" /> <input type="radio" id="colb-true" name="value" value="true" />
@@ -161,7 +164,7 @@
<label for="true">Yes</label> <label for="true">Yes</label>
</div> </div>
<div> <div>
{#if config.plotConfiguration_colorBackground} {#if config?.plotConfiguration_colorBackground}
<input type="radio" id="colb-false" name="value" value="false" /> <input type="radio" id="colb-false" name="value" value="false" />
{:else} {:else}
<input type="radio" id="colb-false-checked" name="value" value="false" checked /> <input type="radio" id="colb-false-checked" name="value" value="false" checked />
@@ -219,4 +222,90 @@
</form> </form>
</Card> </Card>
</Col> </Col>
<!-- RESAMPLE POLICY -->
<Col>
<Card class="h-100">
<form
id="resample-policy-form"
method="post"
action="/frontend/configuration/"
class="card-body"
onsubmit={(e) => updateSetting(e, {
selector: "#resample-policy-form",
target: "rsp",
})}
>
<CardTitle
style="margin-bottom: 1em; display: flex; align-items: center;"
>
<div>Resample Policy</div>
{#if displayMessage && message.target == "rsp"}
<div style="margin-left: auto; font-size: 0.9em;">
<code style="color: {message.color};" out:fade>
Update: {message.msg}
</code>
</div>
{/if}
</CardTitle>
<input type="hidden" name="key" value="plotConfiguration_resamplePolicy" />
<div class="mb-3">
{#each [["", "Default"], ["low", "Low"], ["medium", "Medium"], ["high", "High"]] as [val, label]}
<div>
<input type="radio" id="rsp-{val || 'default'}" name="value" value={JSON.stringify(val)}
checked={(!config?.plotConfiguration_resamplePolicy && val === "") || config?.plotConfiguration_resamplePolicy === val} />
<label for="rsp-{val || 'default'}">{label}</label>
</div>
{/each}
<div id="resamplePolicyHelp" class="form-text">
Controls how many data points are shown in metric plots. Low = fast overview (~200 points), Medium = balanced (~500), High = maximum detail (~1000).
</div>
</div>
<Button color="primary" type="submit">Submit</Button>
</form>
</Card>
</Col>
<!-- RESAMPLE ALGORITHM -->
<Col>
<Card class="h-100">
<form
id="resample-algo-form"
method="post"
action="/frontend/configuration/"
class="card-body"
onsubmit={(e) => updateSetting(e, {
selector: "#resample-algo-form",
target: "rsa",
})}
>
<CardTitle
style="margin-bottom: 1em; display: flex; align-items: center;"
>
<div>Resample Algorithm</div>
{#if displayMessage && message.target == "rsa"}
<div style="margin-left: auto; font-size: 0.9em;">
<code style="color: {message.color};" out:fade>
Update: {message.msg}
</code>
</div>
{/if}
</CardTitle>
<input type="hidden" name="key" value="plotConfiguration_resampleAlgo" />
<div class="mb-3">
{#each [["", "Default"], ["lttb", "LTTB"], ["average", "Average"], ["simple", "Simple"]] as [val, label]}
<div>
<input type="radio" id="rsa-{val || 'default'}" name="value" value={JSON.stringify(val)}
checked={(!config?.plotConfiguration_resampleAlgo && val === "") || config?.plotConfiguration_resampleAlgo === val} />
<label for="rsa-{val || 'default'}">{label}</label>
</div>
{/each}
<div id="resampleAlgoHelp" class="form-text">
Algorithm used when downsampling time-series data. LTTB preserves visual shape, Average smooths data, Simple picks every Nth point.
</div>
</div>
<Button color="primary" type="submit">Submit</Button>
</form>
</Card>
</Col>
</Row> </Row>

View File

@@ -73,9 +73,10 @@
const subClusterTopology = $derived(getContext("getHardwareTopology")(cluster, subCluster)); const subClusterTopology = $derived(getContext("getHardwareTopology")(cluster, subCluster));
const metricConfig = $derived(getContext("getMetricConfig")(cluster, subCluster, metric)); const metricConfig = $derived(getContext("getMetricConfig")(cluster, subCluster, metric));
const usesMeanStatsSeries = $derived((statisticsSeries?.mean && statisticsSeries.mean.length != 0)); const usesMeanStatsSeries = $derived((statisticsSeries?.mean && statisticsSeries.mean.length != 0));
const resampleTrigger = $derived(resampleConfig?.trigger ? Number(resampleConfig.trigger) : null); const resampleTrigger = $derived(resampleConfig?.trigger ? Number(resampleConfig.trigger) : (resampleConfig?.targetPoints ? Math.floor(resampleConfig.targetPoints / 4) : null));
const resampleResolutions = $derived(resampleConfig?.resolutions ? [...resampleConfig.resolutions] : null); const resampleResolutions = $derived(resampleConfig?.resolutions ? [...resampleConfig.resolutions] : null);
const resampleMinimum = $derived(resampleConfig?.resolutions ? Math.min(...resampleConfig.resolutions) : null); const resampleMinimum = $derived(resampleConfig?.resolutions ? Math.min(...resampleConfig.resolutions) : null);
const resampleTargetPoints = $derived(resampleConfig?.targetPoints ? Number(resampleConfig.targetPoints) : null);
const useStatsSeries = $derived(!!statisticsSeries); // Display Stats Series By Default if Exists const useStatsSeries = $derived(!!statisticsSeries); // Display Stats Series By Default if Exists
const thresholds = $derived(findJobAggregationThresholds( const thresholds = $derived(findJobAggregationThresholds(
subClusterTopology, subClusterTopology,
@@ -515,24 +516,29 @@
if (resampleConfig && !forNode && key === 'x') { if (resampleConfig && !forNode && key === 'x') {
const numX = (u.series[0].idxs[1] - u.series[0].idxs[0]) const numX = (u.series[0].idxs[1] - u.series[0].idxs[0])
if (numX <= resampleTrigger && timestep !== resampleMinimum) { if (numX <= resampleTrigger && timestep !== resampleMinimum) {
/* Get closest zoom level; prevents multiple iterative zoom requests for big zoom-steps (e.g. 600 -> 300 -> 120 -> 60) */ let newRes;
// Which resolution to theoretically request to achieve 30 or more visible data points: if (resampleTargetPoints && !resampleResolutions) {
const target = (numX * timestep) / resampleTrigger // Policy-based: compute resolution dynamically from visible window
// Which configured resolution actually matches the closest to theoretical target: const visibleDuration = (u.scales.x.max - u.scales.x.min);
const closest = resampleResolutions.reduce(function(prev, curr) { const nativeTimestep = metricConfig?.timestep || timestep;
newRes = Math.ceil(visibleDuration / resampleTargetPoints / nativeTimestep) * nativeTimestep;
if (newRes < nativeTimestep) newRes = nativeTimestep;
} else if (resampleResolutions) {
// Array-based: find closest configured resolution
const target = (numX * timestep) / resampleTrigger;
newRes = resampleResolutions.reduce(function(prev, curr) {
return (Math.abs(curr - target) < Math.abs(prev - target) ? curr : prev); return (Math.abs(curr - target) < Math.abs(prev - target) ? curr : prev);
}); });
}
// Prevents non-required dispatches // Prevents non-required dispatches
if (timestep !== closest) { if (newRes && timestep !== newRes) {
// console.log('Dispatch: Zoom with Res from / to', timestep, closest)
onZoom({ onZoom({
newRes: closest, newRes: newRes,
lastZoomState: u?.scales, lastZoomState: u?.scales,
lastThreshold: thresholds?.normal lastThreshold: thresholds?.normal
}); });
} }
} else { } else {
// console.log('Dispatch: Zoom Update States')
onZoom({ onZoom({
lastZoomState: u?.scales, lastZoomState: u?.scales,
lastThreshold: thresholds?.normal lastThreshold: thresholds?.normal

View File

@@ -72,6 +72,8 @@ type PlotConfiguration struct {
PlotsPerRow int `json:"plots-per-row"` PlotsPerRow int `json:"plots-per-row"`
LineWidth int `json:"line-width"` LineWidth int `json:"line-width"`
ColorScheme []string `json:"color-scheme"` ColorScheme []string `json:"color-scheme"`
ResampleAlgo string `json:"resample-algo"`
ResamplePolicy string `json:"resample-policy"`
} }
var UIDefaults = WebConfig{ var UIDefaults = WebConfig{
@@ -144,6 +146,8 @@ func Init(rawConfig json.RawMessage) error {
UIDefaultsMap["plotConfiguration_plotsPerRow"] = UIDefaults.PlotConfiguration.PlotsPerRow UIDefaultsMap["plotConfiguration_plotsPerRow"] = UIDefaults.PlotConfiguration.PlotsPerRow
UIDefaultsMap["plotConfiguration_lineWidth"] = UIDefaults.PlotConfiguration.LineWidth UIDefaultsMap["plotConfiguration_lineWidth"] = UIDefaults.PlotConfiguration.LineWidth
UIDefaultsMap["plotConfiguration_colorScheme"] = UIDefaults.PlotConfiguration.ColorScheme UIDefaultsMap["plotConfiguration_colorScheme"] = UIDefaults.PlotConfiguration.ColorScheme
UIDefaultsMap["plotConfiguration_resampleAlgo"] = UIDefaults.PlotConfiguration.ResampleAlgo
UIDefaultsMap["plotConfiguration_resamplePolicy"] = UIDefaults.PlotConfiguration.ResamplePolicy
for _, c := range UIDefaults.MetricConfig.Clusters { for _, c := range UIDefaults.MetricConfig.Clusters {
if c.JobListMetrics != nil { if c.JobListMetrics != nil {