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

@@ -289,8 +289,9 @@ type ComplexityRoot struct {
NodeMetrics struct {
Host func(childComplexity int) int
MetricHealth func(childComplexity int) int
Metrics func(childComplexity int) int
State func(childComplexity int) int
NodeState func(childComplexity int) int
SubCluster func(childComplexity int) int
}
@@ -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"`
@@ -195,7 +194,8 @@ type NodeFilter struct {
type NodeMetrics struct {
Host string `json:"host"`
State string `json:"state"`
NodeState string `json:"nodeState"`
MetricHealth string `json:"metricHealth"`
SubCluster string `json:"subCluster"`
Metrics []*JobMetricWithName `json:"metrics"`
}

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,13 +845,14 @@ 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],
NodeState: nodeStateMap[hostname],
MetricHealth: metricHealthMap[hostname],
Metrics: make([]*model.JobMetricWithName, 0, len(metrics)*len(scopes)),
}
host.SubCluster, err = archive.GetSubClusterByNode(cluster, hostname)
@@ -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")
}
@@ -911,7 +917,8 @@ func (r *queryResolver) NodeMetricsList(ctx context.Context, cluster string, sub
for _, hostname := range nodes {
host := &model.NodeMetrics{
Host: hostname,
State: stateMap[hostname],
NodeState: nodeStateMap[hostname],
MetricHealth: metricHealthMap[hostname],
Metrics: make([]*model.JobMetricWithName, 0),
}
host.SubCluster, err = archive.GetSubClusterByNode(cluster, hostname)

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