From 16ec1e69d949a3266eb71b67bdd23245366e11bd Mon Sep 17 00:00:00 2001 From: Christoph Kluge Date: Thu, 19 Mar 2026 13:30:38 +0100 Subject: [PATCH 1/5] streamline and unify statsSeries calc and render --- internal/metricdispatch/dataLoader.go | 4 ++-- web/frontend/src/generic/joblist/JobListRow.svelte | 2 +- web/frontend/src/generic/plots/DoubleMetricPlot.svelte | 4 ++-- web/frontend/src/generic/plots/MetricPlot.svelte | 8 +------- web/frontend/src/job/Metric.svelte | 1 - web/frontend/src/systems/nodelist/NodeListRow.svelte | 5 ++--- 6 files changed, 8 insertions(+), 16 deletions(-) diff --git a/internal/metricdispatch/dataLoader.go b/internal/metricdispatch/dataLoader.go index c420fee4..2970f527 100644 --- a/internal/metricdispatch/dataLoader.go +++ b/internal/metricdispatch/dataLoader.go @@ -192,10 +192,10 @@ func LoadData(job *schema.Job, // Generate statistics series for jobs with many nodes to enable min/median/max graphs // instead of overwhelming the UI with individual node lines. Note that newly calculated // statistics use min/median/max, while archived statistics may use min/mean/max. - const maxSeriesSize int = 15 + const maxSeriesSize int = 8 for _, scopes := range jd { for _, jm := range scopes { - if jm.StatisticsSeries != nil || len(jm.Series) <= maxSeriesSize { + if jm.StatisticsSeries != nil || len(jm.Series) < maxSeriesSize { continue } diff --git a/web/frontend/src/generic/joblist/JobListRow.svelte b/web/frontend/src/generic/joblist/JobListRow.svelte index e9382bee..c0b18e63 100644 --- a/web/frontend/src/generic/joblist/JobListRow.svelte +++ b/web/frontend/src/generic/joblist/JobListRow.svelte @@ -229,7 +229,7 @@ timestep={metric.data.metric.timestep} scope={metric.data.scope} series={metric.data.metric.series} - statisticsSeries={metric.data.metric.statisticsSeries} + statisticsSeries={metric.data.metric?.statisticsSeries} metric={metric.data.name} cluster={clusterInfos.find((c) => c.name == job.cluster)} subCluster={job.subCluster} diff --git a/web/frontend/src/generic/plots/DoubleMetricPlot.svelte b/web/frontend/src/generic/plots/DoubleMetricPlot.svelte index a3a5bb28..5372aa22 100644 --- a/web/frontend/src/generic/plots/DoubleMetricPlot.svelte +++ b/web/frontend/src/generic/plots/DoubleMetricPlot.svelte @@ -236,7 +236,7 @@ // conditional hide series color markers: if ( - // useStatsSeries || // Min/Max/Median Self-Explanatory + // Min/Max/Median Self-Explanatory dataSize === 1 || // Only one Y-Dataseries dataSize > 8 // More than 8 Y-Dataseries ) { @@ -273,7 +273,7 @@ } } - if (dataSize <= 12 ) { // || useStatsSeries) { + if (dataSize <= 12 ) { return { hooks: { init: init, diff --git a/web/frontend/src/generic/plots/MetricPlot.svelte b/web/frontend/src/generic/plots/MetricPlot.svelte index 3969161d..7f72a298 100644 --- a/web/frontend/src/generic/plots/MetricPlot.svelte +++ b/web/frontend/src/generic/plots/MetricPlot.svelte @@ -9,7 +9,6 @@ - `height Number?`: The plot height [Default: 300] - `timestep Number`: The timestep used for X-axis rendering - `series [GraphQL.Series]`: The metric data object - - `useStatsSeries Bool?`: If this plot uses the statistics Min/Max/Median representation; automatically set to according bool [Default: false] - `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 @@ -37,7 +36,6 @@ height = 300, timestep, series, - useStatsSeries = false, statisticsSeries = null, cluster = "", subCluster, @@ -78,6 +76,7 @@ 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, @@ -243,11 +242,6 @@ return pendingSeries; }) - /* Effects */ - $effect(() => { - if (!useStatsSeries && statisticsSeries != null) useStatsSeries = true; - }) - // This updates plot on all size changes if wrapper (== data) exists $effect(() => { if (plotWrapper) { diff --git a/web/frontend/src/job/Metric.svelte b/web/frontend/src/job/Metric.svelte index 1beb88fb..a0ab18e5 100644 --- a/web/frontend/src/job/Metric.svelte +++ b/web/frontend/src/job/Metric.svelte @@ -197,7 +197,6 @@ {zoomState} {thresholdState} statisticsSeries={statsSeries[selectedScopeIndex]} - useStatsSeries={!!statsSeries[selectedScopeIndex]} enableFlip /> {/if} diff --git a/web/frontend/src/systems/nodelist/NodeListRow.svelte b/web/frontend/src/systems/nodelist/NodeListRow.svelte index 62b6517b..dc8ea09e 100644 --- a/web/frontend/src/systems/nodelist/NodeListRow.svelte +++ b/web/frontend/src/systems/nodelist/NodeListRow.svelte @@ -174,7 +174,7 @@

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

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

- {:else if !!metricData.data?.metric.statisticsSeries} + {:else if !!metricData?.data?.metric?.statisticsSeries} Date: Thu, 19 Mar 2026 13:56:46 +0100 Subject: [PATCH 2/5] bump frontend dependencies, increase version to match release --- web/frontend/package-lock.json | 56 +++++++++++++++++++++------------- web/frontend/package.json | 4 +-- 2 files changed, 37 insertions(+), 23 deletions(-) diff --git a/web/frontend/package-lock.json b/web/frontend/package-lock.json index 8656abc4..339f5eea 100644 --- a/web/frontend/package-lock.json +++ b/web/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "cc-frontend", - "version": "1.5.0", - "lockfileVersion": 3, + "version": "1.5.2", + "lockfileVersion": 4, "requires": true, "packages": { "": { "name": "cc-frontend", - "version": "1.5.0", + "version": "1.5.2", "license": "MIT", "dependencies": { "@rollup/plugin-replace": "^6.0.3", @@ -27,7 +27,7 @@ "rollup": "^4.59.0", "rollup-plugin-css-only": "^4.5.5", "rollup-plugin-svelte": "^7.2.3", - "svelte": "^5.53.9" + "svelte": "^5.54.0" } }, "node_modules/@0no-co/graphql.web": { @@ -45,9 +45,9 @@ } }, "node_modules/@babel/runtime": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.6.tgz", - "integrity": "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==", + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz", + "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==", "license": "MIT", "engines": { "node": ">=6.9.0" @@ -654,6 +654,19 @@ "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", "license": "MIT" }, + "node_modules/@typescript-eslint/types": { + "version": "8.57.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.57.1.tgz", + "integrity": "sha512-S29BOBPJSFUiblEl6RzPPjJt6w25A6XsBqRVDt53tA/tlL8q7ceQNZHTjPeONt/3S7KRI4quk+yP9jK2WjBiPQ==", + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, "node_modules/@urql/core": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/@urql/core/-/core-5.2.0.tgz", @@ -790,9 +803,9 @@ } }, "node_modules/devalue": { - "version": "5.6.3", - "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.6.3.tgz", - "integrity": "sha512-nc7XjUU/2Lb+SvEFVGcWLiKkzfw8+qHI7zn8WYXKkLMgfGSHbgCEaR6bJpev8Cm6Rmrb19Gfd/tZvGqx9is3wg==", + "version": "5.6.4", + "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.6.4.tgz", + "integrity": "sha512-Gp6rDldRsFh/7XuouDbxMH3Mx8GMCcgzIb1pDTvNyn8pZGQ22u+Wa+lGV9dQCltFQ7uVw0MhRyb8XDskNFOReA==", "license": "MIT" }, "node_modules/escape-latex": { @@ -808,12 +821,13 @@ "license": "MIT" }, "node_modules/esrap": { - "version": "2.2.3", - "resolved": "https://registry.npmjs.org/esrap/-/esrap-2.2.3.tgz", - "integrity": "sha512-8fOS+GIGCQZl/ZIlhl59htOlms6U8NvX6ZYgYHpRU/b6tVSh3uHkOHZikl3D4cMbYM0JlpBe+p/BkZEi8J9XIQ==", + "version": "2.2.4", + "resolved": "https://registry.npmjs.org/esrap/-/esrap-2.2.4.tgz", + "integrity": "sha512-suICpxAmZ9A8bzJjEl/+rLJiDKC0X4gYWUxT6URAWBLvlXmtbZd5ySMu/N2ZGEtMCAmflUDPSehrP9BQcsGcSg==", "license": "MIT", "dependencies": { - "@jridgewell/sourcemap-codec": "^1.4.15" + "@jridgewell/sourcemap-codec": "^1.4.15", + "@typescript-eslint/types": "^8.2.0" } }, "node_modules/estree-walker": { @@ -1193,9 +1207,9 @@ } }, "node_modules/svelte": { - "version": "5.53.9", - "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.53.9.tgz", - "integrity": "sha512-MwDfWsN8qZzeP0jlQsWF4k/4B3csb3IbzCRggF+L/QqY7T8bbKvnChEo1cPZztF51HJQhilDbevWYl2LvXbquA==", + "version": "5.54.0", + "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.54.0.tgz", + "integrity": "sha512-TTDxwYnHkova6Wsyj1PGt9TByuWqvMoeY1bQiuAf2DM/JeDSMw7FjRKzk8K/5mJ99vGOKhbCqTDpyAKwjp4igg==", "license": "MIT", "dependencies": { "@jridgewell/remapping": "^2.3.4", @@ -1207,7 +1221,7 @@ "aria-query": "5.3.1", "axobject-query": "^4.1.0", "clsx": "^2.1.1", - "devalue": "^5.6.3", + "devalue": "^5.6.4", "esm-env": "^1.2.1", "esrap": "^2.2.2", "is-reference": "^3.0.3", @@ -1229,9 +1243,9 @@ } }, "node_modules/terser": { - "version": "5.46.0", - "resolved": "https://registry.npmjs.org/terser/-/terser-5.46.0.tgz", - "integrity": "sha512-jTwoImyr/QbOWFFso3YoU3ik0jBBDJ6JTOQiy/J2YxVJdZCc+5u7skhNwiOR3FQIygFqVUPHl7qbbxtjW2K3Qg==", + "version": "5.46.1", + "resolved": "https://registry.npmjs.org/terser/-/terser-5.46.1.tgz", + "integrity": "sha512-vzCjQO/rgUuK9sf8VJZvjqiqiHFaZLnOiimmUuOKODxWL8mm/xua7viT7aqX7dgPY60otQjUotzFMmCB4VdmqQ==", "dev": true, "license": "BSD-2-Clause", "dependencies": { diff --git a/web/frontend/package.json b/web/frontend/package.json index 0c206c66..fee61f5a 100644 --- a/web/frontend/package.json +++ b/web/frontend/package.json @@ -1,6 +1,6 @@ { "name": "cc-frontend", - "version": "1.5.0", + "version": "1.5.2", "license": "MIT", "scripts": { "build": "rollup -c", @@ -14,7 +14,7 @@ "rollup": "^4.59.0", "rollup-plugin-css-only": "^4.5.5", "rollup-plugin-svelte": "^7.2.3", - "svelte": "^5.53.9" + "svelte": "^5.54.0" }, "dependencies": { "@rollup/plugin-replace": "^6.0.3", From 886791cf8a37adce28aaa66322d685a0e81eef5b Mon Sep 17 00:00:00 2001 From: Christoph Kluge Date: Thu, 19 Mar 2026 14:09:10 +0100 Subject: [PATCH 3/5] remove deprecated minRunningFor filter remnants --- api/schema.graphqls | 2 -- internal/graph/generated/generated.go | 11 +---------- internal/graph/model/models_gen.go | 1 - internal/repository/jobQuery.go | 7 ------- 4 files changed, 1 insertion(+), 20 deletions(-) diff --git a/api/schema.graphqls b/api/schema.graphqls index e4e2b8ed..e6830956 100644 --- a/api/schema.graphqls +++ b/api/schema.graphqls @@ -451,8 +451,6 @@ input JobFilter { duration: IntRange energy: FloatRange - minRunningFor: Int - numNodes: IntRange numAccelerators: IntRange numHWThreads: IntRange diff --git a/internal/graph/generated/generated.go b/internal/graph/generated/generated.go index 9af36ffd..f003c04a 100644 --- a/internal/graph/generated/generated.go +++ b/internal/graph/generated/generated.go @@ -2718,8 +2718,6 @@ input JobFilter { duration: IntRange energy: FloatRange - minRunningFor: Int - numNodes: IntRange numAccelerators: IntRange numHWThreads: IntRange @@ -13292,7 +13290,7 @@ func (ec *executionContext) unmarshalInputJobFilter(ctx context.Context, obj any asMap[k] = v } - fieldsInOrder := [...]string{"tags", "dbId", "jobId", "arrayJobId", "user", "project", "jobName", "cluster", "subCluster", "partition", "duration", "energy", "minRunningFor", "numNodes", "numAccelerators", "numHWThreads", "startTime", "state", "metricStats", "shared", "schedule", "node"} + fieldsInOrder := [...]string{"tags", "dbId", "jobId", "arrayJobId", "user", "project", "jobName", "cluster", "subCluster", "partition", "duration", "energy", "numNodes", "numAccelerators", "numHWThreads", "startTime", "state", "metricStats", "shared", "schedule", "node"} for _, k := range fieldsInOrder { v, ok := asMap[k] if !ok { @@ -13383,13 +13381,6 @@ func (ec *executionContext) unmarshalInputJobFilter(ctx context.Context, obj any return it, err } it.Energy = data - case "minRunningFor": - ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("minRunningFor")) - data, err := ec.unmarshalOInt2ᚖint(ctx, v) - if err != nil { - return it, err - } - it.MinRunningFor = data case "numNodes": ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("numNodes")) data, err := ec.unmarshalOIntRange2ᚖgithubᚗcomᚋClusterCockpitᚋccᚑbackendᚋinternalᚋconfigᚐIntRange(ctx, v) diff --git a/internal/graph/model/models_gen.go b/internal/graph/model/models_gen.go index 24b33847..bdf63560 100644 --- a/internal/graph/model/models_gen.go +++ b/internal/graph/model/models_gen.go @@ -75,7 +75,6 @@ type JobFilter struct { Partition *StringInput `json:"partition,omitempty"` Duration *config.IntRange `json:"duration,omitempty"` Energy *FloatRange `json:"energy,omitempty"` - MinRunningFor *int `json:"minRunningFor,omitempty"` NumNodes *config.IntRange `json:"numNodes,omitempty"` NumAccelerators *config.IntRange `json:"numAccelerators,omitempty"` NumHWThreads *config.IntRange `json:"numHWThreads,omitempty"` diff --git a/internal/repository/jobQuery.go b/internal/repository/jobQuery.go index efa1c155..36c5892e 100644 --- a/internal/repository/jobQuery.go +++ b/internal/repository/jobQuery.go @@ -275,13 +275,6 @@ func BuildWhereClause(filter *model.JobFilter, query sq.SelectBuilder) sq.Select } } - // Configurable Filter to exclude recently started jobs, see config.go: ShortRunningJobsDuration - if filter.MinRunningFor != nil { - now := time.Now().Unix() - // Only jobs whose start timestamp is more than MinRunningFor seconds in the past - // If a job completed within the configured timeframe, it will still show up after the start_time matches the condition! - query = query.Where(sq.Lt{"job.start_time": (now - int64(*filter.MinRunningFor))}) - } return query } From 10b4fa5a0673f2cf3f30ad9b660062527c322a54 Mon Sep 17 00:00:00 2001 From: Christoph Kluge Date: Thu, 19 Mar 2026 15:55:58 +0100 Subject: [PATCH 4/5] change: remove heuristic metricHealth, replace with DB metricHealth - add metricHealth to single Node view --- api/schema.graphqls | 3 +- internal/graph/generated/generated.go | 82 +++++++++++++++---- internal/graph/model/models_gen.go | 9 +- internal/graph/schema.resolvers.go | 18 ++-- internal/repository/node.go | 35 ++++---- web/frontend/src/DashPublic.root.svelte | 2 +- web/frontend/src/Node.root.svelte | 41 +++++++--- web/frontend/src/Systems.root.svelte | 8 +- web/frontend/src/systems/NodeList.svelte | 13 +-- web/frontend/src/systems/NodeOverview.svelte | 24 +++--- .../src/systems/nodelist/NodeInfo.svelte | 34 +++----- .../src/systems/nodelist/NodeListRow.svelte | 6 +- 12 files changed, 171 insertions(+), 104 deletions(-) diff --git a/api/schema.graphqls b/api/schema.graphqls index e6830956..cf8f5273 100644 --- a/api/schema.graphqls +++ b/api/schema.graphqls @@ -270,7 +270,8 @@ enum SortByAggregate { type NodeMetrics { host: String! - state: String! + nodeState: String! + metricHealth: String! subCluster: String! metrics: [JobMetricWithName!]! } diff --git a/internal/graph/generated/generated.go b/internal/graph/generated/generated.go index f003c04a..a5319fc7 100644 --- a/internal/graph/generated/generated.go +++ b/internal/graph/generated/generated.go @@ -288,10 +288,11 @@ type ComplexityRoot struct { } NodeMetrics struct { - Host func(childComplexity int) int - Metrics func(childComplexity int) int - State func(childComplexity int) int - SubCluster func(childComplexity int) int + Host func(childComplexity int) int + MetricHealth func(childComplexity int) int + Metrics func(childComplexity int) int + NodeState func(childComplexity int) int + SubCluster func(childComplexity int) int } NodeStateResultList struct { @@ -1501,18 +1502,24 @@ func (e *executableSchema) Complexity(ctx context.Context, typeName, field strin } return e.ComplexityRoot.NodeMetrics.Host(childComplexity), true + case "NodeMetrics.metricHealth": + if e.ComplexityRoot.NodeMetrics.MetricHealth == nil { + break + } + + return e.ComplexityRoot.NodeMetrics.MetricHealth(childComplexity), true case "NodeMetrics.metrics": if e.ComplexityRoot.NodeMetrics.Metrics == nil { break } return e.ComplexityRoot.NodeMetrics.Metrics(childComplexity), true - case "NodeMetrics.state": - if e.ComplexityRoot.NodeMetrics.State == nil { + case "NodeMetrics.nodeState": + if e.ComplexityRoot.NodeMetrics.NodeState == nil { break } - return e.ComplexityRoot.NodeMetrics.State(childComplexity), true + return e.ComplexityRoot.NodeMetrics.NodeState(childComplexity), true case "NodeMetrics.subCluster": if e.ComplexityRoot.NodeMetrics.SubCluster == nil { break @@ -2537,7 +2544,8 @@ enum SortByAggregate { type NodeMetrics { host: String! - state: String! + nodeState: String! + metricHealth: String! subCluster: String! metrics: [JobMetricWithName!]! } @@ -8316,14 +8324,14 @@ func (ec *executionContext) fieldContext_NodeMetrics_host(_ context.Context, fie return fc, nil } -func (ec *executionContext) _NodeMetrics_state(ctx context.Context, field graphql.CollectedField, obj *model.NodeMetrics) (ret graphql.Marshaler) { +func (ec *executionContext) _NodeMetrics_nodeState(ctx context.Context, field graphql.CollectedField, obj *model.NodeMetrics) (ret graphql.Marshaler) { return graphql.ResolveField( ctx, ec.OperationContext, field, - ec.fieldContext_NodeMetrics_state, + ec.fieldContext_NodeMetrics_nodeState, func(ctx context.Context) (any, error) { - return obj.State, nil + return obj.NodeState, nil }, nil, ec.marshalNString2string, @@ -8332,7 +8340,36 @@ func (ec *executionContext) _NodeMetrics_state(ctx context.Context, field graphq ) } -func (ec *executionContext) fieldContext_NodeMetrics_state(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { +func (ec *executionContext) fieldContext_NodeMetrics_nodeState(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "NodeMetrics", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type String does not have child fields") + }, + } + return fc, nil +} + +func (ec *executionContext) _NodeMetrics_metricHealth(ctx context.Context, field graphql.CollectedField, obj *model.NodeMetrics) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext_NodeMetrics_metricHealth, + func(ctx context.Context) (any, error) { + return obj.MetricHealth, nil + }, + nil, + ec.marshalNString2string, + true, + true, + ) +} + +func (ec *executionContext) fieldContext_NodeMetrics_metricHealth(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { fc = &graphql.FieldContext{ Object: "NodeMetrics", Field: field, @@ -8666,8 +8703,10 @@ func (ec *executionContext) fieldContext_NodesResultList_items(_ context.Context switch field.Name { case "host": return ec.fieldContext_NodeMetrics_host(ctx, field) - case "state": - return ec.fieldContext_NodeMetrics_state(ctx, field) + case "nodeState": + return ec.fieldContext_NodeMetrics_nodeState(ctx, field) + case "metricHealth": + return ec.fieldContext_NodeMetrics_metricHealth(ctx, field) case "subCluster": return ec.fieldContext_NodeMetrics_subCluster(ctx, field) case "metrics": @@ -9844,8 +9883,10 @@ func (ec *executionContext) fieldContext_Query_nodeMetrics(ctx context.Context, switch field.Name { case "host": return ec.fieldContext_NodeMetrics_host(ctx, field) - case "state": - return ec.fieldContext_NodeMetrics_state(ctx, field) + case "nodeState": + return ec.fieldContext_NodeMetrics_nodeState(ctx, field) + case "metricHealth": + return ec.fieldContext_NodeMetrics_metricHealth(ctx, field) case "subCluster": return ec.fieldContext_NodeMetrics_subCluster(ctx, field) case "metrics": @@ -15917,8 +15958,13 @@ func (ec *executionContext) _NodeMetrics(ctx context.Context, sel ast.SelectionS if out.Values[i] == graphql.Null { out.Invalids++ } - case "state": - out.Values[i] = ec._NodeMetrics_state(ctx, field, obj) + case "nodeState": + out.Values[i] = ec._NodeMetrics_nodeState(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + case "metricHealth": + out.Values[i] = ec._NodeMetrics_metricHealth(ctx, field, obj) if out.Values[i] == graphql.Null { out.Invalids++ } diff --git a/internal/graph/model/models_gen.go b/internal/graph/model/models_gen.go index bdf63560..7611bf22 100644 --- a/internal/graph/model/models_gen.go +++ b/internal/graph/model/models_gen.go @@ -193,10 +193,11 @@ type NodeFilter struct { } type NodeMetrics struct { - Host string `json:"host"` - State string `json:"state"` - SubCluster string `json:"subCluster"` - Metrics []*JobMetricWithName `json:"metrics"` + Host string `json:"host"` + NodeState string `json:"nodeState"` + MetricHealth string `json:"metricHealth"` + SubCluster string `json:"subCluster"` + Metrics []*JobMetricWithName `json:"metrics"` } type NodeStateResultList struct { diff --git a/internal/graph/schema.resolvers.go b/internal/graph/schema.resolvers.go index adbc5f80..c84cb713 100644 --- a/internal/graph/schema.resolvers.go +++ b/internal/graph/schema.resolvers.go @@ -840,14 +840,15 @@ func (r *queryResolver) NodeMetrics(ctx context.Context, cluster string, nodes [ } nodeRepo := repository.GetNodeRepository() - stateMap, _ := nodeRepo.MapNodes(cluster) + nodeStateMap, metricHealthMap, _ := nodeRepo.MapNodes(cluster) nodeMetrics := make([]*model.NodeMetrics, 0, len(data)) for hostname, metrics := range data { host := &model.NodeMetrics{ - Host: hostname, - State: stateMap[hostname], - Metrics: make([]*model.JobMetricWithName, 0, len(metrics)*len(scopes)), + Host: hostname, + NodeState: nodeStateMap[hostname], + MetricHealth: metricHealthMap[hostname], + Metrics: make([]*model.JobMetricWithName, 0, len(metrics)*len(scopes)), } host.SubCluster, err = archive.GetSubClusterByNode(cluster, hostname) if err != nil { @@ -889,7 +890,7 @@ func (r *queryResolver) NodeMetricsList(ctx context.Context, cluster string, sub nodeRepo := repository.GetNodeRepository() // nodes -> array hostname - nodes, stateMap, countNodes, hasNextPage, nerr := nodeRepo.GetNodesForList(ctx, cluster, subCluster, stateFilter, nodeFilter, page) + 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") } @@ -910,9 +911,10 @@ func (r *queryResolver) NodeMetricsList(ctx context.Context, cluster string, sub nodeMetricsList := make([]*model.NodeMetrics, 0, len(data)) for _, hostname := range nodes { host := &model.NodeMetrics{ - Host: hostname, - State: stateMap[hostname], - Metrics: make([]*model.JobMetricWithName, 0), + Host: hostname, + NodeState: nodeStateMap[hostname], + MetricHealth: metricHealthMap[hostname], + Metrics: make([]*model.JobMetricWithName, 0), } host.SubCluster, err = archive.GetSubClusterByNode(cluster, hostname) if err != nil { diff --git a/internal/repository/node.go b/internal/repository/node.go index 2e174e95..eaa47079 100644 --- a/internal/repository/node.go +++ b/internal/repository/node.go @@ -593,8 +593,8 @@ func (r *NodeRepository) ListNodes(cluster string) ([]*schema.Node, error) { return nodeList, nil } -func (r *NodeRepository) MapNodes(cluster string) (map[string]string, error) { - q := sq.Select("node.hostname", "node_state.node_state"). +func (r *NodeRepository) MapNodes(cluster string) (map[string]string, map[string]string, error) { + q := sq.Select("node.hostname", "node_state.node_state", "node_state.health_state"). From("node"). Join("node_state ON node_state.node_id = node.id"). Where(latestStateCondition()). @@ -604,22 +604,25 @@ func (r *NodeRepository) MapNodes(cluster string) (map[string]string, error) { rows, err := q.RunWith(r.DB).Query() if err != nil { cclog.Warn("Error while querying node list") - return nil, err + return nil, nil, err } - stateMap := make(map[string]string) + nodeStateMap := make(map[string]string) + metricHealthMap := make(map[string]string) + defer rows.Close() for rows.Next() { - var hostname, nodestate string - if err := rows.Scan(&hostname, &nodestate); err != nil { + var hostname, nodeState, metricHealth string + if err := rows.Scan(&hostname, &nodeState, &metricHealth); err != nil { cclog.Warn("Error while scanning node list (MapNodes)") - return nil, err + return nil, nil, err } - stateMap[hostname] = nodestate + nodeStateMap[hostname] = nodeState + metricHealthMap[hostname] = metricHealth } - return stateMap, nil + return nodeStateMap, metricHealthMap, nil } func (r *NodeRepository) CountStates(ctx context.Context, filters []*model.NodeFilter, column string) ([]*model.NodeStates, error) { @@ -741,10 +744,11 @@ func (r *NodeRepository) GetNodesForList( stateFilter string, nodeFilter string, page *model.PageRequest, -) ([]string, map[string]string, int, bool, error) { +) ([]string, map[string]string, map[string]string, int, bool, error) { // Init Return Vars nodes := make([]string, 0) - stateMap := make(map[string]string) + nodeStateMap := make(map[string]string) + metricHealthMap := make(map[string]string) countNodes := 0 hasNextPage := false @@ -778,7 +782,7 @@ func (r *NodeRepository) GetNodesForList( rawNodes, serr := r.QueryNodes(ctx, queryFilters, page, nil) // Order not Used if serr != nil { cclog.Warn("error while loading node database data (Resolver.NodeMetricsList)") - return nil, nil, 0, false, serr + return nil, nil, nil, 0, false, serr } // Intermediate Node Result Info @@ -787,7 +791,8 @@ func (r *NodeRepository) GetNodesForList( continue } nodes = append(nodes, node.Hostname) - stateMap[node.Hostname] = string(node.NodeState) + nodeStateMap[node.Hostname] = string(node.NodeState) + metricHealthMap[node.Hostname] = string(node.HealthState) } // Special Case: Find Nodes not in DB node table but in metricStore only @@ -847,7 +852,7 @@ func (r *NodeRepository) GetNodesForList( countNodes, cerr = r.CountNodes(ctx, queryFilters) if cerr != nil { cclog.Warn("error while counting node database data (Resolver.NodeMetricsList)") - return nil, nil, 0, false, cerr + return nil, nil, nil, 0, false, cerr } hasNextPage = page.Page*page.ItemsPerPage < countNodes } @@ -857,7 +862,7 @@ func (r *NodeRepository) GetNodesForList( nodes, countNodes, hasNextPage = getNodesFromTopol(cluster, subCluster, nodeFilter, page) } - return nodes, stateMap, countNodes, hasNextPage, nil + return nodes, nodeStateMap, metricHealthMap, countNodes, hasNextPage, nil } func AccessCheck(ctx context.Context, query sq.SelectBuilder) (sq.SelectBuilder, error) { diff --git a/web/frontend/src/DashPublic.root.svelte b/web/frontend/src/DashPublic.root.svelte index e824f881..6301fba4 100644 --- a/web/frontend/src/DashPublic.root.svelte +++ b/web/frontend/src/DashPublic.root.svelte @@ -130,7 +130,7 @@ name count } - # Get Current States fir Pie Charts + # Get Current States for Pie Charts nodeStates(filter: $nodeFilter) { state count diff --git a/web/frontend/src/Node.root.svelte b/web/frontend/src/Node.root.svelte index a9ce8a74..35cfcca9 100644 --- a/web/frontend/src/Node.root.svelte +++ b/web/frontend/src/Node.root.svelte @@ -57,7 +57,8 @@ query ($cluster: String!, $nodes: [String!], $from: Time!, $to: Time!) { nodeMetrics(cluster: $cluster, nodes: $nodes, from: $from, to: $to) { host - state + nodeState + metricHealth subCluster metrics { name @@ -92,7 +93,7 @@ } } `; - // Node State Colors + // Node/Metric State Colors const stateColors = { allocated: 'success', reserved: 'info', @@ -100,7 +101,10 @@ mixed: 'warning', down: 'danger', unknown: 'dark', - notindb: 'secondary' + notindb: 'secondary', + full: 'success', + partial: 'warning', + failed: 'danger' } /* State Init */ @@ -153,31 +157,46 @@ }) ); - const thisNodeState = $derived($nodeMetricsData?.data?.nodeMetrics[0]?.state ? $nodeMetricsData.data.nodeMetrics[0].state : 'notindb'); + const thisNodeState = $derived($nodeMetricsData?.data?.nodeMetrics[0]?.nodeState || 'notindb'); + const thisMetricHealth = $derived($nodeMetricsData?.data?.nodeMetrics[0]?.metricHealth || 'unknown'); - + {#if $initq.error} {$initq.error.message} {:else if $initq.fetching} {:else} - + Selected Node - - + + Node State + + + + + + + Metric Health + - {:else if healthWarn} + {#if metricHealth == "failed"} Info @@ -101,13 +89,17 @@ - {:else if metricWarn} + {:else if metricHealth == "partial" || metricHealth == "unknown"} Info {:else if nodeJobsData.jobs.count == 1 && nodeJobsData?.jobs?.items[0]?.shared == "none"} @@ -150,8 +142,8 @@ State - diff --git a/web/frontend/src/systems/nodelist/NodeListRow.svelte b/web/frontend/src/systems/nodelist/NodeListRow.svelte index dc8ea09e..2c99f604 100644 --- a/web/frontend/src/systems/nodelist/NodeListRow.svelte +++ b/web/frontend/src/systems/nodelist/NodeListRow.svelte @@ -75,7 +75,6 @@ const extendedLegendData = $derived($nodeJobsData?.data ? buildExtendedLegend() : null); const refinedData = $derived(nodeData?.metrics ? sortAndSelectScope(selectedMetrics, nodeData.metrics) : []); - const dataHealth = $derived(refinedData.filter((rd) => rd.availability == "configured").map((enabled) => (nodeDataFetching ? 'fetching' : enabled?.data?.metric?.series?.length > 0))); /* Functions */ function sortAndSelectScope(metricList = [], nodeMetrics = []) { @@ -145,11 +144,12 @@ {:else} + nodeState={nodeData?.nodeState || 'notindb'} + metricHealth={nodeData?.metricHealth || 'unknown'} + /> {/if} {#each refinedData as metricData, i (metricData?.data?.name || i)} From bf48389aeb5b038b3446fd08fc271777c7c60239 Mon Sep 17 00:00:00 2001 From: Jan Eitzinger Date: Fri, 20 Mar 2026 05:39:19 +0100 Subject: [PATCH 5/5] Optimize sortby in stats queries Entire-Checkpoint: 9b5b833472e1 --- internal/graph/schema.resolvers.go | 5 +++++ internal/graph/stats_cache.go | 27 +++++++++++++++++++++++++++ 2 files changed, 32 insertions(+) diff --git a/internal/graph/schema.resolvers.go b/internal/graph/schema.resolvers.go index c84cb713..9cfb808e 100644 --- a/internal/graph/schema.resolvers.go +++ b/internal/graph/schema.resolvers.go @@ -676,6 +676,11 @@ func (r *queryResolver) JobsStatistics(ctx context.Context, filter []*model.JobF // Use request-scoped cache: multiple aliases with same (filter, groupBy) // but different sortBy/page hit the DB only once. if cache := getStatsGroupCache(ctx); cache != nil { + // Ensure the sort field is computed even if not in the GraphQL selection, + // because sortAndPageStats will sort by it in memory. + if sortBy != nil { + reqFields[sortByFieldName(*sortBy)] = true + } key := statsCacheKey(filter, groupBy, reqFields) var allStats []*model.JobsStatistics allStats, err = cache.getOrCompute(key, func() ([]*model.JobsStatistics, error) { diff --git a/internal/graph/stats_cache.go b/internal/graph/stats_cache.go index 92e8e85b..c4f0d7f8 100644 --- a/internal/graph/stats_cache.go +++ b/internal/graph/stats_cache.go @@ -107,6 +107,33 @@ func sortAndPageStats(allStats []*model.JobsStatistics, sortBy *model.SortByAggr return sorted } +// sortByFieldName maps a SortByAggregate enum to the corresponding reqFields key. +// This ensures the DB computes the column that sortAndPageStats will sort by. +func sortByFieldName(sortBy model.SortByAggregate) string { + switch sortBy { + case model.SortByAggregateTotaljobs: + return "totalJobs" + case model.SortByAggregateTotalusers: + return "totalUsers" + case model.SortByAggregateTotalwalltime: + return "totalWalltime" + case model.SortByAggregateTotalnodes: + return "totalNodes" + case model.SortByAggregateTotalnodehours: + return "totalNodeHours" + case model.SortByAggregateTotalcores: + return "totalCores" + case model.SortByAggregateTotalcorehours: + return "totalCoreHours" + case model.SortByAggregateTotalaccs: + return "totalAccs" + case model.SortByAggregateTotalacchours: + return "totalAccHours" + default: + return "totalJobs" + } +} + // statsFieldGetter returns a function that extracts the sortable int field // from a JobsStatistics struct for the given sort key. func statsFieldGetter(sortBy model.SortByAggregate) func(*model.JobsStatistics) int {