Merge pull request #529 from ClusterCockpit/hotfix

Hotfix
This commit is contained in:
Jan Eitzinger
2026-03-20 05:50:18 +01:00
committed by GitHub
21 changed files with 249 additions and 163 deletions

View File

@@ -270,7 +270,8 @@ enum SortByAggregate {
type NodeMetrics {
host: String!
state: String!
nodeState: String!
metricHealth: String!
subCluster: String!
metrics: [JobMetricWithName!]!
}
@@ -451,8 +452,6 @@ input JobFilter {
duration: IntRange
energy: FloatRange
minRunningFor: Int
numNodes: IntRange
numAccelerators: IntRange
numHWThreads: IntRange

View File

@@ -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!]!
}
@@ -2718,8 +2726,6 @@ input JobFilter {
duration: IntRange
energy: FloatRange
minRunningFor: Int
numNodes: IntRange
numAccelerators: IntRange
numHWThreads: IntRange
@@ -8318,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,
@@ -8334,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,
@@ -8668,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":
@@ -9846,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":
@@ -13292,7 +13331,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 +13422,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)
@@ -15926,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++
}

View File

@@ -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"`
@@ -194,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 {

View File

@@ -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) {
@@ -840,14 +845,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 +895,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 +916,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 {

View File

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

View File

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

View File

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

View File

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

View File

@@ -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": {

View File

@@ -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",

View File

@@ -130,7 +130,7 @@
name
count
}
# Get Current States fir Pie Charts
# Get Current States for Pie Charts
nodeStates(filter: $nodeFilter) {
state
count

View File

@@ -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');
</script>
<Row cols={{ xs: 2, lg: 5 }}>
<Row cols={{ xs: 2, lg: 3}}>
{#if $initq.error}
<Card body color="danger">{$initq.error.message}</Card>
{:else if $initq.fetching}
<Spinner />
{:else}
<!-- Node Col -->
<Col>
<Col class="mb-2">
<InputGroup>
<InputGroupText><Icon name="hdd" /></InputGroupText>
<InputGroupText>Selected Node</InputGroupText>
<Input style="background-color: white;" type="text" value="{hostname} [{cluster} {$nodeMetricsData?.data?.nodeMetrics[0] ? `(${$nodeMetricsData.data.nodeMetrics[0].subCluster})` : ''}]" disabled/>
</InputGroup>
</Col>
<!-- State Col -->
<Col>
<!-- Node State Col -->
<Col class="mb-2">
<InputGroup>
<InputGroupText><Icon name="clipboard2-pulse" /></InputGroupText>
<InputGroupText>Node State</InputGroupText>
<Button class="flex-grow-1 text-center" color={stateColors[thisNodeState]} disabled>
{#if $nodeMetricsData?.data}
{thisNodeState}
{thisNodeState.charAt(0).toUpperCase() + thisNodeState.slice(1)}
{:else}
<span><Spinner size="sm" secondary/></span>
{/if}
</Button>
</InputGroup>
</Col>
<!-- Metric Health Col -->
<Col class="mb-2">
<InputGroup>
<InputGroupText><Icon name="clipboard2-pulse" /></InputGroupText>
<InputGroupText>Metric Health</InputGroupText>
<Button class="flex-grow-1 text-center" color={stateColors[thisMetricHealth]} disabled>
{#if $nodeMetricsData?.data}
{thisMetricHealth.charAt(0).toUpperCase() + thisMetricHealth.slice(1)}
{:else}
<span><Spinner size="sm" secondary/></span>
{/if}
@@ -185,7 +204,7 @@
</InputGroup>
</Col>
<!-- Concurrent Col -->
<Col class="mt-2 mt-lg-0">
<Col>
{#if $nodeJobsData.fetching}
<Spinner />
{:else if $nodeJobsData.data}
@@ -217,7 +236,7 @@
/>
</Col>
<!-- Refresh Col-->
<Col class="mt-2 mt-lg-0">
<Col>
<Refresher
onRefresh={() => {
const diff = Date.now() - to;

View File

@@ -59,7 +59,7 @@
/* State Init */
let hostnameFilter = $state("");
let hoststateFilter = $state("all");
let nodeStateFilter = $state("all");
let pendingHostnameFilter = $state("");
let isMetricsSelectionOpen = $state(false);
@@ -210,7 +210,7 @@
<InputGroup>
<InputGroupText><Icon name="clipboard2-pulse" /></InputGroupText>
<InputGroupText>State</InputGroupText>
<Input type="select" bind:value={hoststateFilter}>
<Input type="select" bind:value={nodeStateFilter}>
{#each stateOptions as so}
<option value={so}>{so.charAt(0).toUpperCase() + so.slice(1)}</option>
{/each}
@@ -269,11 +269,11 @@
{:else}
{#if displayNodeOverview}
<!-- ROW2-1: Node Overview (Grid Included)-->
<NodeOverview {cluster} {ccconfig} {selectedMetric} {globalMetrics} {from} {to} {hostnameFilter} {hoststateFilter}/>
<NodeOverview {cluster} {ccconfig} {selectedMetric} {globalMetrics} {from} {to} {hostnameFilter} {nodeStateFilter}/>
{:else}
<!-- ROW2-2: Node List (Grid Included)-->
<NodeList pendingSelectedMetrics={selectedMetrics} {cluster} {subCluster}
{selectedResolution} {hostnameFilter} {hoststateFilter} {from} {to} {systemUnits}/>
{selectedResolution} {hostnameFilter} {nodeStateFilter} {from} {to} {systemUnits}/>
{/if}
{/if}

View File

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

View File

@@ -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,

View File

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

View File

@@ -197,7 +197,6 @@
{zoomState}
{thresholdState}
statisticsSeries={statsSeries[selectedScopeIndex]}
useStatsSeries={!!statsSeries[selectedScopeIndex]}
enableFlip
/>
{/if}

View File

@@ -6,8 +6,8 @@
- `subCluster String`: The nodes' subCluster [Default: ""]
- `pendingSelectedMetrics [String]`: The array of selected metrics [Default []]
- `selectedResolution Number?`: The selected data resolution [Default: 0]
- `hostnameFilter String?`: The active hostnamefilter [Default: ""]
- `hoststateFilter String?`: The active hoststatefilter [Default: ""]
- `hostnameFilter String?`: The active hostname filter [Default: ""]
- `nodeStateFilter String?`: The active nodeState filter [Default: ""]
- `systemUnits Object`: The object of metric units [Default: null]
- `from Date?`: The selected "from" date [Default: null]
- `to Date?`: The selected "to" date [Default: null]
@@ -28,7 +28,7 @@
pendingSelectedMetrics = [],
selectedResolution = 0,
hostnameFilter = "",
hoststateFilter = "",
nodeStateFilter = "",
systemUnits = null,
from = null,
to = null
@@ -54,7 +54,8 @@
) {
items {
host
state
nodeState
metricHealth
subCluster
metrics {
name
@@ -110,7 +111,7 @@
variables: {
cluster: cluster,
subCluster: subCluster,
stateFilter: hoststateFilter,
stateFilter: nodeStateFilter,
nodeFilter: hostnameFilter,
scopes: ["core", "socket", "accelerator"],
metrics: pendingSelectedMetrics,
@@ -164,7 +165,7 @@
$effect(() => {
// Update NodeListRows metrics only: Keep ordered nodes on page 1
hostnameFilter, hoststateFilter
hostnameFilter, nodeStateFilter
// Continous Scroll: Paging if parameters change: Existing entries will not match new selections
nodes = [];
if (!usePaging) {

View File

@@ -5,8 +5,8 @@
- `ccconfig Object?`: The ClusterCockpit Config Context [Default: null]
- `cluster String`: The cluster to show status information for
- `selectedMetric String?`: The selectedMetric input [Default: ""]
- `hostnameFilter String?`: The active hostnamefilter [Default: ""]
- `hostnameFilter String?`: The active hoststatefilter [Default: ""]
- `hostnameFilter String?`: The active hostname filter [Default: ""]
- `nodeStateFilter String?`: The active nodeState filter [Default: ""]
- `from Date?`: The selected "from" date [Default: null]
- `to Date?`: The selected "to" date [Default: null]
- `globalMetrics [Obj]`: Includes the backend supplied availabilities for cluster and subCluster
@@ -24,7 +24,7 @@
cluster = "",
selectedMetric = "",
hostnameFilter = "",
hoststateFilter = "",
nodeStateFilter = "",
from = null,
to = null,
globalMetrics
@@ -55,7 +55,7 @@
to: $to
) {
host
state
nodeState
subCluster
metrics {
name
@@ -91,11 +91,11 @@
const mappedData = $derived(handleQueryData($nodesQuery?.data));
const filteredData = $derived(mappedData.filter((h) => {
if (hostnameFilter) {
if (hoststateFilter == 'all') return h.host.includes(hostnameFilter)
else return (h.host.includes(hostnameFilter) && h.state == hoststateFilter)
if (nodeStateFilter == 'all') return h.host.includes(hostnameFilter)
else return (h.host.includes(hostnameFilter) && h.nodeState == nodeStateFilter)
} else {
if (hoststateFilter == 'all') return true
else return h.state == hoststateFilter
if (nodeStateFilter == 'all') return true
else return h.nodeState == nodeStateFilter
}
}));
@@ -116,7 +116,7 @@
if (rawData.length > 0) {
pendingMapped = rawData.map((h) => ({
host: h.host,
state: h?.state? h.state : 'notindb',
nodeState: h?.nodeState || 'notindb',
subCluster: h.subCluster,
data: h.metrics.filter(
(m) => m?.name == selectedMetric && m.scope == "node",
@@ -157,8 +157,8 @@
>
</h4>
<span style="margin-right: 0.5rem;">
<Badge color={stateColors[item?.state? item.state : 'notindb']}>
State: {item?.state? item.state.charAt(0).toUpperCase() + item.state.slice(1) : 'Not in DB'}
<Badge color={stateColors[item?.nodeState || 'notindb']}>
State: {item?.nodeState ? item.nodeState.charAt(0).toUpperCase() + item.nodeState.slice(1) : 'Not in DB'}
</Badge>
</span>
</div>
@@ -202,7 +202,7 @@
{/each}
{/key}
</Row>
{:else if hostnameFilter || hoststateFilter != 'all'}
{:else if hostnameFilter || nodeStateFilter != 'all'}
<Row class="mx-1">
<Card class="px-0">
<CardHeader>

View File

@@ -5,7 +5,8 @@
- `cluster String`: The nodes' cluster
- `subCluster String`: The nodes' subCluster
- `hostname String`: The nodes' hostname
- `dataHealth [Bool]`: Array of Booleans depicting state of returned data per metric
- `nodeState String`: The nodes current state as reported by the scheduler
- `metricHealth String`: The nodes current metric health as reported by the metricstore
- `nodeJobsData [Object]`: Data returned by GQL for jobs runninig on this node [Default: null]
-->
@@ -32,8 +33,8 @@
cluster,
subCluster,
hostname,
hoststate,
dataHealth,
nodeState,
metricHealth,
nodeJobsData = null,
} = $props();
@@ -50,12 +51,6 @@
}
/* Derived */
// Not at least one returned, selected metric: NodeHealth warning
const fetchInfo = $derived(dataHealth.includes('fetching'));
// Not at least one returned, selected metric: NodeHealth warning
const healthWarn = $derived(!dataHealth.includes(true));
// At least one non-returned selected metric: Metric config error?
const metricWarn = $derived(dataHealth.includes(false));
const userList = $derived(nodeJobsData
? Array.from(new Set(nodeJobsData.jobs.items.map((j) => scrambleNames ? scramble(j.user) : j.user))).sort((a, b) => a.localeCompare(b))
: []
@@ -86,14 +81,7 @@
<Row cols={{xs: 1, lg: 2}}>
<Col class="mb-2 mb-lg-0">
<InputGroup size="sm">
{#if fetchInfo}
<InputGroupText class="flex-grow-1 flex-lg-grow-0">
<Icon name="arrow-clockwise" style="padding-right: 0.5rem;"/>
</InputGroupText>
<Button class="flex-grow-1" color="dark" outline disabled>
Fetching
</Button>
{:else if healthWarn}
{#if metricHealth == "failed"}
<InputGroupText class="flex-grow-1 flex-lg-grow-0">
<Icon name="exclamation-circle" style="padding-right: 0.5rem;"/>
<span>Info</span>
@@ -101,13 +89,17 @@
<Button class="flex-grow-1" color="danger" disabled>
No Metrics
</Button>
{:else if metricWarn}
{:else if metricHealth == "partial" || metricHealth == "unknown"}
<InputGroupText class="flex-grow-1 flex-lg-grow-0">
<Icon name="info-circle" style="padding-right: 0.5rem;"/>
<span>Info</span>
</InputGroupText>
<Button class="flex-grow-1" color="warning" disabled>
Missing Metric
{#if metricHealth == "partial"}
Missing Metric(s)
{:else if metricHealth == "unknown"}
Metric Health Unknown
{/if}
</Button>
{:else if nodeJobsData.jobs.count == 1 && nodeJobsData?.jobs?.items[0]?.shared == "none"}
<InputGroupText class="flex-grow-1 flex-lg-grow-0">
@@ -150,8 +142,8 @@
<InputGroupText class="flex-grow-1 flex-lg-grow-0">
State
</InputGroupText>
<Button class="flex-grow-1" color={stateColors[hoststate]} disabled>
{hoststate.charAt(0).toUpperCase() + hoststate.slice(1)}
<Button class="flex-grow-1" color={stateColors[nodeState]} disabled>
{nodeState.charAt(0).toUpperCase() + nodeState.slice(1)}
</Button>
</InputGroup>
</Col>

View File

@@ -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}
<NodeInfo
{cluster}
{dataHealth}
nodeJobsData={$nodeJobsData.data}
subCluster={nodeData.subCluster}
hostname={nodeData.host}
hoststate={nodeData?.state? nodeData.state: 'notindb'}/>
nodeState={nodeData?.nodeState || 'notindb'}
metricHealth={nodeData?.metricHealth || 'unknown'}
/>
{/if}
</td>
{#each refinedData as metricData, i (metricData?.data?.name || i)}
@@ -174,7 +174,7 @@
<p>No dataset(s) returned for <b>{selectedMetrics[i]}</b></p>
<p class="mb-1">Metric or host was not found in metric store for cluster <b>{cluster}</b>.</p>
</Card>
{:else if !!metricData.data?.metric.statisticsSeries}
{:else if !!metricData?.data?.metric?.statisticsSeries}
<!-- "No Data"-Warning included in MetricPlot-Component -->
<MetricPlot
{cluster}
@@ -183,8 +183,7 @@
scope={metricData.data.scope}
timestep={metricData.data.metric.timestep}
series={metricData.data.metric.series}
statisticsSeries={metricData.data?.metric.statisticsSeries}
useStatsSeries={!!metricData.data?.metric.statisticsSeries}
statisticsSeries={metricData.data.metric.statisticsSeries}
height={175}
{plotSync}
forNode