Merge pull request #325 from ClusterCockpit/add_detailed_nodelist

Add detailed nodelist
This commit is contained in:
Jan Eitzinger 2025-01-28 13:53:57 +01:00 committed by GitHub
commit 4ec9f06114
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
43 changed files with 2717 additions and 341 deletions

View File

@ -194,6 +194,15 @@ type NodeMetrics {
metrics: [JobMetricWithName!]!
}
type NodesResultList {
items: [NodeMetrics!]!
offset: Int
limit: Int
count: Int
totalNodes: Int
hasNextPage: Boolean
}
type ClusterSupport {
cluster: String!
subClusters: [String!]!
@ -241,6 +250,7 @@ type Query {
rooflineHeatmap(filter: [JobFilter!]!, rows: Int!, cols: Int!, minX: Float!, minY: Float!, maxX: Float!, maxY: Float!): [[Float!]!]!
nodeMetrics(cluster: String!, nodes: [String!], scopes: [MetricScope!], metrics: [String!], from: Time!, to: Time!): [NodeMetrics!]!
nodeMetricsList(cluster: String!, subCluster: String!, nodeFilter: String!, scopes: [MetricScope!], metrics: [String!], from: Time!, to: Time!, page: PageRequest, resolution: Int): NodesResultList!
}
type Mutation {

View File

@ -1419,7 +1419,7 @@ func (api *RestApi) updateConfiguration(rw http.ResponseWriter, r *http.Request)
rw.Header().Set("Content-Type", "text/plain")
key, value := r.FormValue("key"), r.FormValue("value")
fmt.Printf("REST > KEY: %#v\nVALUE: %#v\n", key, value)
// fmt.Printf("REST > KEY: %#v\nVALUE: %#v\n", key, value)
if err := repository.GetUserCfgRepo().UpdateConfig(key, value, repository.GetUserFromContext(r.Context())); err != nil {
http.Error(rw, err.Error(), http.StatusUnprocessableEntity)

View File

@ -249,6 +249,15 @@ type ComplexityRoot struct {
SubCluster func(childComplexity int) int
}
NodesResultList struct {
Count func(childComplexity int) int
HasNextPage func(childComplexity int) int
Items func(childComplexity int) int
Limit func(childComplexity int) int
Offset func(childComplexity int) int
TotalNodes func(childComplexity int) int
}
Query struct {
AllocatedNodes func(childComplexity int, cluster string) int
Clusters func(childComplexity int) int
@ -259,6 +268,7 @@ type ComplexityRoot struct {
JobsFootprints func(childComplexity int, filter []*model.JobFilter, metrics []string) int
JobsStatistics func(childComplexity int, filter []*model.JobFilter, metrics []string, page *model.PageRequest, sortBy *model.SortByAggregate, groupBy *model.Aggregate, numDurationBins *string, numMetricBins *int) int
NodeMetrics func(childComplexity int, cluster string, nodes []string, scopes []schema.MetricScope, metrics []string, from time.Time, to time.Time) int
NodeMetricsList func(childComplexity int, cluster string, subCluster string, nodeFilter string, scopes []schema.MetricScope, metrics []string, from time.Time, to time.Time, page *model.PageRequest, resolution *int) int
RooflineHeatmap func(childComplexity int, filter []*model.JobFilter, rows int, cols int, minX float64, minY float64, maxX float64, maxY float64) int
Tags func(childComplexity int) int
User func(childComplexity int, username string) int
@ -385,6 +395,7 @@ type QueryResolver interface {
JobsStatistics(ctx context.Context, filter []*model.JobFilter, metrics []string, page *model.PageRequest, sortBy *model.SortByAggregate, groupBy *model.Aggregate, numDurationBins *string, numMetricBins *int) ([]*model.JobsStatistics, error)
RooflineHeatmap(ctx context.Context, filter []*model.JobFilter, rows int, cols int, minX float64, minY float64, maxX float64, maxY float64) ([][]float64, error)
NodeMetrics(ctx context.Context, cluster string, nodes []string, scopes []schema.MetricScope, metrics []string, from time.Time, to time.Time) ([]*model.NodeMetrics, error)
NodeMetricsList(ctx context.Context, cluster string, subCluster string, nodeFilter string, scopes []schema.MetricScope, metrics []string, from time.Time, to time.Time, page *model.PageRequest, resolution *int) (*model.NodesResultList, error)
}
type SubClusterResolver interface {
NumberOfNodes(ctx context.Context, obj *schema.SubCluster) (int, error)
@ -1288,6 +1299,48 @@ func (e *executableSchema) Complexity(typeName, field string, childComplexity in
return e.complexity.NodeMetrics.SubCluster(childComplexity), true
case "NodesResultList.count":
if e.complexity.NodesResultList.Count == nil {
break
}
return e.complexity.NodesResultList.Count(childComplexity), true
case "NodesResultList.hasNextPage":
if e.complexity.NodesResultList.HasNextPage == nil {
break
}
return e.complexity.NodesResultList.HasNextPage(childComplexity), true
case "NodesResultList.items":
if e.complexity.NodesResultList.Items == nil {
break
}
return e.complexity.NodesResultList.Items(childComplexity), true
case "NodesResultList.limit":
if e.complexity.NodesResultList.Limit == nil {
break
}
return e.complexity.NodesResultList.Limit(childComplexity), true
case "NodesResultList.offset":
if e.complexity.NodesResultList.Offset == nil {
break
}
return e.complexity.NodesResultList.Offset(childComplexity), true
case "NodesResultList.totalNodes":
if e.complexity.NodesResultList.TotalNodes == nil {
break
}
return e.complexity.NodesResultList.TotalNodes(childComplexity), true
case "Query.allocatedNodes":
if e.complexity.Query.AllocatedNodes == nil {
break
@ -1386,6 +1439,18 @@ func (e *executableSchema) Complexity(typeName, field string, childComplexity in
return e.complexity.Query.NodeMetrics(childComplexity, args["cluster"].(string), args["nodes"].([]string), args["scopes"].([]schema.MetricScope), args["metrics"].([]string), args["from"].(time.Time), args["to"].(time.Time)), true
case "Query.nodeMetricsList":
if e.complexity.Query.NodeMetricsList == nil {
break
}
args, err := ec.field_Query_nodeMetricsList_args(context.TODO(), rawArgs)
if err != nil {
return 0, false
}
return e.complexity.Query.NodeMetricsList(childComplexity, args["cluster"].(string), args["subCluster"].(string), args["nodeFilter"].(string), args["scopes"].([]schema.MetricScope), args["metrics"].([]string), args["from"].(time.Time), args["to"].(time.Time), args["page"].(*model.PageRequest), args["resolution"].(*int)), true
case "Query.rooflineHeatmap":
if e.complexity.Query.RooflineHeatmap == nil {
break
@ -2090,6 +2155,15 @@ type NodeMetrics {
metrics: [JobMetricWithName!]!
}
type NodesResultList {
items: [NodeMetrics!]!
offset: Int
limit: Int
count: Int
totalNodes: Int
hasNextPage: Boolean
}
type ClusterSupport {
cluster: String!
subClusters: [String!]!
@ -2137,6 +2211,7 @@ type Query {
rooflineHeatmap(filter: [JobFilter!]!, rows: Int!, cols: Int!, minX: Float!, minY: Float!, maxX: Float!, maxY: Float!): [[Float!]!]!
nodeMetrics(cluster: String!, nodes: [String!], scopes: [MetricScope!], metrics: [String!], from: Time!, to: Time!): [NodeMetrics!]!
nodeMetricsList(cluster: String!, subCluster: String!, nodeFilter: String!, scopes: [MetricScope!], metrics: [String!], from: Time!, to: Time!, page: PageRequest, resolution: Int): NodesResultList!
}
type Mutation {
@ -3112,6 +3187,254 @@ func (ec *executionContext) field_Query_jobs_argsOrder(
return zeroVal, nil
}
func (ec *executionContext) field_Query_nodeMetricsList_args(ctx context.Context, rawArgs map[string]interface{}) (map[string]interface{}, error) {
var err error
args := map[string]interface{}{}
arg0, err := ec.field_Query_nodeMetricsList_argsCluster(ctx, rawArgs)
if err != nil {
return nil, err
}
args["cluster"] = arg0
arg1, err := ec.field_Query_nodeMetricsList_argsSubCluster(ctx, rawArgs)
if err != nil {
return nil, err
}
args["subCluster"] = arg1
arg2, err := ec.field_Query_nodeMetricsList_argsNodeFilter(ctx, rawArgs)
if err != nil {
return nil, err
}
args["nodeFilter"] = arg2
arg3, err := ec.field_Query_nodeMetricsList_argsScopes(ctx, rawArgs)
if err != nil {
return nil, err
}
args["scopes"] = arg3
arg4, err := ec.field_Query_nodeMetricsList_argsMetrics(ctx, rawArgs)
if err != nil {
return nil, err
}
args["metrics"] = arg4
arg5, err := ec.field_Query_nodeMetricsList_argsFrom(ctx, rawArgs)
if err != nil {
return nil, err
}
args["from"] = arg5
arg6, err := ec.field_Query_nodeMetricsList_argsTo(ctx, rawArgs)
if err != nil {
return nil, err
}
args["to"] = arg6
arg7, err := ec.field_Query_nodeMetricsList_argsPage(ctx, rawArgs)
if err != nil {
return nil, err
}
args["page"] = arg7
arg8, err := ec.field_Query_nodeMetricsList_argsResolution(ctx, rawArgs)
if err != nil {
return nil, err
}
args["resolution"] = arg8
return args, nil
}
func (ec *executionContext) field_Query_nodeMetricsList_argsCluster(
ctx context.Context,
rawArgs map[string]interface{},
) (string, error) {
// We won't call the directive if the argument is null.
// Set call_argument_directives_with_null to true to call directives
// even if the argument is null.
_, ok := rawArgs["cluster"]
if !ok {
var zeroVal string
return zeroVal, nil
}
ctx = graphql.WithPathContext(ctx, graphql.NewPathWithField("cluster"))
if tmp, ok := rawArgs["cluster"]; ok {
return ec.unmarshalNString2string(ctx, tmp)
}
var zeroVal string
return zeroVal, nil
}
func (ec *executionContext) field_Query_nodeMetricsList_argsSubCluster(
ctx context.Context,
rawArgs map[string]interface{},
) (string, error) {
// We won't call the directive if the argument is null.
// Set call_argument_directives_with_null to true to call directives
// even if the argument is null.
_, ok := rawArgs["subCluster"]
if !ok {
var zeroVal string
return zeroVal, nil
}
ctx = graphql.WithPathContext(ctx, graphql.NewPathWithField("subCluster"))
if tmp, ok := rawArgs["subCluster"]; ok {
return ec.unmarshalNString2string(ctx, tmp)
}
var zeroVal string
return zeroVal, nil
}
func (ec *executionContext) field_Query_nodeMetricsList_argsNodeFilter(
ctx context.Context,
rawArgs map[string]interface{},
) (string, error) {
// We won't call the directive if the argument is null.
// Set call_argument_directives_with_null to true to call directives
// even if the argument is null.
_, ok := rawArgs["nodeFilter"]
if !ok {
var zeroVal string
return zeroVal, nil
}
ctx = graphql.WithPathContext(ctx, graphql.NewPathWithField("nodeFilter"))
if tmp, ok := rawArgs["nodeFilter"]; ok {
return ec.unmarshalNString2string(ctx, tmp)
}
var zeroVal string
return zeroVal, nil
}
func (ec *executionContext) field_Query_nodeMetricsList_argsScopes(
ctx context.Context,
rawArgs map[string]interface{},
) ([]schema.MetricScope, error) {
// We won't call the directive if the argument is null.
// Set call_argument_directives_with_null to true to call directives
// even if the argument is null.
_, ok := rawArgs["scopes"]
if !ok {
var zeroVal []schema.MetricScope
return zeroVal, nil
}
ctx = graphql.WithPathContext(ctx, graphql.NewPathWithField("scopes"))
if tmp, ok := rawArgs["scopes"]; ok {
return ec.unmarshalOMetricScope2ᚕgithubᚗcomᚋClusterCockpitᚋccᚑbackendᚋpkgᚋschemaᚐMetricScopeᚄ(ctx, tmp)
}
var zeroVal []schema.MetricScope
return zeroVal, nil
}
func (ec *executionContext) field_Query_nodeMetricsList_argsMetrics(
ctx context.Context,
rawArgs map[string]interface{},
) ([]string, error) {
// We won't call the directive if the argument is null.
// Set call_argument_directives_with_null to true to call directives
// even if the argument is null.
_, ok := rawArgs["metrics"]
if !ok {
var zeroVal []string
return zeroVal, nil
}
ctx = graphql.WithPathContext(ctx, graphql.NewPathWithField("metrics"))
if tmp, ok := rawArgs["metrics"]; ok {
return ec.unmarshalOString2ᚕstringᚄ(ctx, tmp)
}
var zeroVal []string
return zeroVal, nil
}
func (ec *executionContext) field_Query_nodeMetricsList_argsFrom(
ctx context.Context,
rawArgs map[string]interface{},
) (time.Time, error) {
// We won't call the directive if the argument is null.
// Set call_argument_directives_with_null to true to call directives
// even if the argument is null.
_, ok := rawArgs["from"]
if !ok {
var zeroVal time.Time
return zeroVal, nil
}
ctx = graphql.WithPathContext(ctx, graphql.NewPathWithField("from"))
if tmp, ok := rawArgs["from"]; ok {
return ec.unmarshalNTime2timeᚐTime(ctx, tmp)
}
var zeroVal time.Time
return zeroVal, nil
}
func (ec *executionContext) field_Query_nodeMetricsList_argsTo(
ctx context.Context,
rawArgs map[string]interface{},
) (time.Time, error) {
// We won't call the directive if the argument is null.
// Set call_argument_directives_with_null to true to call directives
// even if the argument is null.
_, ok := rawArgs["to"]
if !ok {
var zeroVal time.Time
return zeroVal, nil
}
ctx = graphql.WithPathContext(ctx, graphql.NewPathWithField("to"))
if tmp, ok := rawArgs["to"]; ok {
return ec.unmarshalNTime2timeᚐTime(ctx, tmp)
}
var zeroVal time.Time
return zeroVal, nil
}
func (ec *executionContext) field_Query_nodeMetricsList_argsPage(
ctx context.Context,
rawArgs map[string]interface{},
) (*model.PageRequest, error) {
// We won't call the directive if the argument is null.
// Set call_argument_directives_with_null to true to call directives
// even if the argument is null.
_, ok := rawArgs["page"]
if !ok {
var zeroVal *model.PageRequest
return zeroVal, nil
}
ctx = graphql.WithPathContext(ctx, graphql.NewPathWithField("page"))
if tmp, ok := rawArgs["page"]; ok {
return ec.unmarshalOPageRequest2ᚖgithubᚗcomᚋClusterCockpitᚋccᚑbackendᚋinternalᚋgraphᚋmodelᚐPageRequest(ctx, tmp)
}
var zeroVal *model.PageRequest
return zeroVal, nil
}
func (ec *executionContext) field_Query_nodeMetricsList_argsResolution(
ctx context.Context,
rawArgs map[string]interface{},
) (*int, error) {
// We won't call the directive if the argument is null.
// Set call_argument_directives_with_null to true to call directives
// even if the argument is null.
_, ok := rawArgs["resolution"]
if !ok {
var zeroVal *int
return zeroVal, nil
}
ctx = graphql.WithPathContext(ctx, graphql.NewPathWithField("resolution"))
if tmp, ok := rawArgs["resolution"]; ok {
return ec.unmarshalOInt2ᚖint(ctx, tmp)
}
var zeroVal *int
return zeroVal, nil
}
func (ec *executionContext) field_Query_nodeMetrics_args(ctx context.Context, rawArgs map[string]interface{}) (map[string]interface{}, error) {
var err error
args := map[string]interface{}{}
@ -9238,6 +9561,263 @@ func (ec *executionContext) fieldContext_NodeMetrics_metrics(_ context.Context,
return fc, nil
}
func (ec *executionContext) _NodesResultList_items(ctx context.Context, field graphql.CollectedField, obj *model.NodesResultList) (ret graphql.Marshaler) {
fc, err := ec.fieldContext_NodesResultList_items(ctx, field)
if err != nil {
return graphql.Null
}
ctx = graphql.WithFieldContext(ctx, fc)
defer func() {
if r := recover(); r != nil {
ec.Error(ctx, ec.Recover(ctx, r))
ret = graphql.Null
}
}()
resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) {
ctx = rctx // use context from middleware stack in children
return obj.Items, nil
})
if err != nil {
ec.Error(ctx, err)
return graphql.Null
}
if resTmp == nil {
if !graphql.HasFieldError(ctx, fc) {
ec.Errorf(ctx, "must not be null")
}
return graphql.Null
}
res := resTmp.([]*model.NodeMetrics)
fc.Result = res
return ec.marshalNNodeMetrics2ᚕᚖgithubᚗcomᚋClusterCockpitᚋccᚑbackendᚋinternalᚋgraphᚋmodelᚐNodeMetricsᚄ(ctx, field.Selections, res)
}
func (ec *executionContext) fieldContext_NodesResultList_items(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) {
fc = &graphql.FieldContext{
Object: "NodesResultList",
Field: field,
IsMethod: false,
IsResolver: false,
Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) {
switch field.Name {
case "host":
return ec.fieldContext_NodeMetrics_host(ctx, field)
case "subCluster":
return ec.fieldContext_NodeMetrics_subCluster(ctx, field)
case "metrics":
return ec.fieldContext_NodeMetrics_metrics(ctx, field)
}
return nil, fmt.Errorf("no field named %q was found under type NodeMetrics", field.Name)
},
}
return fc, nil
}
func (ec *executionContext) _NodesResultList_offset(ctx context.Context, field graphql.CollectedField, obj *model.NodesResultList) (ret graphql.Marshaler) {
fc, err := ec.fieldContext_NodesResultList_offset(ctx, field)
if err != nil {
return graphql.Null
}
ctx = graphql.WithFieldContext(ctx, fc)
defer func() {
if r := recover(); r != nil {
ec.Error(ctx, ec.Recover(ctx, r))
ret = graphql.Null
}
}()
resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) {
ctx = rctx // use context from middleware stack in children
return obj.Offset, nil
})
if err != nil {
ec.Error(ctx, err)
return graphql.Null
}
if resTmp == nil {
return graphql.Null
}
res := resTmp.(*int)
fc.Result = res
return ec.marshalOInt2ᚖint(ctx, field.Selections, res)
}
func (ec *executionContext) fieldContext_NodesResultList_offset(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) {
fc = &graphql.FieldContext{
Object: "NodesResultList",
Field: field,
IsMethod: false,
IsResolver: false,
Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) {
return nil, errors.New("field of type Int does not have child fields")
},
}
return fc, nil
}
func (ec *executionContext) _NodesResultList_limit(ctx context.Context, field graphql.CollectedField, obj *model.NodesResultList) (ret graphql.Marshaler) {
fc, err := ec.fieldContext_NodesResultList_limit(ctx, field)
if err != nil {
return graphql.Null
}
ctx = graphql.WithFieldContext(ctx, fc)
defer func() {
if r := recover(); r != nil {
ec.Error(ctx, ec.Recover(ctx, r))
ret = graphql.Null
}
}()
resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) {
ctx = rctx // use context from middleware stack in children
return obj.Limit, nil
})
if err != nil {
ec.Error(ctx, err)
return graphql.Null
}
if resTmp == nil {
return graphql.Null
}
res := resTmp.(*int)
fc.Result = res
return ec.marshalOInt2ᚖint(ctx, field.Selections, res)
}
func (ec *executionContext) fieldContext_NodesResultList_limit(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) {
fc = &graphql.FieldContext{
Object: "NodesResultList",
Field: field,
IsMethod: false,
IsResolver: false,
Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) {
return nil, errors.New("field of type Int does not have child fields")
},
}
return fc, nil
}
func (ec *executionContext) _NodesResultList_count(ctx context.Context, field graphql.CollectedField, obj *model.NodesResultList) (ret graphql.Marshaler) {
fc, err := ec.fieldContext_NodesResultList_count(ctx, field)
if err != nil {
return graphql.Null
}
ctx = graphql.WithFieldContext(ctx, fc)
defer func() {
if r := recover(); r != nil {
ec.Error(ctx, ec.Recover(ctx, r))
ret = graphql.Null
}
}()
resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) {
ctx = rctx // use context from middleware stack in children
return obj.Count, nil
})
if err != nil {
ec.Error(ctx, err)
return graphql.Null
}
if resTmp == nil {
return graphql.Null
}
res := resTmp.(*int)
fc.Result = res
return ec.marshalOInt2ᚖint(ctx, field.Selections, res)
}
func (ec *executionContext) fieldContext_NodesResultList_count(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) {
fc = &graphql.FieldContext{
Object: "NodesResultList",
Field: field,
IsMethod: false,
IsResolver: false,
Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) {
return nil, errors.New("field of type Int does not have child fields")
},
}
return fc, nil
}
func (ec *executionContext) _NodesResultList_totalNodes(ctx context.Context, field graphql.CollectedField, obj *model.NodesResultList) (ret graphql.Marshaler) {
fc, err := ec.fieldContext_NodesResultList_totalNodes(ctx, field)
if err != nil {
return graphql.Null
}
ctx = graphql.WithFieldContext(ctx, fc)
defer func() {
if r := recover(); r != nil {
ec.Error(ctx, ec.Recover(ctx, r))
ret = graphql.Null
}
}()
resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) {
ctx = rctx // use context from middleware stack in children
return obj.TotalNodes, nil
})
if err != nil {
ec.Error(ctx, err)
return graphql.Null
}
if resTmp == nil {
return graphql.Null
}
res := resTmp.(*int)
fc.Result = res
return ec.marshalOInt2ᚖint(ctx, field.Selections, res)
}
func (ec *executionContext) fieldContext_NodesResultList_totalNodes(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) {
fc = &graphql.FieldContext{
Object: "NodesResultList",
Field: field,
IsMethod: false,
IsResolver: false,
Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) {
return nil, errors.New("field of type Int does not have child fields")
},
}
return fc, nil
}
func (ec *executionContext) _NodesResultList_hasNextPage(ctx context.Context, field graphql.CollectedField, obj *model.NodesResultList) (ret graphql.Marshaler) {
fc, err := ec.fieldContext_NodesResultList_hasNextPage(ctx, field)
if err != nil {
return graphql.Null
}
ctx = graphql.WithFieldContext(ctx, fc)
defer func() {
if r := recover(); r != nil {
ec.Error(ctx, ec.Recover(ctx, r))
ret = graphql.Null
}
}()
resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) {
ctx = rctx // use context from middleware stack in children
return obj.HasNextPage, nil
})
if err != nil {
ec.Error(ctx, err)
return graphql.Null
}
if resTmp == nil {
return graphql.Null
}
res := resTmp.(*bool)
fc.Result = res
return ec.marshalOBoolean2ᚖbool(ctx, field.Selections, res)
}
func (ec *executionContext) fieldContext_NodesResultList_hasNextPage(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) {
fc = &graphql.FieldContext{
Object: "NodesResultList",
Field: field,
IsMethod: false,
IsResolver: false,
Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) {
return nil, errors.New("field of type Boolean does not have child fields")
},
}
return fc, nil
}
func (ec *executionContext) _Query_clusters(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) {
fc, err := ec.fieldContext_Query_clusters(ctx, field)
if err != nil {
@ -10024,6 +10604,75 @@ func (ec *executionContext) fieldContext_Query_nodeMetrics(ctx context.Context,
return fc, nil
}
func (ec *executionContext) _Query_nodeMetricsList(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) {
fc, err := ec.fieldContext_Query_nodeMetricsList(ctx, field)
if err != nil {
return graphql.Null
}
ctx = graphql.WithFieldContext(ctx, fc)
defer func() {
if r := recover(); r != nil {
ec.Error(ctx, ec.Recover(ctx, r))
ret = graphql.Null
}
}()
resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) {
ctx = rctx // use context from middleware stack in children
return ec.resolvers.Query().NodeMetricsList(rctx, fc.Args["cluster"].(string), fc.Args["subCluster"].(string), fc.Args["nodeFilter"].(string), fc.Args["scopes"].([]schema.MetricScope), fc.Args["metrics"].([]string), fc.Args["from"].(time.Time), fc.Args["to"].(time.Time), fc.Args["page"].(*model.PageRequest), fc.Args["resolution"].(*int))
})
if err != nil {
ec.Error(ctx, err)
return graphql.Null
}
if resTmp == nil {
if !graphql.HasFieldError(ctx, fc) {
ec.Errorf(ctx, "must not be null")
}
return graphql.Null
}
res := resTmp.(*model.NodesResultList)
fc.Result = res
return ec.marshalNNodesResultList2ᚖgithubᚗcomᚋClusterCockpitᚋccᚑbackendᚋinternalᚋgraphᚋmodelᚐNodesResultList(ctx, field.Selections, res)
}
func (ec *executionContext) fieldContext_Query_nodeMetricsList(ctx context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) {
fc = &graphql.FieldContext{
Object: "Query",
Field: field,
IsMethod: true,
IsResolver: true,
Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) {
switch field.Name {
case "items":
return ec.fieldContext_NodesResultList_items(ctx, field)
case "offset":
return ec.fieldContext_NodesResultList_offset(ctx, field)
case "limit":
return ec.fieldContext_NodesResultList_limit(ctx, field)
case "count":
return ec.fieldContext_NodesResultList_count(ctx, field)
case "totalNodes":
return ec.fieldContext_NodesResultList_totalNodes(ctx, field)
case "hasNextPage":
return ec.fieldContext_NodesResultList_hasNextPage(ctx, field)
}
return nil, fmt.Errorf("no field named %q was found under type NodesResultList", field.Name)
},
}
defer func() {
if r := recover(); r != nil {
err = ec.Recover(ctx, r)
ec.Error(ctx, err)
}
}()
ctx = graphql.WithFieldContext(ctx, fc)
if fc.Args, err = ec.field_Query_nodeMetricsList_args(ctx, field.ArgumentMap(ec.Variables)); err != nil {
ec.Error(ctx, err)
return fc, err
}
return fc, nil
}
func (ec *executionContext) _Query___type(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) {
fc, err := ec.fieldContext_Query___type(ctx, field)
if err != nil {
@ -16337,6 +16986,55 @@ func (ec *executionContext) _NodeMetrics(ctx context.Context, sel ast.SelectionS
return out
}
var nodesResultListImplementors = []string{"NodesResultList"}
func (ec *executionContext) _NodesResultList(ctx context.Context, sel ast.SelectionSet, obj *model.NodesResultList) graphql.Marshaler {
fields := graphql.CollectFields(ec.OperationContext, sel, nodesResultListImplementors)
out := graphql.NewFieldSet(fields)
deferred := make(map[string]*graphql.FieldSet)
for i, field := range fields {
switch field.Name {
case "__typename":
out.Values[i] = graphql.MarshalString("NodesResultList")
case "items":
out.Values[i] = ec._NodesResultList_items(ctx, field, obj)
if out.Values[i] == graphql.Null {
out.Invalids++
}
case "offset":
out.Values[i] = ec._NodesResultList_offset(ctx, field, obj)
case "limit":
out.Values[i] = ec._NodesResultList_limit(ctx, field, obj)
case "count":
out.Values[i] = ec._NodesResultList_count(ctx, field, obj)
case "totalNodes":
out.Values[i] = ec._NodesResultList_totalNodes(ctx, field, obj)
case "hasNextPage":
out.Values[i] = ec._NodesResultList_hasNextPage(ctx, field, obj)
default:
panic("unknown field " + strconv.Quote(field.Name))
}
}
out.Dispatch(ctx)
if out.Invalids > 0 {
return graphql.Null
}
atomic.AddInt32(&ec.deferred, int32(len(deferred)))
for label, dfs := range deferred {
ec.processDeferredGroup(graphql.DeferredGroup{
Label: label,
Path: graphql.GetPath(ctx),
FieldSet: dfs,
Context: ctx,
})
}
return out
}
var queryImplementors = []string{"Query"}
func (ec *executionContext) _Query(ctx context.Context, sel ast.SelectionSet) graphql.Marshaler {
@ -16610,6 +17308,28 @@ func (ec *executionContext) _Query(ctx context.Context, sel ast.SelectionSet) gr
func(ctx context.Context) graphql.Marshaler { return innerFunc(ctx, out) })
}
out.Concurrently(i, func(ctx context.Context) graphql.Marshaler { return rrm(innerCtx) })
case "nodeMetricsList":
field := field
innerFunc := func(ctx context.Context, fs *graphql.FieldSet) (res graphql.Marshaler) {
defer func() {
if r := recover(); r != nil {
ec.Error(ctx, ec.Recover(ctx, r))
}
}()
res = ec._Query_nodeMetricsList(ctx, field)
if res == graphql.Null {
atomic.AddUint32(&fs.Invalids, 1)
}
return res
}
rrm := func(ctx context.Context) graphql.Marshaler {
return ec.OperationContext.RootResolverMiddleware(ctx,
func(ctx context.Context) graphql.Marshaler { return innerFunc(ctx, out) })
}
out.Concurrently(i, func(ctx context.Context) graphql.Marshaler { return rrm(innerCtx) })
case "__type":
out.Values[i] = ec.OperationContext.RootResolverMiddleware(innerCtx, func(ctx context.Context) (res graphql.Marshaler) {
@ -18655,6 +19375,20 @@ func (ec *executionContext) marshalNNodeMetrics2ᚖgithubᚗcomᚋClusterCockpit
return ec._NodeMetrics(ctx, sel, v)
}
func (ec *executionContext) marshalNNodesResultList2githubᚗcomᚋClusterCockpitᚋccᚑbackendᚋinternalᚋgraphᚋmodelᚐNodesResultList(ctx context.Context, sel ast.SelectionSet, v model.NodesResultList) graphql.Marshaler {
return ec._NodesResultList(ctx, sel, &v)
}
func (ec *executionContext) marshalNNodesResultList2ᚖgithubᚗcomᚋClusterCockpitᚋccᚑbackendᚋinternalᚋgraphᚋmodelᚐNodesResultList(ctx context.Context, sel ast.SelectionSet, v *model.NodesResultList) graphql.Marshaler {
if v == nil {
if !graphql.HasFieldError(ctx, graphql.GetFieldContext(ctx)) {
ec.Errorf(ctx, "the requested element is null which the schema does not allow")
}
return graphql.Null
}
return ec._NodesResultList(ctx, sel, v)
}
func (ec *executionContext) unmarshalNNullableFloat2githubᚗcomᚋClusterCockpitᚋccᚑbackendᚋpkgᚋschemaᚐFloat(ctx context.Context, v interface{}) (schema.Float, error) {
var res schema.Float
err := res.UnmarshalGQL(v)

View File

@ -148,6 +148,15 @@ type NodeMetrics struct {
Metrics []*JobMetricWithName `json:"metrics"`
}
type NodesResultList struct {
Items []*NodeMetrics `json:"items"`
Offset *int `json:"offset,omitempty"`
Limit *int `json:"limit,omitempty"`
Count *int `json:"count,omitempty"`
TotalNodes *int `json:"totalNodes,omitempty"`
HasNextPage *bool `json:"hasNextPage,omitempty"`
}
type OrderByInput struct {
Field string `json:"field"`
Type string `json:"type"`

View File

@ -437,8 +437,8 @@ func (r *queryResolver) RooflineHeatmap(ctx context.Context, filter []*model.Job
// NodeMetrics is the resolver for the nodeMetrics field.
func (r *queryResolver) NodeMetrics(ctx context.Context, cluster string, nodes []string, scopes []schema.MetricScope, metrics []string, from time.Time, to time.Time) ([]*model.NodeMetrics, error) {
user := repository.GetUserFromContext(ctx)
if user != nil && !user.HasRole(schema.RoleAdmin) {
return nil, errors.New("you need to be an administrator for this query")
if user != nil && !user.HasAnyRole([]schema.Role{schema.RoleAdmin, schema.RoleSupport}) {
return nil, errors.New("you need to be administrator or support staff for this query")
}
if metrics == nil {
@ -449,7 +449,7 @@ func (r *queryResolver) NodeMetrics(ctx context.Context, cluster string, nodes [
data, err := metricDataDispatcher.LoadNodeData(cluster, metrics, nodes, scopes, from, to, ctx)
if err != nil {
log.Warn("Error while loading node data")
log.Warn("error while loading node data")
return nil, err
}
@ -459,7 +459,10 @@ func (r *queryResolver) NodeMetrics(ctx context.Context, cluster string, nodes [
Host: hostname,
Metrics: make([]*model.JobMetricWithName, 0, len(metrics)*len(scopes)),
}
host.SubCluster, _ = archive.GetSubClusterByNode(cluster, hostname)
host.SubCluster, err = archive.GetSubClusterByNode(cluster, hostname)
if err != nil {
log.Warnf("error in nodeMetrics resolver: %s", err)
}
for metric, scopedMetrics := range metrics {
for _, scopedMetric := range scopedMetrics {
@ -477,6 +480,68 @@ func (r *queryResolver) NodeMetrics(ctx context.Context, cluster string, nodes [
return nodeMetrics, nil
}
// NodeMetricsList is the resolver for the nodeMetricsList field.
func (r *queryResolver) NodeMetricsList(ctx context.Context, cluster string, subCluster string, nodeFilter string, scopes []schema.MetricScope, metrics []string, from time.Time, to time.Time, page *model.PageRequest, resolution *int) (*model.NodesResultList, error) {
if resolution == nil { // Load from Config
if config.Keys.EnableResampling != nil {
defaultRes := slices.Max(config.Keys.EnableResampling.Resolutions)
resolution = &defaultRes
} else { // Set 0 (Loads configured metric timestep)
defaultRes := 0
resolution = &defaultRes
}
}
user := repository.GetUserFromContext(ctx)
if user != nil && !user.HasAnyRole([]schema.Role{schema.RoleAdmin, schema.RoleSupport}) {
return nil, errors.New("you need to be administrator or support staff for this query")
}
if metrics == nil {
for _, mc := range archive.GetCluster(cluster).MetricConfig {
metrics = append(metrics, mc.Name)
}
}
data, totalNodes, hasNextPage, err := metricDataDispatcher.LoadNodeListData(cluster, subCluster, nodeFilter, metrics, scopes, *resolution, from, to, page, ctx)
if err != nil {
log.Warn("error while loading node data")
return nil, err
}
nodeMetricsList := make([]*model.NodeMetrics, 0, len(data))
for hostname, metrics := range data {
host := &model.NodeMetrics{
Host: hostname,
Metrics: make([]*model.JobMetricWithName, 0, len(metrics)*len(scopes)),
}
host.SubCluster, err = archive.GetSubClusterByNode(cluster, hostname)
if err != nil {
log.Warnf("error in nodeMetrics resolver: %s", err)
}
for metric, scopedMetrics := range metrics {
for scope, scopedMetric := range scopedMetrics {
host.Metrics = append(host.Metrics, &model.JobMetricWithName{
Name: metric,
Scope: scope,
Metric: scopedMetric,
})
}
}
nodeMetricsList = append(nodeMetricsList, host)
}
nodeMetricsListResult := &model.NodesResultList{
Items: nodeMetricsList,
TotalNodes: &totalNodes,
HasNextPage: &hasNextPage,
}
return nodeMetricsListResult, nil
}
// NumberOfNodes is the resolver for the numberOfNodes field.
func (r *subClusterResolver) NumberOfNodes(ctx context.Context, obj *schema.SubCluster) (int, error) {
nodeList, err := archive.ParseNodeList(obj.Nodes)

View File

@ -10,6 +10,7 @@ import (
"time"
"github.com/ClusterCockpit/cc-backend/internal/config"
"github.com/ClusterCockpit/cc-backend/internal/graph/model"
"github.com/ClusterCockpit/cc-backend/internal/metricdata"
"github.com/ClusterCockpit/cc-backend/pkg/archive"
"github.com/ClusterCockpit/cc-backend/pkg/log"
@ -219,7 +220,7 @@ func LoadAverages(
return nil
}
// Used for the node/system view. Returns a map of nodes to a map of metrics.
// Used for the classic node/system view. Returns a map of nodes to a map of metrics.
func LoadNodeData(
cluster string,
metrics, nodes []string,
@ -254,3 +255,53 @@ func LoadNodeData(
return data, nil
}
func LoadNodeListData(
cluster, subCluster, nodeFilter string,
metrics []string,
scopes []schema.MetricScope,
resolution int,
from, to time.Time,
page *model.PageRequest,
ctx context.Context,
) (map[string]schema.JobData, int, bool, error) {
repo, err := metricdata.GetMetricDataRepo(cluster)
if err != nil {
return nil, 0, false, fmt.Errorf("METRICDATA/METRICDATA > no metric data repository configured for '%s'", cluster)
}
if metrics == nil {
for _, m := range archive.GetCluster(cluster).MetricConfig {
metrics = append(metrics, m.Name)
}
}
data, totalNodes, hasNextPage, err := repo.LoadNodeListData(cluster, subCluster, nodeFilter, metrics, scopes, resolution, from, to, page, ctx)
if err != nil {
if len(data) != 0 {
log.Warnf("partial error: %s", err.Error())
} else {
log.Error("Error while loading node data from metric repository")
return nil, totalNodes, hasNextPage, err
}
}
// NOTE: New StatsSeries will always be calculated as 'min/median/max'
const maxSeriesSize int = 8
for _, jd := range data {
for _, scopes := range jd {
for _, jm := range scopes {
if jm.StatisticsSeries != nil || len(jm.Series) < maxSeriesSize {
continue
}
jm.AddStatisticsSeries()
}
}
}
if data == nil {
return nil, totalNodes, hasNextPage, fmt.Errorf("METRICDATA/METRICDATA > the metric data repository for '%s' does not support this query", cluster)
}
return data, totalNodes, hasNextPage, nil
}

View File

@ -11,10 +11,12 @@ import (
"encoding/json"
"fmt"
"net/http"
"sort"
"strconv"
"strings"
"time"
"github.com/ClusterCockpit/cc-backend/internal/graph/model"
"github.com/ClusterCockpit/cc-backend/pkg/archive"
"github.com/ClusterCockpit/cc-backend/pkg/log"
"github.com/ClusterCockpit/cc-backend/pkg/schema"
@ -211,7 +213,6 @@ func (ccms *CCMetricStore) LoadData(
}
jobMetric, ok := jobData[metric][scope]
if !ok {
jobMetric = &schema.JobMetric{
Unit: mc.Unit,
@ -235,8 +236,7 @@ func (ccms *CCMetricStore) LoadData(
}
if res.Avg.IsNaN() || res.Min.IsNaN() || res.Max.IsNaN() {
// TODO: use schema.Float instead of float64?
// This is done because regular float64 can not be JSONed when NaN.
// "schema.Float()" because regular float64 can not be JSONed when NaN.
res.Avg = schema.Float(0)
res.Min = schema.Float(0)
res.Max = schema.Float(0)
@ -693,6 +693,445 @@ func (ccms *CCMetricStore) LoadNodeData(
return data, nil
}
func (ccms *CCMetricStore) LoadNodeListData(
cluster, subCluster, nodeFilter string,
metrics []string,
scopes []schema.MetricScope,
resolution int,
from, to time.Time,
page *model.PageRequest,
ctx context.Context,
) (map[string]schema.JobData, int, bool, error) {
// 0) Init additional vars
var totalNodes int = 0
var hasNextPage bool = false
// 1) Get list of all nodes
var nodes []string
if subCluster != "" {
scNodes := archive.NodeLists[cluster][subCluster]
nodes = scNodes.PrintList()
} else {
subClusterNodeLists := archive.NodeLists[cluster]
for _, nodeList := range subClusterNodeLists {
nodes = append(nodes, nodeList.PrintList()...)
}
}
// 2) Filter nodes
if nodeFilter != "" {
filteredNodes := []string{}
for _, node := range nodes {
if strings.Contains(node, nodeFilter) {
filteredNodes = append(filteredNodes, node)
}
}
nodes = filteredNodes
}
// 2.1) Count total nodes && Sort nodes -> Sorting invalidated after ccms return ...
totalNodes = len(nodes)
sort.Strings(nodes)
// 3) Apply paging
if len(nodes) > page.ItemsPerPage {
start := (page.Page - 1) * page.ItemsPerPage
end := start + page.ItemsPerPage
if end > len(nodes) {
end = len(nodes)
hasNextPage = false
} else {
hasNextPage = true
}
nodes = nodes[start:end]
}
// Note: Order of node data is not guaranteed after this point, but contents match page and filter criteria
queries, assignedScope, err := ccms.buildNodeQueries(cluster, subCluster, nodes, metrics, scopes, resolution)
if err != nil {
log.Warn("Error while building queries")
return nil, totalNodes, hasNextPage, err
}
req := ApiQueryRequest{
Cluster: cluster,
Queries: queries,
From: from.Unix(),
To: to.Unix(),
WithStats: true,
WithData: true,
}
resBody, err := ccms.doRequest(ctx, &req)
if err != nil {
log.Error(fmt.Sprintf("Error while performing request %#v\n", err))
return nil, totalNodes, hasNextPage, err
}
var errors []string
data := make(map[string]schema.JobData)
for i, row := range resBody.Results {
var query ApiQuery
if resBody.Queries != nil {
query = resBody.Queries[i]
} else {
query = req.Queries[i]
}
// qdata := res[0]
metric := ccms.toLocalName(query.Metric)
scope := assignedScope[i]
mc := archive.GetMetricConfig(cluster, metric)
res := row[0].Resolution
if res == 0 {
res = mc.Timestep
}
// Init Nested Map Data Structures If Not Found
hostData, ok := data[query.Hostname]
if !ok {
hostData = make(schema.JobData)
data[query.Hostname] = hostData
}
metricData, ok := hostData[metric]
if !ok {
metricData = make(map[schema.MetricScope]*schema.JobMetric)
data[query.Hostname][metric] = metricData
}
scopeData, ok := metricData[scope]
if !ok {
scopeData = &schema.JobMetric{
Unit: mc.Unit,
Timestep: res,
Series: make([]schema.Series, 0),
}
data[query.Hostname][metric][scope] = scopeData
}
for ndx, res := range row {
if res.Error != nil {
/* Build list for "partial errors", if any */
errors = append(errors, fmt.Sprintf("failed to fetch '%s' from host '%s': %s", query.Metric, query.Hostname, *res.Error))
continue
}
id := (*string)(nil)
if query.Type != nil {
id = new(string)
*id = query.TypeIds[ndx]
}
if res.Avg.IsNaN() || res.Min.IsNaN() || res.Max.IsNaN() {
// "schema.Float()" because regular float64 can not be JSONed when NaN.
res.Avg = schema.Float(0)
res.Min = schema.Float(0)
res.Max = schema.Float(0)
}
scopeData.Series = append(scopeData.Series, schema.Series{
Hostname: query.Hostname,
Id: id,
Statistics: schema.MetricStatistics{
Avg: float64(res.Avg),
Min: float64(res.Min),
Max: float64(res.Max),
},
Data: res.Data,
})
}
}
if len(errors) != 0 {
/* Returns list of "partial errors" */
return data, totalNodes, hasNextPage, fmt.Errorf("METRICDATA/CCMS > Errors: %s", strings.Join(errors, ", "))
}
return data, totalNodes, hasNextPage, nil
}
func (ccms *CCMetricStore) buildNodeQueries(
cluster string,
subCluster string,
nodes []string,
metrics []string,
scopes []schema.MetricScope,
resolution int,
) ([]ApiQuery, []schema.MetricScope, error) {
queries := make([]ApiQuery, 0, len(metrics)*len(scopes)*len(nodes))
assignedScope := []schema.MetricScope{}
// Get Topol before loop if subCluster given
var subClusterTopol *schema.SubCluster
var scterr error
if subCluster != "" {
subClusterTopol, scterr = archive.GetSubCluster(cluster, subCluster)
if scterr != nil {
// TODO: Log
return nil, nil, scterr
}
}
for _, metric := range metrics {
remoteName := ccms.toRemoteName(metric)
mc := archive.GetMetricConfig(cluster, metric)
if mc == nil {
// return nil, fmt.Errorf("METRICDATA/CCMS > metric '%s' is not specified for cluster '%s'", metric, cluster)
log.Infof("metric '%s' is not specified for cluster '%s'", metric, cluster)
continue
}
// Avoid duplicates...
handledScopes := make([]schema.MetricScope, 0, 3)
scopesLoop:
for _, requestedScope := range scopes {
nativeScope := mc.Scope
scope := nativeScope.Max(requestedScope)
for _, s := range handledScopes {
if scope == s {
continue scopesLoop
}
}
handledScopes = append(handledScopes, scope)
for _, hostname := range nodes {
// If no subCluster given, get it by node
if subCluster == "" {
subClusterName, scnerr := archive.GetSubClusterByNode(cluster, hostname)
if scnerr != nil {
return nil, nil, scnerr
}
subClusterTopol, scterr = archive.GetSubCluster(cluster, subClusterName)
if scterr != nil {
return nil, nil, scterr
}
}
// Always full node hwthread id list, no partial queries expected -> Use "topology.Node" directly where applicable
// Always full accelerator id list, no partial queries expected -> Use "acceleratorIds" directly where applicable
topology := subClusterTopol.Topology
acceleratorIds := topology.GetAcceleratorIDs()
// Moved check here if metric matches hardware specs
if nativeScope == schema.MetricScopeAccelerator && len(acceleratorIds) == 0 {
continue scopesLoop
}
// Accelerator -> Accelerator (Use "accelerator" scope if requested scope is lower than node)
if nativeScope == schema.MetricScopeAccelerator && scope.LT(schema.MetricScopeNode) {
if scope != schema.MetricScopeAccelerator {
// Skip all other catched cases
continue
}
queries = append(queries, ApiQuery{
Metric: remoteName,
Hostname: hostname,
Aggregate: false,
Type: &acceleratorString,
TypeIds: acceleratorIds,
Resolution: resolution,
})
assignedScope = append(assignedScope, schema.MetricScopeAccelerator)
continue
}
// Accelerator -> Node
if nativeScope == schema.MetricScopeAccelerator && scope == schema.MetricScopeNode {
if len(acceleratorIds) == 0 {
continue
}
queries = append(queries, ApiQuery{
Metric: remoteName,
Hostname: hostname,
Aggregate: true,
Type: &acceleratorString,
TypeIds: acceleratorIds,
Resolution: resolution,
})
assignedScope = append(assignedScope, scope)
continue
}
// HWThread -> HWThead
if nativeScope == schema.MetricScopeHWThread && scope == schema.MetricScopeHWThread {
queries = append(queries, ApiQuery{
Metric: remoteName,
Hostname: hostname,
Aggregate: false,
Type: &hwthreadString,
TypeIds: intToStringSlice(topology.Node),
Resolution: resolution,
})
assignedScope = append(assignedScope, scope)
continue
}
// HWThread -> Core
if nativeScope == schema.MetricScopeHWThread && scope == schema.MetricScopeCore {
cores, _ := topology.GetCoresFromHWThreads(topology.Node)
for _, core := range cores {
queries = append(queries, ApiQuery{
Metric: remoteName,
Hostname: hostname,
Aggregate: true,
Type: &hwthreadString,
TypeIds: intToStringSlice(topology.Core[core]),
Resolution: resolution,
})
assignedScope = append(assignedScope, scope)
}
continue
}
// HWThread -> Socket
if nativeScope == schema.MetricScopeHWThread && scope == schema.MetricScopeSocket {
sockets, _ := topology.GetSocketsFromHWThreads(topology.Node)
for _, socket := range sockets {
queries = append(queries, ApiQuery{
Metric: remoteName,
Hostname: hostname,
Aggregate: true,
Type: &hwthreadString,
TypeIds: intToStringSlice(topology.Socket[socket]),
Resolution: resolution,
})
assignedScope = append(assignedScope, scope)
}
continue
}
// HWThread -> Node
if nativeScope == schema.MetricScopeHWThread && scope == schema.MetricScopeNode {
queries = append(queries, ApiQuery{
Metric: remoteName,
Hostname: hostname,
Aggregate: true,
Type: &hwthreadString,
TypeIds: intToStringSlice(topology.Node),
Resolution: resolution,
})
assignedScope = append(assignedScope, scope)
continue
}
// Core -> Core
if nativeScope == schema.MetricScopeCore && scope == schema.MetricScopeCore {
cores, _ := topology.GetCoresFromHWThreads(topology.Node)
queries = append(queries, ApiQuery{
Metric: remoteName,
Hostname: hostname,
Aggregate: false,
Type: &coreString,
TypeIds: intToStringSlice(cores),
Resolution: resolution,
})
assignedScope = append(assignedScope, scope)
continue
}
// Core -> Node
if nativeScope == schema.MetricScopeCore && scope == schema.MetricScopeNode {
cores, _ := topology.GetCoresFromHWThreads(topology.Node)
queries = append(queries, ApiQuery{
Metric: remoteName,
Hostname: hostname,
Aggregate: true,
Type: &coreString,
TypeIds: intToStringSlice(cores),
Resolution: resolution,
})
assignedScope = append(assignedScope, scope)
continue
}
// MemoryDomain -> MemoryDomain
if nativeScope == schema.MetricScopeMemoryDomain && scope == schema.MetricScopeMemoryDomain {
sockets, _ := topology.GetMemoryDomainsFromHWThreads(topology.Node)
queries = append(queries, ApiQuery{
Metric: remoteName,
Hostname: hostname,
Aggregate: false,
Type: &memoryDomainString,
TypeIds: intToStringSlice(sockets),
Resolution: resolution,
})
assignedScope = append(assignedScope, scope)
continue
}
// MemoryDoman -> Node
if nativeScope == schema.MetricScopeMemoryDomain && scope == schema.MetricScopeNode {
sockets, _ := topology.GetMemoryDomainsFromHWThreads(topology.Node)
queries = append(queries, ApiQuery{
Metric: remoteName,
Hostname: hostname,
Aggregate: true,
Type: &memoryDomainString,
TypeIds: intToStringSlice(sockets),
Resolution: resolution,
})
assignedScope = append(assignedScope, scope)
continue
}
// Socket -> Socket
if nativeScope == schema.MetricScopeSocket && scope == schema.MetricScopeSocket {
sockets, _ := topology.GetSocketsFromHWThreads(topology.Node)
queries = append(queries, ApiQuery{
Metric: remoteName,
Hostname: hostname,
Aggregate: false,
Type: &socketString,
TypeIds: intToStringSlice(sockets),
Resolution: resolution,
})
assignedScope = append(assignedScope, scope)
continue
}
// Socket -> Node
if nativeScope == schema.MetricScopeSocket && scope == schema.MetricScopeNode {
sockets, _ := topology.GetSocketsFromHWThreads(topology.Node)
queries = append(queries, ApiQuery{
Metric: remoteName,
Hostname: hostname,
Aggregate: true,
Type: &socketString,
TypeIds: intToStringSlice(sockets),
Resolution: resolution,
})
assignedScope = append(assignedScope, scope)
continue
}
// Node -> Node
if nativeScope == schema.MetricScopeNode && scope == schema.MetricScopeNode {
queries = append(queries, ApiQuery{
Metric: remoteName,
Hostname: hostname,
Resolution: resolution,
})
assignedScope = append(assignedScope, scope)
continue
}
return nil, nil, fmt.Errorf("METRICDATA/CCMS > TODO: unhandled case: native-scope=%s, requested-scope=%s", nativeScope, requestedScope)
}
}
}
return queries, assignedScope, nil
}
func intToStringSlice(is []int) []string {
ss := make([]string, len(is))
for i, x := range is {

View File

@ -13,6 +13,7 @@ import (
"strings"
"time"
"github.com/ClusterCockpit/cc-backend/internal/graph/model"
"github.com/ClusterCockpit/cc-backend/pkg/archive"
"github.com/ClusterCockpit/cc-backend/pkg/log"
"github.com/ClusterCockpit/cc-backend/pkg/schema"
@ -312,3 +313,21 @@ func (idb *InfluxDBv2DataRepository) LoadNodeData(
return nil, errors.New("METRICDATA/INFLUXV2 > unimplemented for InfluxDBv2DataRepository")
}
func (idb *InfluxDBv2DataRepository) LoadNodeListData(
cluster, subCluster, nodeFilter string,
metrics []string,
scopes []schema.MetricScope,
resolution int,
from, to time.Time,
page *model.PageRequest,
ctx context.Context,
) (map[string]schema.JobData, int, bool, error) {
var totalNodes int = 0
var hasNextPage bool = false
// TODO : Implement to be used in NodeList-View
log.Infof("LoadNodeListData unimplemented for InfluxDBv2DataRepository, Args: cluster %s, metrics %v, nodeFilter %v, scopes %v", cluster, metrics, nodeFilter, scopes)
return nil, totalNodes, hasNextPage, errors.New("METRICDATA/INFLUXV2 > unimplemented for InfluxDBv2DataRepository")
}

View File

@ -11,6 +11,7 @@ import (
"time"
"github.com/ClusterCockpit/cc-backend/internal/config"
"github.com/ClusterCockpit/cc-backend/internal/graph/model"
"github.com/ClusterCockpit/cc-backend/pkg/log"
"github.com/ClusterCockpit/cc-backend/pkg/schema"
)
@ -26,8 +27,11 @@ type MetricDataRepository interface {
// Return a map of metrics to a map of nodes to the metric statistics of the job. node scope assumed for now.
LoadStats(job *schema.Job, metrics []string, ctx context.Context) (map[string]map[string]schema.MetricStatistics, error)
// Return a map of hosts to a map of metrics at the requested scopes for that node.
// Return a map of hosts to a map of metrics at the requested scopes (currently only node) for that node.
LoadNodeData(cluster string, metrics, nodes []string, scopes []schema.MetricScope, from, to time.Time, ctx context.Context) (map[string]map[string][]*schema.JobMetric, error)
// Return a map of hosts to a map of metrics to a map of scopes for multiple nodes.
LoadNodeListData(cluster, subCluster, nodeFilter string, metrics []string, scopes []schema.MetricScope, resolution int, from, to time.Time, page *model.PageRequest, ctx context.Context) (map[string]schema.JobData, int, bool, error)
}
var metricDataRepos map[string]MetricDataRepository = map[string]MetricDataRepository{}

View File

@ -20,6 +20,7 @@ import (
"text/template"
"time"
"github.com/ClusterCockpit/cc-backend/internal/graph/model"
"github.com/ClusterCockpit/cc-backend/pkg/archive"
"github.com/ClusterCockpit/cc-backend/pkg/log"
"github.com/ClusterCockpit/cc-backend/pkg/schema"
@ -446,3 +447,21 @@ func (pdb *PrometheusDataRepository) LoadNodeData(
log.Debugf("LoadNodeData of %v nodes took %s", len(data), t1)
return data, nil
}
func (pdb *PrometheusDataRepository) LoadNodeListData(
cluster, subCluster, nodeFilter string,
metrics []string,
scopes []schema.MetricScope,
resolution int,
from, to time.Time,
page *model.PageRequest,
ctx context.Context,
) (map[string]schema.JobData, int, bool, error) {
var totalNodes int = 0
var hasNextPage bool = false
// TODO : Implement to be used in NodeList-View
log.Infof("LoadNodeListData unimplemented for PrometheusDataRepository, Args: cluster %s, metrics %v, nodeFilter %v, scopes %v", cluster, metrics, nodeFilter, scopes)
return nil, totalNodes, hasNextPage, errors.New("METRICDATA/INFLUXV2 > unimplemented for PrometheusDataRepository")
}

View File

@ -9,6 +9,7 @@ import (
"encoding/json"
"time"
"github.com/ClusterCockpit/cc-backend/internal/graph/model"
"github.com/ClusterCockpit/cc-backend/pkg/schema"
)
@ -50,6 +51,19 @@ func (tmdr *TestMetricDataRepository) LoadNodeData(
panic("TODO")
}
func (tmdr *TestMetricDataRepository) LoadNodeListData(
cluster, subCluster, nodeFilter string,
metrics []string,
scopes []schema.MetricScope,
resolution int,
from, to time.Time,
page *model.PageRequest,
ctx context.Context,
) (map[string]schema.JobData, int, bool, error) {
panic("TODO")
}
func DeepCopy(jd_temp schema.JobData) schema.JobData {
var jd schema.JobData

View File

@ -42,10 +42,12 @@ var routes []Route = []Route{
{"/monitoring/projects/", "monitoring/list.tmpl", "Projects - ClusterCockpit", true, func(i InfoType, r *http.Request) InfoType { i["listType"] = "PROJECT"; return i }},
{"/monitoring/tags/", "monitoring/taglist.tmpl", "Tags - ClusterCockpit", false, setupTaglistRoute},
{"/monitoring/user/{id}", "monitoring/user.tmpl", "User <ID> - ClusterCockpit", true, setupUserRoute},
{"/monitoring/systems/{cluster}", "monitoring/systems.tmpl", "Cluster <ID> - ClusterCockpit", false, setupClusterRoute},
{"/monitoring/systems/{cluster}", "monitoring/systems.tmpl", "Cluster <ID> Node Overview - ClusterCockpit", false, setupClusterOverviewRoute},
{"/monitoring/systems/list/{cluster}", "monitoring/systems.tmpl", "Cluster <ID> Node List - ClusterCockpit", false, setupClusterListRoute},
{"/monitoring/systems/list/{cluster}/{subcluster}", "monitoring/systems.tmpl", "Cluster <ID> <SID> Node List - ClusterCockpit", false, setupClusterListRoute},
{"/monitoring/node/{cluster}/{hostname}", "monitoring/node.tmpl", "Node <ID> - ClusterCockpit", false, setupNodeRoute},
{"/monitoring/analysis/{cluster}", "monitoring/analysis.tmpl", "Analysis - ClusterCockpit", true, setupAnalysisRoute},
{"/monitoring/status/{cluster}", "monitoring/status.tmpl", "Status of <ID> - ClusterCockpit", false, setupClusterRoute},
{"/monitoring/status/{cluster}", "monitoring/status.tmpl", "Status of <ID> - ClusterCockpit", false, setupClusterStatusRoute},
}
func setupHomeRoute(i InfoType, r *http.Request) InfoType {
@ -111,7 +113,7 @@ func setupUserRoute(i InfoType, r *http.Request) InfoType {
return i
}
func setupClusterRoute(i InfoType, r *http.Request) InfoType {
func setupClusterStatusRoute(i InfoType, r *http.Request) InfoType {
vars := mux.Vars(r)
i["id"] = vars["cluster"]
i["cluster"] = vars["cluster"]
@ -123,6 +125,36 @@ func setupClusterRoute(i InfoType, r *http.Request) InfoType {
return i
}
func setupClusterOverviewRoute(i InfoType, r *http.Request) InfoType {
vars := mux.Vars(r)
i["id"] = vars["cluster"]
i["cluster"] = vars["cluster"]
i["displayType"] = "OVERVIEW"
from, to := r.URL.Query().Get("from"), r.URL.Query().Get("to")
if from != "" || to != "" {
i["from"] = from
i["to"] = to
}
return i
}
func setupClusterListRoute(i InfoType, r *http.Request) InfoType {
vars := mux.Vars(r)
i["id"] = vars["cluster"]
i["cluster"] = vars["cluster"]
i["sid"] = vars["subcluster"]
i["subCluster"] = vars["subcluster"]
i["displayType"] = "LIST"
from, to := r.URL.Query().Get("from"), r.URL.Query().Get("to")
if from != "" || to != "" {
i["from"] = from
i["to"] = to
}
return i
}
func setupNodeRoute(i InfoType, r *http.Request) InfoType {
vars := mux.Vars(r)
i["cluster"] = vars["cluster"]
@ -343,6 +375,9 @@ func SetupRoutes(router *mux.Router, buildInfo web.Build) {
infos := route.Setup(map[string]interface{}{}, r)
if id, ok := infos["id"]; ok {
title = strings.Replace(route.Title, "<ID>", id.(string), 1)
if sid, ok := infos["sid"]; ok { // 2nd ID element
title = strings.Replace(title, "<SID>", sid.(string), 1)
}
}
// Get User -> What if NIL?

View File

@ -15,12 +15,12 @@ import (
var (
Clusters []*schema.Cluster
GlobalMetricList []*schema.GlobalMetricListItem
nodeLists map[string]map[string]NodeList
NodeLists map[string]map[string]NodeList
)
func initClusterConfig() error {
Clusters = []*schema.Cluster{}
nodeLists = map[string]map[string]NodeList{}
NodeLists = map[string]map[string]NodeList{}
metricLookup := make(map[string]schema.GlobalMetricListItem)
for _, c := range ar.GetClusters() {
@ -109,7 +109,7 @@ func initClusterConfig() error {
Clusters = append(Clusters, cluster)
nodeLists[cluster.Name] = make(map[string]NodeList)
NodeLists[cluster.Name] = make(map[string]NodeList)
for _, sc := range cluster.SubClusters {
if sc.Nodes == "*" {
continue
@ -119,7 +119,7 @@ func initClusterConfig() error {
if err != nil {
return fmt.Errorf("ARCHIVE/CLUSTERCONFIG > in %s/cluster.json: %w", cluster.Name, err)
}
nodeLists[cluster.Name][sc.Name] = nl
NodeLists[cluster.Name][sc.Name] = nl
}
}
@ -187,7 +187,7 @@ func AssignSubCluster(job *schema.BaseJob) error {
}
host0 := job.Resources[0].Hostname
for sc, nl := range nodeLists[job.Cluster] {
for sc, nl := range NodeLists[job.Cluster] {
if nl != nil && nl.Contains(host0) {
job.SubCluster = sc
return nil
@ -203,7 +203,7 @@ func AssignSubCluster(job *schema.BaseJob) error {
}
func GetSubClusterByNode(cluster, hostname string) (string, error) {
for sc, nl := range nodeLists[cluster] {
for sc, nl := range NodeLists[cluster] {
if nl != nil && nl.Contains(hostname) {
return sc, nil
}

View File

@ -194,7 +194,17 @@ func (topo *Topology) GetAcceleratorID(id int) (string, error) {
}
}
func (topo *Topology) GetAcceleratorIDs() ([]int, error) {
// Return list of hardware (string) accelerator IDs
func (topo *Topology) GetAcceleratorIDs() []string {
accels := make([]string, 0)
for _, accel := range topo.Accelerators {
accels = append(accels, accel.ID)
}
return accels
}
// Outdated? Or: Return indices of accelerators in parent array?
func (topo *Topology) GetAcceleratorIDsAsInt() ([]int, error) {
accels := make([]int, 0)
for _, accel := range topo.Accelerators {
id, err := strconv.Atoi(accel.ID)

View File

@ -1,12 +1,12 @@
{
"name": "cc-frontend",
"version": "1.0.2",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "cc-frontend",
"version": "1.0.2",
"version": "1.0.0",
"license": "MIT",
"dependencies": {
"@rollup/plugin-replace": "^5.0.7",

View File

@ -522,7 +522,6 @@
<Col>
<PlotGrid
let:item
renderFor="analysis"
items={metricsInHistograms.map((metric) => ({
metric,
...binsFromFootprint(
@ -566,7 +565,6 @@
<PlotGrid
let:item
let:width
renderFor="analysis"
items={metricsInScatterplots.map(([m1, m2]) => ({
m1,
f1: $footprintsQuery.data.footprints.metrics.find(

View File

@ -3,6 +3,7 @@
Properties:
- `ìsAdmin Bool!`: Is currently logged in user admin authority
- `isSupport Bool!`: Is currently logged in user support authority
- `isApi Bool!`: Is currently logged in user api authority
- `username String!`: Empty string if auth. is disabled, otherwise the username as string
-->
@ -10,15 +11,17 @@
<script>
import { Card, CardHeader, CardTitle } from "@sveltestrap/sveltestrap";
import UserSettings from "./config/UserSettings.svelte";
import SupportSettings from "./config/SupportSettings.svelte";
import AdminSettings from "./config/AdminSettings.svelte";
export let isAdmin;
export let isSupport;
export let isApi;
export let username;
export let ncontent;
</script>
{#if isAdmin == true}
{#if isAdmin}
<Card style="margin-bottom: 1.5em;">
<CardHeader>
<CardTitle class="mb-1">Admin Options</CardTitle>
@ -27,6 +30,15 @@
</Card>
{/if}
{#if isSupport || isAdmin}
<Card style="margin-bottom: 1.5em;">
<CardHeader>
<CardTitle class="mb-1">Support Options</CardTitle>
</CardHeader>
<SupportSettings/>
</Card>
{/if}
<Card>
<CardHeader>
<CardTitle class="mb-1">User Options</CardTitle>

View File

@ -26,6 +26,7 @@
export let username;
export let authlevel;
export let clusters;
export let subClusters;
export let roles;
let isOpen = false;
@ -93,10 +94,19 @@
},
{
title: "Nodes",
requiredRole: roles.admin,
requiredRole: roles.support,
href: "/monitoring/systems/",
icon: "hdd-rack",
perCluster: true,
listOptions: true,
menu: "Info",
},
{
title: "Analysis",
requiredRole: roles.support,
href: "/monitoring/analysis/",
icon: "graph-up",
perCluster: true,
listOptions: false,
menu: "Info",
},
@ -109,15 +119,6 @@
listOptions: false,
menu: "Info",
},
{
title: "Analysis",
requiredRole: roles.support,
href: "/monitoring/analysis/",
icon: "graph-up",
perCluster: true,
listOptions: false,
menu: "Info",
},
];
</script>
@ -138,11 +139,13 @@
{#if screenSize > 1500 || screenSize < 768}
<NavbarLinks
{clusters}
{subClusters}
links={views.filter((item) => item.requiredRole <= authlevel)}
/>
{:else if screenSize > 1300}
<NavbarLinks
{clusters}
{subClusters}
links={views.filter(
(item) => item.requiredRole <= authlevel && item.menu != "Info",
)}
@ -156,6 +159,7 @@
<DropdownMenu class="dropdown-menu-lg-end">
<NavbarLinks
{clusters}
{subClusters}
direction="right"
links={views.filter(
(item) =>
@ -168,6 +172,7 @@
{:else}
<NavbarLinks
{clusters}
{subClusters}
links={views.filter(
(item) => item.requiredRole <= authlevel && item.menu == "none",
)}
@ -180,6 +185,7 @@
<DropdownMenu class="dropdown-menu-lg-end">
<NavbarLinks
{clusters}
{subClusters}
direction="right"
links={views.filter(
(item) => item.requiredRole <= authlevel && item.menu == 'Jobs',
@ -196,6 +202,7 @@
<DropdownMenu class="dropdown-menu-lg-end">
<NavbarLinks
{clusters}
{subClusters}
direction="right"
links={views.filter(
(item) => item.requiredRole <= authlevel && item.menu == 'Groups',
@ -212,6 +219,7 @@
<DropdownMenu class="dropdown-menu-lg-end">
<NavbarLinks
{clusters}
{subClusters}
direction="right"
links={views.filter(
(item) => item.requiredRole <= authlevel && item.menu == 'Info',

View File

@ -348,7 +348,6 @@
{:else if $initq?.data && $jobMetrics?.data?.jobMetrics}
<PlotGrid
let:item
renderFor="job"
items={orderAndMap(
groupByScope($jobMetrics.data.jobMetrics),
selectedMetrics,

View File

@ -44,7 +44,7 @@
if (from == null || to == null) {
to = new Date(Date.now());
from = new Date(to.getTime());
from.setHours(from.getHours() - 12);
from.setHours(from.getHours() - 4);
}
const initialized = getContext("initialized")
@ -141,7 +141,7 @@
<InputGroup>
<InputGroupText><Icon name="hdd" /></InputGroupText>
<InputGroupText>Selected Node</InputGroupText>
<Input style="background-color: white;"type="text" value="{hostname} ({cluster})" disabled/>
<Input style="background-color: white;"type="text" value="{hostname} [{cluster} ({$nodeMetricsData?.data ? $nodeMetricsData.data.nodeMetrics[0].subCluster : ''})]" disabled/>
</InputGroup>
</Col>
<!-- Time Col -->
@ -153,18 +153,20 @@
{#if $nodeJobsData.fetching}
<Spinner />
{:else if $nodeJobsData.data}
<InputGroup>
<InputGroupText><Icon name="activity" /></InputGroupText>
<InputGroupText>Activity</InputGroupText>
<Input style="background-color: white;"type="text" value="{$nodeJobsData.data.jobs.count} Jobs" disabled/>
<a title="Show jobs running on this node" href="/monitoring/jobs/?cluster={cluster}&state=running&node={hostname}" target="_blank" class="btn btn-outline-secondary" role="button" aria-disabled="true">
<Icon name="view-list" /> Show List
</a>
</InputGroup>
<InputGroup>
<InputGroupText><Icon name="activity" /></InputGroupText>
<InputGroupText>Activity</InputGroupText>
<Input style="background-color: white;" type="text" value="{$nodeJobsData.data.jobs.count} Jobs" disabled/>
<a title="Show jobs running on this node" href="/monitoring/jobs/?cluster={cluster}&state=running&node={hostname}" target="_blank" class="btn btn-outline-secondary" role="button" aria-disabled="true">
<Icon name="view-list" /> Show List
</a>
</InputGroup>
{:else}
<Input type="text" disabled>
No currently running jobs.
</Input>
<InputGroup>
<InputGroupText><Icon name="activity" /></InputGroupText>
<InputGroupText>Activity</InputGroupText>
<Input type="text" value="No running jobs." disabled />
</InputGroup>
{/if}
</Col>
<!-- Refresh Col-->
@ -189,7 +191,6 @@
{:else}
<PlotGrid
let:item
renderFor="node"
itemsPerRow={ccconfig.plot_view_plotsPerRow}
items={$nodeMetricsData.data.nodeMetrics[0].metrics
.map((m) => ({

View File

@ -655,7 +655,6 @@
{#key $mainQuery.data.stats[0].histMetrics}
<PlotGrid
let:item
renderFor="user"
items={$mainQuery.data.stats[0].histMetrics}
itemsPerRow={2}
>

View File

@ -1,7 +1,8 @@
<!--
@component Main cluster metric status view component; renders current state of metrics / nodes
@component Main cluster node status view component; renders overview or list depending on type
Properties:
- `displayType String?`: The type of node display ['OVERVIEW' || 'LIST']
- `cluster String`: The cluster to show status information for
- `from Date?`: Custom Time Range selection 'from' [Default: null]
- `to Date?`: Custom Time Range selection 'to' [Default: null]
@ -12,33 +13,34 @@
import {
Row,
Col,
Card,
Input,
InputGroup,
InputGroupText,
Icon,
Spinner,
Card,
Button,
} from "@sveltestrap/sveltestrap";
import {
queryStore,
gql,
getContextClient,
} from "@urql/svelte";
import {
init,
checkMetricDisabled,
} from "./generic/utils.js";
import PlotGrid from "./generic/PlotGrid.svelte";
import MetricPlot from "./generic/plots/MetricPlot.svelte";
import { init } from "./generic/utils.js";
import NodeOverview from "./systems/NodeOverview.svelte";
import NodeList from "./systems/NodeList.svelte";
import MetricSelection from "./generic/select/MetricSelection.svelte";
import TimeSelection from "./generic/select/TimeSelection.svelte";
import Refresher from "./generic/helper/Refresher.svelte";
export let displayType;
export let cluster;
export let subCluster = "";
export let from = null;
export let to = null;
const { query: initq } = init();
console.assert(
displayType == "OVERVIEW" || displayType == "LIST",
"Invalid nodes displayType provided!",
);
if (from == null || to == null) {
to = new Date(Date.now());
from = new Date(to.getTime());
@ -47,57 +49,28 @@
const initialized = getContext("initialized");
const ccconfig = getContext("cc-config");
const clusters = getContext("clusters");
const globalMetrics = getContext("globalMetrics");
const displayNodeOverview = (displayType === 'OVERVIEW')
const resampleConfig = getContext("resampling") || null;
const resampleResolutions = resampleConfig ? [...resampleConfig.resolutions] : [];
const resampleDefault = resampleConfig ? Math.max(...resampleConfig.resolutions) : 0;
let selectedResolution = resampleConfig ? resampleDefault : 0;
let hostnameFilter = "";
let selectedMetric = ccconfig.system_view_selectedMetric;
let pendingHostnameFilter = "";
let selectedMetric = ccconfig.system_view_selectedMetric || "";
let selectedMetrics = ccconfig[`node_list_selectedMetrics:${cluster}`] || [ccconfig.system_view_selectedMetric];
let isMetricsSelectionOpen = false;
const client = getContextClient();
$: nodesQuery = queryStore({
client: client,
query: gql`
query ($cluster: String!, $metrics: [String!], $from: Time!, $to: Time!) {
nodeMetrics(
cluster: $cluster
metrics: $metrics
from: $from
to: $to
) {
host
subCluster
metrics {
name
scope
metric {
timestep
unit {
base
prefix
}
series {
statistics {
min
avg
max
}
data
}
}
}
}
}
`,
variables: {
cluster: cluster,
metrics: [selectedMetric],
from: from.toISOString(),
to: to.toISOString(),
},
});
/*
Note 1: "Sorting" as use-case ignored for now, probably default to alphanumerical on hostnames of cluster (handled in frontend at the moment)
Note 2: Add Idle State Filter (== No allocated Jobs) [Frontend?] : Cannot be handled by CCMS, requires secondary job query and refiltering of visible nodes
*/
let systemMetrics = [];
let systemUnits = {};
function loadMetrics(isInitialized) {
if (!isInitialized) return
systemMetrics = [...globalMetrics.filter((gm) => gm?.availability.find((av) => av.cluster == cluster))]
@ -108,23 +81,60 @@
$: loadMetrics($initialized)
$: if (displayNodeOverview) {
selectedMetrics = [selectedMetric]
}
$: { // Wait after input for some time to prevent too many requests
setTimeout(function () {
hostnameFilter = pendingHostnameFilter;
}, 500);
}
</script>
<Row cols={{ xs: 2, lg: 4 }}>
{#if $initq.error}
<Card body color="danger">{$initq.error.message}</Card>
{:else if $initq.fetching}
<Spinner />
{:else}
<!-- ROW1: Tools-->
<Row cols={{ xs: 2, lg: !displayNodeOverview ? (resampleConfig ? 5 : 4) : 4 }} class="mb-3">
{#if $initq.data}
<!-- List Metric Select Col-->
{#if !displayNodeOverview}
<Col>
<InputGroup>
<InputGroupText><Icon name="graph-up" /></InputGroupText>
<InputGroupText class="text-capitalize">Metrics</InputGroupText>
<Button
outline
color="primary"
on:click={() => (isMetricsSelectionOpen = true)}
>
{selectedMetrics.length} selected
</Button>
</InputGroup>
</Col>
{#if resampleConfig}
<Col>
<InputGroup>
<InputGroupText><Icon name="plus-slash-minus" /></InputGroupText>
<InputGroupText>Resolution</InputGroupText>
<Input type="select" bind:value={selectedResolution}>
{#each resampleResolutions as res}
<option value={res}
>{res} sec</option
>
{/each}
</Input>
</InputGroup>
</Col>
{/if}
{/if}
<!-- Node Col-->
<Col>
<Col class="mt-2 mt-lg-0">
<InputGroup>
<InputGroupText><Icon name="hdd" /></InputGroupText>
<InputGroupText>Find Node</InputGroupText>
<InputGroupText>Find Node(s)</InputGroupText>
<Input
placeholder="hostname..."
placeholder="Filter hostname ..."
type="text"
bind:value={hostnameFilter}
bind:value={pendingHostnameFilter}
/>
</InputGroup>
</Col>
@ -132,20 +142,22 @@
<Col>
<TimeSelection bind:from bind:to />
</Col>
<!-- Metric Col-->
<Col class="mt-2 mt-lg-0">
<InputGroup>
<InputGroupText><Icon name="graph-up" /></InputGroupText>
<InputGroupText>Metric</InputGroupText>
<select class="form-select" bind:value={selectedMetric}>
{#each systemMetrics as metric}
<option value={metric.name}
>{metric.name} {systemUnits[metric.name] ? "("+systemUnits[metric.name]+")" : ""}</option
>
{/each}
</select>
</InputGroup>
</Col>
<!-- Overview Metric Col-->
{#if displayNodeOverview}
<Col class="mt-2 mt-lg-0">
<InputGroup>
<InputGroupText><Icon name="graph-up" /></InputGroupText>
<InputGroupText>Metric</InputGroupText>
<Input type="select" bind:value={selectedMetric}>
{#each systemMetrics as metric}
<option value={metric.name}
>{metric.name} {systemUnits[metric.name] ? "("+systemUnits[metric.name]+")" : ""}</option
>
{/each}
</Input>
</InputGroup>
</Col>
{/if}
<!-- Refresh Col-->
<Col class="mt-2 mt-lg-0">
<Refresher
@ -158,75 +170,30 @@
</Col>
{/if}
</Row>
<br />
{#if $nodesQuery.error}
<!-- ROW2: Content-->
{#if displayType !== "OVERVIEW" && displayType !== "LIST"}
<Row>
<Col>
<Card body color="danger">{$nodesQuery.error.message}</Card>
</Col>
</Row>
{:else if $nodesQuery.fetching || $initq.fetching}
<Row>
<Col>
<Spinner />
<Card body color="danger">Unknown displayList type! </Card>
</Col>
</Row>
{:else}
<PlotGrid
let:item
renderFor="systems"
itemsPerRow={ccconfig.plot_view_plotsPerRow}
items={$nodesQuery.data.nodeMetrics
.filter(
(h) =>
h.host.includes(hostnameFilter) &&
h.metrics.some(
(m) => m.name == selectedMetric && m.scope == "node",
),
)
.map((h) => ({
host: h.host,
subCluster: h.subCluster,
data: h.metrics.find(
(m) => m.name == selectedMetric && m.scope == "node",
),
disabled: checkMetricDisabled(
selectedMetric,
cluster,
h.subCluster,
),
}))
.sort((a, b) => a.host.localeCompare(b.host))}
>
<h4 style="width: 100%; text-align: center;">
<a
style="display: block;padding-top: 15px;"
href="/monitoring/node/{cluster}/{item.host}"
>{item.host} ({item.subCluster})</a
>
</h4>
{#if item.disabled === false && item.data}
<MetricPlot
timestep={item.data.metric.timestep}
series={item.data.metric.series}
metric={item.data.name}
cluster={clusters.find((c) => c.name == cluster)}
subCluster={item.subCluster}
forNode={true}
/>
{:else if item.disabled === true && item.data}
<Card style="margin-left: 2rem;margin-right: 2rem;" body color="info"
>Metric disabled for subcluster <code
>{selectedMetric}:{item.subCluster}</code
></Card
>
{:else}
<Card
style="margin-left: 2rem;margin-right: 2rem;"
body
color="warning"
>No dataset returned for <code>{selectedMetric}</code></Card
>
{/if}
</PlotGrid>
{#if displayNodeOverview}
<!-- ROW2-1: Node Overview (Grid Included)-->
<NodeOverview {cluster} {subCluster} {ccconfig} {selectedMetrics} {from} {to} {hostnameFilter}/>
{:else}
<!-- ROW2-2: Node List (Grid Included)-->
<NodeList {cluster} {subCluster} {ccconfig} {selectedMetrics} {selectedResolution} {hostnameFilter} {from} {to} {systemUnits}/>
{/if}
{/if}
<MetricSelection
{cluster}
configName="node_list_selectedMetrics"
metrics={selectedMetrics}
bind:isOpen={isMetricsSelectionOpen}
on:update-metrics={({ detail }) => {
selectedMetrics = [...detail]
}}
/>

View File

@ -308,7 +308,6 @@
{#key $stats.data.jobsStatistics[0].histMetrics}
<PlotGrid
let:item
renderFor="user"
items={$stats.data.jobsStatistics[0].histMetrics}
itemsPerRow={3}
>

View File

@ -5,6 +5,7 @@ new Config({
target: document.getElementById('svelte-app'),
props: {
isAdmin: isAdmin,
isSupport: isSupport,
isApi: isApi,
username: username,
ncontent: ncontent,

View File

@ -4,7 +4,7 @@
<script>
import { Row, Col } from "@sveltestrap/sveltestrap";
import { onMount } from "svelte";
import { onMount, getContext } from "svelte";
import EditRole from "./admin/EditRole.svelte";
import EditProject from "./admin/EditProject.svelte";
import AddUser from "./admin/AddUser.svelte";
@ -17,6 +17,8 @@
let users = [];
let roles = [];
const ccconfig = getContext("cc-config");
function getUserList() {
fetch("/config/users/?via-ldap=false&not-just-user=true")
.then((res) => res.json())
@ -54,6 +56,6 @@
<Col>
<EditProject on:reload={getUserList} />
</Col>
<Options />
<Options config={ccconfig}/>
<NoticeEdit {ncontent}/>
</Row>

View File

@ -0,0 +1,13 @@
<!--
@component Support settings wrapper
Properties: None
-->
<script>
import { getContext } from "svelte";
import SupportOptions from "./support/SupportOptions.svelte";
const ccconfig = getContext("cc-config");
</script>
<SupportOptions config={ccconfig}/>

View File

@ -45,7 +45,7 @@
<Col>
<Card class="h-100">
<CardBody>
<CardTitle class="mb-3">Metric Plot Resampling</CardTitle>
<CardTitle class="mb-3">Metric Plot Resampling Info</CardTitle>
<p>Triggered at {resampleConfig.trigger} datapoints.</p>
<p>Configured resolutions: {resampleConfig.resolutions}</p>
</CardBody>

View File

@ -0,0 +1,89 @@
<!--
@component Support option select card
-->
<script>
import { Row, Col, Card, CardTitle, Button} from "@sveltestrap/sveltestrap";
import { fade } from "svelte/transition";
export let config;
let message;
let displayMessage;
async function handleSettingSubmit(selector, target) {
let form = document.querySelector(selector);
let formData = new FormData(form);
try {
const res = await fetch(form.action, { method: "POST", body: formData });
if (res.ok) {
let text = await res.text();
popMessage(text, target, "#048109");
} else {
let text = await res.text();
throw new Error("Response Code " + res.status + "-> " + text);
}
} catch (err) {
popMessage(err, target, "#d63384");
}
return false;
}
function popMessage(response, restarget, rescolor) {
message = { msg: response, target: restarget, color: rescolor };
displayMessage = true;
setTimeout(function () {
displayMessage = false;
}, 3500);
}
</script>
<Row cols={1} class="p-2 g-2">
<Col>
<Card class="h-100">
<form
id="node-paging-form"
method="post"
action="/frontend/configuration/"
class="card-body"
on:submit|preventDefault={() =>
handleSettingSubmit("#node-paging-form", "npag")}
>
<!-- Svelte 'class' directive only on DOMs directly, normal 'class="xxx"' does not work, so style-array it is. -->
<CardTitle
style="margin-bottom: 1em; display: flex; align-items: center;"
>
<div>Node List Paging Type</div>
{#if displayMessage && message.target == "npag"}<div
style="margin-left: auto; font-size: 0.9em;"
>
<code style="color: {message.color};" out:fade
>Update: {message.msg}</code
>
</div>{/if}
</CardTitle>
<input type="hidden" name="key" value="node_list_usePaging" />
<div class="mb-3">
<div>
{#if config?.node_list_usePaging}
<input type="radio" id="nodes-true-checked" name="value" value="true" checked />
{:else}
<input type="radio" id="nodes-true" name="value" value="true" />
{/if}
<label for="true">Paging with selectable count of nodes.</label>
</div>
<div>
{#if config?.node_list_usePaging}
<input type="radio" id="nodes-false" name="value" value="false" />
{:else}
<input type="radio" id="nodes-false-checked" name="value" value="false" checked />
{/if}
<label for="false">Continuous scroll iteratively adding 10 nodes.</label>
</div>
</div>
<Button color="primary" type="submit">Submit</Button>
</form>
</Card>
</Col>
</Row>

View File

@ -74,7 +74,7 @@
<CardTitle
style="margin-bottom: 1em; display: flex; align-items: center;"
>
<div>Paging Type</div>
<div>Job List Paging Type</div>
{#if displayMessage && message.target == "pag"}<div
style="margin-left: auto; font-size: 0.9em;"
>

View File

@ -4,7 +4,6 @@
Properties:
- `itemsPerRow Number`: Elements to render per row
- `items [Any]`: List of plot components to render
- `renderFor String`: If 'job', filter disabled metrics
-->
<script>
@ -15,43 +14,13 @@
export let itemsPerRow
export let items
export let renderFor
let rows = [];
const isPlaceholder = x => x._is_placeholder === true;
function tile(items, itemsPerRow) {
const rows = []
for (let ri = 0; ri < items.length; ri += itemsPerRow) {
const row = []
for (let ci = 0; ci < itemsPerRow; ci += 1) {
if (ri + ci < items.length)
row.push(items[ri + ci])
else
row.push({ _is_placeholder: true, ri, ci })
}
rows.push(row)
}
return rows
}
$: if (renderFor === 'job') {
rows = tile(items.filter(item => item.disabled === false), itemsPerRow)
} else {
rows = tile(items, itemsPerRow)
}
</script>
{#each rows as row}
<Row cols={{ xs: 1, sm: 1, md: 2, lg: itemsPerRow}}>
{#each row as item (item)}
<Col class="px-1">
{#if !isPlaceholder(item)}
<slot item={item}/>
{/if}
</Col>
{/each}
</Row>
{/each}
<Row cols={{ xs: 1, sm: 2, md: 3, lg: itemsPerRow}}>
{#each items as item}
<Col class="px-1">
<slot {item}/>
</Col>
{/each}
</Row>

View File

@ -9,12 +9,12 @@
- `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: null]
- `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 GraphQL.Cluster`: Cluster Object of the parent job
- `cluster String`: Cluster name of the parent job / data
- `subCluster String`: Name of the subCluster of the parent job
- `isShared Bool?`: If this job used shared resources; will adapt threshold indicators accordingly [Default: false]
- `forNode Bool?`: If this plot is used for node data display; will ren[data, err := metricdata.LoadNodeData(cluster, metrics, nodes, scopes, from, to, ctx)](https://github.com/ClusterCockpit/cc-backend/blob/9fe7cdca9215220a19930779a60c8afc910276a3/internal/graph/schema.resolvers.go#L391-L392)der x-axis as negative time with $now as maximum [Default: false]
- `forNode Bool?`: If this plot is used for node data display; will render x-axis as negative time with $now as maximum [Default: false]
- `numhwthreads Number?`: Number of job HWThreads [Default: 0]
- `numaccs Number?`: Number of job Accelerators [Default: 0]
- `zoomState Object?`: The last zoom state to preserve on user zoom [Default: null]
@ -124,13 +124,13 @@
export let metric;
export let scope = "node";
export let width = null;
export let width = 0;
export let height = 300;
export let timestep;
export let series;
export let useStatsSeries = null;
export let useStatsSeries = false;
export let statisticsSeries = null;
export let cluster;
export let cluster = "";
export let subCluster;
export let isShared = false;
export let forNode = false;
@ -138,11 +138,11 @@
export let numaccs = 0;
export let zoomState = null;
export let thresholdState = null;
export let extendedLegendData = null;
if (useStatsSeries == null) useStatsSeries = statisticsSeries != null;
if (useStatsSeries == false && series == null) useStatsSeries = true;
if (!useStatsSeries && statisticsSeries != null) useStatsSeries = true;
const usesMeanStatsSeries = (useStatsSeries && statisticsSeries.mean.length != 0)
const usesMeanStatsSeries = (statisticsSeries?.mean && statisticsSeries.mean.length != 0)
const dispatch = createEventDispatcher();
const subClusterTopology = getContext("getHardwareTopology")(cluster, subCluster);
const metricConfig = getContext("getMetricConfig")(cluster, subCluster, metric);
@ -194,6 +194,7 @@
className && legendEl.classList.add(className);
uPlot.assign(legendEl.style, {
minWidth: extendedLegendData ? "300px" : "100px",
textAlign: "left",
pointerEvents: "none",
display: "none",
@ -207,11 +208,10 @@
// conditional hide series color markers:
if (
useStatsSeries === true || // Min/Max/Median Self-Explanatory
useStatsSeries || // Min/Max/Median Self-Explanatory
dataSize === 1 || // Only one Y-Dataseries
dataSize > 6
dataSize > 8 // More than 8 Y-Dataseries
) {
// More than 6 Y-Dataseries
const idents = legendEl.querySelectorAll(".u-marker");
for (let i = 0; i < idents.length; i++)
idents[i].style.display = "none";
@ -237,12 +237,12 @@
function update(u) {
const { left, top } = u.cursor;
const width = u.over.querySelector(".u-legend").offsetWidth;
const width = u?.over?.querySelector(".u-legend")?.offsetWidth ? u.over.querySelector(".u-legend").offsetWidth : 0;
legendEl.style.transform =
"translate(" + (left - width - 15) + "px, " + (top + 15) + "px)";
}
if (dataSize <= 12 || useStatsSeries === true) {
if (dataSize <= 12 || useStatsSeries) {
return {
hooks: {
init: init,
@ -311,13 +311,6 @@
}
}
const plotSeries = [
{
label: "Runtime",
value: (u, ts, sidx, didx) =>
didx == null ? null : formatTime(ts, forNode),
},
];
const plotData = [new Array(longestSeries)];
if (forNode === true) {
// Negative Timestamp Buildup
@ -334,6 +327,15 @@
plotData[0][j] = j * timestep;
}
const plotSeries = [
// Note: X-Legend Will not be shown as soon as Y-Axis are in extendedMode
{
label: "Runtime",
value: (u, ts, sidx, didx) =>
(didx == null) ? null : formatTime(ts, forNode),
}
];
let plotBands = undefined;
if (useStatsSeries) {
plotData.push(statisticsSeries.min);
@ -370,15 +372,60 @@
} else {
for (let i = 0; i < series.length; i++) {
plotData.push(series[i].data);
plotSeries.push({
label:
scope === "node"
// Default
if (!extendedLegendData) {
plotSeries.push({
label:
scope === "node"
? series[i].hostname
: scope + " #" + (i + 1),
scale: "y",
width: lineWidth,
stroke: lineColor(i, series.length),
});
: scope === "accelerator"
? 'Acc #' + (i + 1) // series[i].id.slice(9, 14) | Too Hardware Specific
: scope + " #" + (i + 1),
scale: "y",
width: lineWidth,
stroke: lineColor(i, series.length),
});
}
// Extended Legend For NodeList
else {
plotSeries.push({
label:
scope === "node"
? series[i].hostname
: scope === "accelerator"
? 'Acc #' + (i + 1) // series[i].id.slice(9, 14) | Too Hardware Specific
: scope + " #" + (i + 1),
scale: "y",
width: lineWidth,
stroke: lineColor(i, series.length),
values: (u, sidx, idx) => {
// "i" = "sidx - 1" : sidx contains x-axis-data
if (idx == null)
return {
time: '-',
value: '-',
user: '-',
job: '-'
};
if (series[i].id in extendedLegendData) {
return {
time: formatTime(plotData[0][idx], forNode),
value: plotData[sidx][idx],
user: extendedLegendData[series[i].id].user,
job: extendedLegendData[series[i].id].job,
};
} else {
return {
time: formatTime(plotData[0][idx], forNode),
value: plotData[sidx][idx],
user: '-',
job: '-',
};
}
}
});
}
}
}
@ -434,13 +481,13 @@
u.ctx.save();
u.ctx.textAlign = "start"; // 'end'
u.ctx.fillStyle = "black";
u.ctx.fillText(textl, u.bbox.left + 10, u.bbox.top + 10);
u.ctx.fillText(textl, u.bbox.left + 10, u.bbox.top + (forNode ? 0 : 10));
u.ctx.textAlign = "end";
u.ctx.fillStyle = "black";
u.ctx.fillText(
textr,
u.bbox.left + u.bbox.width - 10,
u.bbox.top + 10,
u.bbox.top + (forNode ? 0 : 10),
);
// u.ctx.fillText(text, u.bbox.left + u.bbox.width - 10, u.bbox.top + u.bbox.height - 10) // Recipe for bottom right
@ -498,10 +545,12 @@
},
legend: {
// Display legend until max 12 Y-dataseries
show: series.length <= 12 || useStatsSeries === true ? true : false,
live: series.length <= 12 || useStatsSeries === true ? true : false,
show: series.length <= 12 || useStatsSeries,
live: series.length <= 12 || useStatsSeries,
},
cursor: { drag: { x: true, y: true } },
cursor: {
drag: { x: true, y: true },
}
};
// RENDER HANDLING
@ -537,17 +586,9 @@
}
onMount(() => {
// Setup Wrapper
if (series[0].data.length > 0) {
if (forNode) {
plotWrapper.style.paddingTop = "0.5rem"
plotWrapper.style.paddingBottom = "0.5rem"
}
plotWrapper.style.backgroundColor = backgroundColor();
plotWrapper.style.borderRadius = "5px";
if (plotWrapper) {
render(width, height);
}
// Init Plot
render(width, height);
});
onDestroy(() => {
@ -555,22 +596,20 @@
if (uplot) uplot.destroy();
});
// This updates it on all size changes
// Condition for reactive triggering (eg scope change)
$: if (series[0].data.length > 0) {
// This updates plot on all size changes if wrapper (== data) exists
$: if (plotWrapper) {
onSizeChange(width, height);
}
</script>
<!-- Define Wrapper and NoData Card within $width -->
<div bind:clientWidth={width}>
{#if series[0].data.length > 0}
<div bind:this={plotWrapper}/>
{:else}
<Card class="mx-4" body color="warning"
>Cannot render plot: No series data returned for <code>{metric}</code></Card
>
{/if}
</div>
<!-- Define $width Wrapper and NoData Card -->
{#if series[0]?.data && series[0].data.length > 0}
<div bind:this={plotWrapper} bind:clientWidth={width}
style="background-color: {backgroundColor()};" class={forNode ? 'py-2 rounded' : 'rounded'}
/>
{:else}
<Card body color="warning" class="mx-4"
>Cannot render plot: No series data returned for <code>{metric}</code></Card
>
{/if}

View File

@ -12,7 +12,7 @@
-->
<script>
import { getContext } from "svelte";
import { getContext, createEventDispatcher } from "svelte";
import {
Modal,
ModalBody,
@ -33,6 +33,7 @@
const onInit = getContext("on-init")
const globalMetrics = getContext("globalMetrics")
const dispatch = createEventDispatcher();
let newMetricsOrder = [];
let unorderedMetrics = [...metrics];
@ -128,6 +129,8 @@
throw res.error;
}
});
dispatch('update-metrics', metrics);
}
</script>
@ -175,6 +178,7 @@
</ModalBody>
<ModalFooter>
<Button color="primary" on:click={closeAndApply}>Close & Apply</Button>
<Button color="secondary" on:click={() => (isOpen = !isOpen)}>Cancel</Button>
</ModalFooter>
</Modal>

View File

@ -304,8 +304,19 @@ export function stickyHeader(datatableHeaderSelector, updatePading) {
export function checkMetricDisabled(m, c, s) { // [m]etric, [c]luster, [s]ubcluster
const metrics = getContext("globalMetrics");
const result = metrics?.find((gm) => gm.name === m)?.availability?.find((av) => av.cluster === c)?.subClusters?.includes(s)
return !result
const available = metrics?.find((gm) => gm.name === m)?.availability?.find((av) => av.cluster === c)?.subClusters?.includes(s)
// Return inverse logic
return !available
}
export function checkMetricsDisabled(ma, c, s) { // [m]etric[a]rray, [c]luster, [s]ubcluster
let result = {};
const metrics = getContext("globalMetrics");
ma.forEach((m) => {
// Return named inverse logic: !available
result[m] = !(metrics?.find((gm) => gm.name === m)?.availability?.find((av) => av.cluster === c)?.subClusters?.includes(s))
});
return result
}
export function getStatsItems(presetStats = []) {

View File

@ -3,6 +3,7 @@
Properties:
- `clusters [String]`: List of cluster names
- `subClusters map[String][]string`: Map of subclusters by cluster names
- `links [Object]`: Pre-filtered link objects based on user auth
- `direction String?`: The direcion of the drop-down menue [default: down]
-->
@ -18,45 +19,83 @@
} from "@sveltestrap/sveltestrap";
export let clusters;
export let subClusters;
export let links;
export let direction = "down";
</script>
{#each links as item}
{#if item.listOptions}
<Dropdown nav inNavbar {direction}>
<DropdownToggle nav caret>
<Icon name={item.icon} />
{item.title}
</DropdownToggle>
<DropdownMenu class="dropdown-menu-lg-end">
<DropdownItem
href={item.href}
>
All Clusters
</DropdownItem>
<DropdownItem divider />
{#each clusters as cluster}
<Dropdown nav direction="right">
<DropdownToggle nav caret class="dropdown-item py-1 px-2">
{cluster.name}
</DropdownToggle>
<DropdownMenu>
<DropdownItem class="py-1 px-2"
href={item.href + '?cluster=' + cluster.name}
>
All Jobs
</DropdownItem>
<DropdownItem class="py-1 px-2"
href={item.href + '?cluster=' + cluster.name + '&state=running'}
>
Running Jobs
</DropdownItem>
</DropdownMenu>
</Dropdown>
{/each}
</DropdownMenu>
</Dropdown>
{#if item.title === 'Nodes'}
<Dropdown nav inNavbar {direction}>
<DropdownToggle nav caret>
<Icon name={item.icon} />
{item.title}
</DropdownToggle>
<DropdownMenu class="dropdown-menu-lg-end">
{#each clusters as cluster}
<Dropdown nav direction="right">
<DropdownToggle nav caret class="dropdown-item py-1 px-2">
{cluster.name}
</DropdownToggle>
<DropdownMenu>
<DropdownItem class="py-1 px-2"
href={item.href + cluster.name}
>
Node Overview
</DropdownItem>
<DropdownItem class="py-1 px-2"
href={item.href + 'list/' + cluster.name}
>
Node List
</DropdownItem>
{#each subClusters[cluster.name] as subCluster}
<DropdownItem class="py-1 px-2"
href={item.href + 'list/' + cluster.name + '/' + subCluster}
>
{subCluster} Node List
</DropdownItem>
{/each}
</DropdownMenu>
</Dropdown>
{/each}
</DropdownMenu>
</Dropdown>
{:else}
<Dropdown nav inNavbar {direction}>
<DropdownToggle nav caret>
<Icon name={item.icon} />
{item.title}
</DropdownToggle>
<DropdownMenu class="dropdown-menu-lg-end">
<DropdownItem
href={item.href}
>
All Clusters
</DropdownItem>
<DropdownItem divider />
{#each clusters as cluster}
<Dropdown nav direction="right">
<DropdownToggle nav caret class="dropdown-item py-1 px-2">
{cluster.name}
</DropdownToggle>
<DropdownMenu>
<DropdownItem class="py-1 px-2"
href={item.href + '?cluster=' + cluster.name}
>
All Jobs
</DropdownItem>
<DropdownItem class="py-1 px-2"
href={item.href + '?cluster=' + cluster.name + '&state=running'}
>
Running Jobs
</DropdownItem>
</DropdownMenu>
</Dropdown>
{/each}
</DropdownMenu>
</Dropdown>
{/if}
{:else if !item.perCluster}
<NavLink href={item.href} active={window.location.pathname == item.href}
><Icon name={item.icon} /> {item.title}</NavLink

View File

@ -4,11 +4,14 @@ import Systems from './Systems.root.svelte'
new Systems({
target: document.getElementById('svelte-app'),
props: {
displayType: displayType,
cluster: infos.cluster,
subCluster: infos.subCluster,
from: infos.from,
to: infos.to
},
context: new Map([
['cc-config', clusterCockpitConfig]
['cc-config', clusterCockpitConfig],
['resampling', resampleConfig]
])
})

View File

@ -0,0 +1,280 @@
<!--
@component Cluster Per Node List component; renders current state of SELECTABLE metrics for ALL nodes
Properties:
- `cluster String`: The nodes' cluster
- `subCluster String`: The nodes' subCluster
- `ccconfig Object?`: The ClusterCockpit Config Context [Default: null]
- `selectedMetrics [String]`: The array of selected metrics
- `systemUnits Object`: The object of metric units
-->
<script>
import { getContext } from "svelte";
import { queryStore, gql, getContextClient, mutationStore } from "@urql/svelte";
import { Row, Col, Card, Table, Spinner } from "@sveltestrap/sveltestrap";
import { stickyHeader } from "../generic/utils.js";
import NodeListRow from "./nodelist/NodeListRow.svelte";
import Pagination from "../generic/joblist/Pagination.svelte";
export let cluster;
export let subCluster = "";
export let ccconfig = null;
export let selectedMetrics = [];
export let selectedResolution = 0;
export let hostnameFilter = "";
export let systemUnits = null;
export let from = null;
export let to = null;
// Decouple from Job List Paging Params?
let usePaging = ccconfig?.node_list_usePaging || false
let itemsPerPage = usePaging ? (ccconfig?.plot_list_nodesPerPage || 10) : 10;
let page = 1;
let paging = { itemsPerPage, page };
let headerPaddingTop = 0;
stickyHeader(
".cc-table-wrapper > table.table >thead > tr > th.position-sticky:nth-child(1)",
(x) => (headerPaddingTop = x),
);
// const { query: initq } = init();
const initialized = getContext("initialized");
const client = getContextClient();
const nodeListQuery = gql`
query ($cluster: String!, $subCluster: String!, $nodeFilter: String!, $metrics: [String!], $scopes: [MetricScope!]!, $from: Time!, $to: Time!, $paging: PageRequest!, $selectedResolution: Int) {
nodeMetricsList(
cluster: $cluster
subCluster: $subCluster
nodeFilter: $nodeFilter
scopes: $scopes
metrics: $metrics
from: $from
to: $to
page: $paging
resolution: $selectedResolution
) {
items {
host
subCluster
metrics {
name
scope
metric {
timestep
unit {
base
prefix
}
series {
id
hostname
data
statistics {
min
avg
max
}
}
statisticsSeries {
min
median
max
}
}
}
}
totalNodes
hasNextPage
}
}
`
const updateConfigurationMutation = ({ name, value }) => {
return mutationStore({
client: client,
query: gql`
mutation ($name: String!, $value: String!) {
updateConfiguration(name: $name, value: $value)
}
`,
variables: { name, value },
});
};
// Decouple from Job List Paging Params?
function updateConfiguration(value, page) {
updateConfigurationMutation({
name: "plot_list_nodesPerPage",
value: value,
}).subscribe((res) => {
if (res.fetching === false && !res.error) {
nodes = [] // Empty List
paging = { itemsPerPage: value, page: page }; // Trigger reload of nodeList
} else if (res.fetching === false && res.error) {
throw res.error;
}
});
}
if (!usePaging) {
window.addEventListener('scroll', () => {
let {
scrollTop,
scrollHeight,
clientHeight
} = document.documentElement;
// Add 100 px offset to trigger load earlier
if (scrollTop + clientHeight >= scrollHeight - 100 && $nodesQuery?.data != null && $nodesQuery.data?.nodeMetricsList.hasNextPage) {
let pendingPaging = { ...paging }
pendingPaging.page += 1
paging = pendingPaging
};
});
};
$: nodesQuery = queryStore({
client: client,
query: nodeListQuery,
variables: {
cluster: cluster,
subCluster: subCluster,
nodeFilter: hostnameFilter,
scopes: ["core", "socket", "accelerator"],
metrics: selectedMetrics,
from: from.toISOString(),
to: to.toISOString(),
paging: paging,
selectedResolution: selectedResolution,
},
requestPolicy: "network-only", // Resolution queries are cached, but how to access them? For now: reload on every change
});
let nodes = [];
$: if ($initialized && $nodesQuery.data) {
if (usePaging) {
nodes = [...$nodesQuery.data.nodeMetricsList.items].sort((a, b) => a.host.localeCompare(b.host));
} else {
nodes = nodes.concat([...$nodesQuery.data.nodeMetricsList.items].sort((a, b) => a.host.localeCompare(b.host)))
}
}
$: if (!usePaging && (selectedMetrics || selectedResolution || hostnameFilter || from || to)) {
// Continous Scroll: Reset list and paging if parameters change: Existing entries will not match new selections
nodes = [];
paging = { itemsPerPage, page: 1 };
}
$: matchedNodes = $nodesQuery.data?.nodeMetricsList.totalNodes || matchedNodes;
</script>
<Row>
<div class="col cc-table-wrapper">
<Table cellspacing="0px" cellpadding="0px">
<thead>
<tr>
<th
class="position-sticky top-0 text-capitalize"
scope="col"
style="padding-top: {headerPaddingTop}px;"
>
{cluster} Node Info
{#if $nodesQuery.fetching}
<Spinner size="sm" style="margin-left:10px;" secondary />
{/if}
</th>
{#each selectedMetrics as metric (metric)}
<th
class="position-sticky top-0 text-center"
scope="col"
style="padding-top: {headerPaddingTop}px"
>
{metric} ({systemUnits[metric]})
</th>
{/each}
</tr>
</thead>
<tbody>
{#if $nodesQuery.error}
<Row>
<Col>
<Card body color="danger">{$nodesQuery.error.message}</Card>
</Col>
</Row>
{:else}
{#each nodes as nodeData}
<NodeListRow {nodeData} {cluster} {selectedMetrics}/>
{:else}
<tr>
<td colspan={selectedMetrics.length + 1}> No nodes found </td>
</tr>
{/each}
{/if}
{#if $nodesQuery.fetching || !$nodesQuery.data}
<tr>
<td colspan={selectedMetrics.length + 1}>
<div style="text-align:center;">
<p><b>
Loading nodes {nodes.length + 1} to
{ matchedNodes
? `${(nodes.length + paging.itemsPerPage) > matchedNodes ? matchedNodes : (nodes.length + paging.itemsPerPage)} of ${matchedNodes} total`
: (nodes.length + paging.itemsPerPage)
}
</b></p>
<Spinner secondary />
</div>
</td>
</tr>
{/if}
</tbody>
</Table>
</div>
</Row>
{#if usePaging}
<Pagination
bind:page
{itemsPerPage}
itemText="Nodes"
totalItems={matchedNodes}
on:update-paging={({ detail }) => {
if (detail.itemsPerPage != itemsPerPage) {
updateConfiguration(detail.itemsPerPage.toString(), detail.page);
} else {
nodes = []
paging = { itemsPerPage: detail.itemsPerPage, page: detail.page };
}
}}
/>
{/if}
<style>
.cc-table-wrapper {
overflow: initial;
}
.cc-table-wrapper > :global(table) {
border-collapse: separate;
border-spacing: 0px;
table-layout: fixed;
}
.cc-table-wrapper :global(button) {
margin-bottom: 0px;
}
.cc-table-wrapper > :global(table > tbody > tr > td) {
margin: 0px;
padding-left: 5px;
padding-right: 0px;
}
th.position-sticky.top-0 {
background-color: white;
z-index: 10;
border-bottom: 1px solid black;
}
</style>

View File

@ -0,0 +1,155 @@
<!--
@component Cluster Per Node Overview component; renders current state of ONE metric for ALL nodes
Properties:
- `ccconfig Object?`: The ClusterCockpit Config Context [Default: null]
- `cluster String`: The cluster to show status information for
- `selectedMetric String?`: The selectedMetric input [Default: ""]
-->
<script>
import { queryStore, gql, getContextClient } from "@urql/svelte";
import { Row, Col, Card, Spinner } from "@sveltestrap/sveltestrap";
import { init, checkMetricsDisabled } from "../generic/utils.js";
import MetricPlot from "../generic/plots/MetricPlot.svelte";
export let ccconfig = null;
export let cluster = "";
export const subCluster = "";
export let selectedMetrics = null;
export let hostnameFilter = "";
export let from = null;
export let to = null;
const { query: initq } = init();
const client = getContextClient();
const nodeQuery = gql`
query ($cluster: String!, $metrics: [String!], $from: Time!, $to: Time!) {
nodeMetrics(
cluster: $cluster
metrics: $metrics
from: $from
to: $to
) {
host
subCluster
metrics {
name
scope
metric {
timestep
unit {
base
prefix
}
series {
statistics {
min
avg
max
}
data
}
}
}
}
}
`
$: selectedMetric = selectedMetrics[0] ? selectedMetrics[0] : "";
$: nodesQuery = queryStore({
client: client,
query: nodeQuery,
variables: {
cluster: cluster,
metrics: selectedMetrics,
from: from.toISOString(),
to: to.toISOString(),
},
});
let rawData = []
$: if ($initq.data && $nodesQuery?.data) {
rawData = $nodesQuery?.data?.nodeMetrics.filter((h) => {
if (h.subCluster === '') { // Exclude nodes with empty subCluster field
console.warn('subCluster not configured for node', h.host)
return false
} else {
return h.metrics.some(
(m) => selectedMetrics.includes(m.name) && m.scope == "node",
)
}
})
}
let mappedData = []
$: if (rawData?.length > 0) {
mappedData = rawData.map((h) => ({
host: h.host,
subCluster: h.subCluster,
data: h.metrics.filter(
(m) => selectedMetrics.includes(m.name) && m.scope == "node",
),
disabled: checkMetricsDisabled(
selectedMetrics,
cluster,
h.subCluster,
),
}))
.sort((a, b) => a.host.localeCompare(b.host))
}
let filteredData = []
$: if (mappedData?.length > 0) {
filteredData = mappedData.filter((h) =>
h.host.includes(hostnameFilter)
)
}
</script>
{#if $nodesQuery.error}
<Row>
<Col>
<Card body color="danger">{$nodesQuery.error.message}</Card>
</Col>
</Row>
{:else if $nodesQuery.fetching }
<Row>
<Col>
<Spinner />
</Col>
</Row>
{:else if filteredData?.length > 0}
<!-- PlotGrid flattened into this component -->
<Row cols={{ xs: 1, sm: 2, md: 3, lg: ccconfig.plot_view_plotsPerRow}}>
{#each filteredData as item (item.host)}
<Col class="px-1">
<h4 style="width: 100%; text-align: center;">
<a
style="display: block;padding-top: 15px;"
href="/monitoring/node/{cluster}/{item.host}"
>{item.host} ({item.subCluster})</a
>
</h4>
{#if item?.disabled[selectedMetric]}
<Card body class="mx-3" color="info"
>Metric disabled for subcluster <code
>{selectedMetric}:{item.subCluster}</code
></Card
>
{:else}
<!-- "No Data"-Warning included in MetricPlot-Component -->
<MetricPlot
timestep={item.data[0].metric.timestep}
series={item.data[0].metric.series}
metric={item.data[0].name}
{cluster}
subCluster={item.subCluster}
forNode
/>
{/if}
</Col>
{/each}
</Row>
{/if}

View File

@ -0,0 +1,177 @@
<!--
@component Displays node info, serves links to single node page and lists
Properties:
- `cluster String`: The nodes' cluster
- `subCluster String`: The nodes' subCluster
- `cluster String`: The nodes' hostname
-->
<script>
import {
Icon,
Button,
Card,
CardHeader,
CardBody,
Input,
InputGroup,
InputGroupText, } from "@sveltestrap/sveltestrap";
export let cluster;
export let subCluster
export let hostname;
export let dataHealth;
export let nodeJobsData = null;
// Not at least one returned, selected metric: NodeHealth warning
const healthWarn = !dataHealth.includes(true);
// At least one non-returned selected metric: Metric config error?
const metricWarn = dataHealth.includes(false);
let userList;
let projectList;
$: if (nodeJobsData) {
userList = Array.from(new Set(nodeJobsData.jobs.items.map((j) => j.user))).sort((a, b) => a.localeCompare(b));
projectList = Array.from(new Set(nodeJobsData.jobs.items.map((j) => j.project))).sort((a, b) => a.localeCompare(b));
}
</script>
<Card class="pb-3">
<CardHeader class="d-inline-flex justify-content-between align-items-end">
<div>
<h5 class="mb-0">
Node
<a href="/monitoring/node/{cluster}/{hostname}" target="_blank">
{hostname}
</a>
</h5>
</div>
<div class="text-capitalize">
<h6 class="mb-0">
{cluster} {subCluster}
</h6>
</div>
</CardHeader>
<CardBody>
{#if healthWarn}
<InputGroup>
<InputGroupText>
<Icon name="exclamation-circle"/>
</InputGroupText>
<InputGroupText>
Status
</InputGroupText>
<Button color="danger" disabled>
Unhealthy
</Button>
</InputGroup>
{:else if metricWarn}
<InputGroup>
<InputGroupText>
<Icon name="info-circle"/>
</InputGroupText>
<InputGroupText>
Status
</InputGroupText>
<Button color="warning" disabled>
Missing Metric
</Button>
</InputGroup>
{:else if nodeJobsData.jobs.count == 1 && nodeJobsData.jobs.items[0].exclusive}
<InputGroup>
<InputGroupText>
<Icon name="circle-fill"/>
</InputGroupText>
<InputGroupText>
Status
</InputGroupText>
<Button color="success" disabled>
Exclusive
</Button>
</InputGroup>
{:else if nodeJobsData.jobs.count >= 1 && !nodeJobsData.jobs.items[0].exclusive}
<InputGroup>
<InputGroupText>
<Icon name="circle-half"/>
</InputGroupText>
<InputGroupText>
Status
</InputGroupText>
<Button color="success" disabled>
Shared
</Button>
</InputGroup>
{:else}
<InputGroup>
<InputGroupText>
<Icon name="circle"/>
</InputGroupText>
<InputGroupText>
Status
</InputGroupText>
<Button color="secondary" disabled>
Idle
</Button>
</InputGroup>
{/if}
<hr class="my-3"/>
<!-- JOBS -->
<InputGroup size="sm" class="justify-content-between mb-3">
<InputGroupText>
<Icon name="activity"/>
</InputGroupText>
<InputGroupText class="justify-content-center" style="width: 4.4rem;">
Activity
</InputGroupText>
<Input class="flex-grow-1" style="background-color: white;" type="text" value="{nodeJobsData?.jobs?.count || 0} Job{(nodeJobsData?.jobs?.count == 1) ? '': 's'}" disabled />
<a title="Show jobs running on this node" href="/monitoring/jobs/?cluster={cluster}&state=running&node={hostname}" target="_blank" class="btn btn-outline-primary" role="button" aria-disabled="true" >
<Icon name="view-list" />
List
</a>
</InputGroup>
<!-- USERS -->
<InputGroup size="sm" class="justify-content-between {(userList?.length > 0) ? 'mb-1' : 'mb-3'}">
<InputGroupText>
<Icon name="people"/>
</InputGroupText>
<InputGroupText class="justify-content-center" style="width: 4.4rem;">
Users
</InputGroupText>
<Input class="flex-grow-1" style="background-color: white;" type="text" value="{userList?.length || 0} User{(userList?.length == 1) ? '': 's'}" disabled />
<a title="Show users active on this node" href="/monitoring/users/?cluster={cluster}&state=running&node={hostname}" target="_blank" class="btn btn-outline-primary" role="button" aria-disabled="true" >
<Icon name="view-list" />
List
</a>
</InputGroup>
{#if userList?.length > 0}
<Card class="mb-3">
<div class="p-1">
{userList.join(", ")}
</div>
</Card>
{/if}
<!-- PROJECTS -->
<InputGroup size="sm" class="justify-content-between {(projectList?.length > 0) ? 'mb-1' : 'mb-3'}">
<InputGroupText>
<Icon name="journals"/>
</InputGroupText>
<InputGroupText class="justify-content-center" style="width: 4.4rem;">
Projects
</InputGroupText>
<Input class="flex-grow-1" style="background-color: white;" type="text" value="{projectList?.length || 0} Project{(projectList?.length == 1) ? '': 's'}" disabled />
<a title="Show projects active on this node" href="/monitoring/projects/?cluster={cluster}&state=running&node={hostname}" target="_blank" class="btn btn-outline-primary" role="button" aria-disabled="true" >
<Icon name="view-list" />
List
</a>
</InputGroup>
{#if projectList?.length > 0}
<Card>
<div class="p-1">
{projectList.join(", ")}
</div>
</Card>
{/if}
</CardBody>
</Card>

View File

@ -0,0 +1,187 @@
<!--
@component Data row for a single node displaying metric plots
Properties:
- `cluster String`: The nodes' cluster
- `nodeData Object`: The node data object including metric data
- `selectedMetrics [String]`: The array of selected metrics
-->
<script>
import {
queryStore,
gql,
getContextClient,
} from "@urql/svelte";
import { Card, CardBody, Spinner } from "@sveltestrap/sveltestrap";
import { maxScope, checkMetricDisabled } from "../../generic/utils.js";
import MetricPlot from "../../generic/plots/MetricPlot.svelte";
import NodeInfo from "./NodeInfo.svelte";
export let cluster;
export let nodeData;
export let selectedMetrics;
const client = getContextClient();
const paging = { itemsPerPage: 50, page: 1 };
const sorting = { field: "startTime", type: "col", order: "DESC" };
const filter = [
{ cluster: { eq: cluster } },
{ node: { contains: nodeData.host } },
{ state: ["running"] },
];
const nodeJobsQuery = gql`
query (
$filter: [JobFilter!]!
$sorting: OrderByInput!
$paging: PageRequest!
) {
jobs(filter: $filter, order: $sorting, page: $paging) {
items {
jobId
user
project
exclusive
resources {
hostname
accelerators
}
}
count
}
}
`;
$: nodeJobsData = queryStore({
client: client,
query: nodeJobsQuery,
variables: { paging, sorting, filter },
});
// Helper
const selectScope = (nodeMetrics) =>
nodeMetrics.reduce(
(a, b) =>
maxScope([a.scope, b.scope]) == a.scope ? b : a,
nodeMetrics[0],
);
const sortAndSelectScope = (allNodeMetrics) =>
selectedMetrics
.map((selectedName) => allNodeMetrics.filter((nodeMetric) => nodeMetric.name == selectedName))
.map((matchedNodeMetrics) => ({
disabled: false,
data: matchedNodeMetrics.length > 0 ? selectScope(matchedNodeMetrics) : null,
}))
.map((scopedNodeMetric) => {
if (scopedNodeMetric?.data) {
return {
disabled: checkMetricDisabled(
scopedNodeMetric.data.name,
cluster,
nodeData.subCluster,
),
data: scopedNodeMetric.data,
};
} else {
return scopedNodeMetric;
}
});
let refinedData;
let dataHealth;
$: if (nodeData?.metrics) {
refinedData = sortAndSelectScope(nodeData?.metrics)
dataHealth = refinedData.filter((rd) => rd.disabled === false).map((enabled) => (enabled.data.metric.series.length > 0))
}
let extendedLegendData = null;
$: if ($nodeJobsData?.data) {
// Get Shared State of Node: Only Build extended Legend For Shared Nodes
if ($nodeJobsData.data.jobs.count >= 1 && !$nodeJobsData.data.jobs.items[0].exclusive) {
const accSet = Array.from(new Set($nodeJobsData.data.jobs.items
.map((i) => i.resources
.filter((r) => r.hostname === nodeData.host)
.map((r) => r.accelerators)
)
)).flat(2)
extendedLegendData = {}
for (const accId of accSet) {
const matchJob = $nodeJobsData.data.jobs.items.find((i) => i.resources.find((r) => r.accelerators.includes(accId)))
extendedLegendData[accId] = {
user: matchJob?.user ? matchJob?.user : '-',
job: matchJob?.jobId ? matchJob?.jobId : '-',
}
}
// Theoretically extendable for hwthreadIDs
}
}
</script>
<tr>
<td>
{#if $nodeJobsData.fetching}
<Card>
<CardBody class="content-center">
<Spinner/>
</CardBody>
</Card>
{:else}
<NodeInfo nodeJobsData={$nodeJobsData.data} {cluster} subCluster={nodeData.subCluster} hostname={nodeData.host} {dataHealth}/>
{/if}
</td>
{#each refinedData as metricData (metricData.data.name)}
{#key metricData}
<td>
{#if metricData?.disabled}
<Card body class="mx-3" color="info"
>Metric disabled for subcluster <code
>{metricData.data.name}:{nodeData.subCluster}</code
></Card
>
{:else if !!metricData.data?.metric.statisticsSeries}
<!-- "No Data"-Warning included in MetricPlot-Component -->
<MetricPlot
{cluster}
subCluster={nodeData.subCluster}
metric={metricData.data.name}
scope={metricData.data.scope}
timestep={metricData.data.metric.timestep}
series={metricData.data.metric.series}
statisticsSeries={metricData.data?.metric.statisticsSeries}
useStatsSeries={!!metricData.data?.metric.statisticsSeries}
height={175}
forNode
/>
<div class="my-2"/>
{#key extendedLegendData}
<MetricPlot
{cluster}
subCluster={nodeData.subCluster}
metric={metricData.data.name}
scope={metricData.data.scope}
timestep={metricData.data.metric.timestep}
series={metricData.data.metric.series}
height={175}
{extendedLegendData}
forNode
/>
{/key}
{:else}
<MetricPlot
{cluster}
subCluster={nodeData.subCluster}
metric={metricData.data.name}
scope={metricData.data.scope}
timestep={metricData.data.metric.timestep}
series={metricData.data.metric.series}
height={375}
forNode
/>
{/if}
</td>
{/key}
{/each}
</tr>

View File

@ -15,10 +15,11 @@
{{end}}
<script>
const header = {
"username": "{{ .User.Username }}",
"authlevel": {{ .User.GetAuthLevel }},
"clusters": {{ .Clusters }},
"roles": {{ .Roles }}
"username": "{{ .User.Username }}",
"authlevel": {{ .User.GetAuthLevel }},
"clusters": {{ .Clusters }},
"subClusters": {{ .SubClusters }},
"roles": {{ .Roles }}
};
</script>
</head>

View File

@ -8,6 +8,7 @@
{{define "javascript"}}
<script>
const isAdmin = {{ .User.HasRole .Roles.admin }};
const isSupport = {{ .User.HasRole .Roles.support }};
const isApi = {{ .User.HasRole .Roles.api }};
const username = {{ .User.Username }};
const filterPresets = {{ .FilterPresets }};

View File

@ -7,8 +7,10 @@
{{end}}
{{define "javascript"}}
<script>
const displayType = {{ .Infos.displayType }};
const infos = {{ .Infos }};
const clusterCockpitConfig = {{ .Config }};
const resampleConfig = {{ .Resampling }};
</script>
<script src='/build/systems.js'></script>
{{end}}

View File

@ -13,6 +13,7 @@ import (
"github.com/ClusterCockpit/cc-backend/internal/config"
"github.com/ClusterCockpit/cc-backend/internal/util"
"github.com/ClusterCockpit/cc-backend/pkg/archive"
"github.com/ClusterCockpit/cc-backend/pkg/log"
"github.com/ClusterCockpit/cc-backend/pkg/schema"
)
@ -95,6 +96,7 @@ type Page struct {
Roles map[string]schema.Role // Available roles for frontend render checks
Build Build // Latest information about the application
Clusters []schema.ClusterConfig // List of all clusters for use in the Header
SubClusters map[string][]string // Map per cluster of all subClusters for use in the Header
FilterPresets map[string]interface{} // For pages with the Filter component, this can be used to set initial filters.
Infos map[string]interface{} // For generic use (e.g. username for /monitoring/user/<id>, job id for /monitoring/job/<id>)
Config map[string]interface{} // UI settings for the currently logged in user (e.g. line width, ...)
@ -114,6 +116,15 @@ func RenderTemplate(rw http.ResponseWriter, file string, page *Page) {
}
}
if page.SubClusters == nil {
page.SubClusters = make(map[string][]string)
for _, cluster := range archive.Clusters {
for _, sc := range cluster.SubClusters {
page.SubClusters[cluster.Name] = append(page.SubClusters[cluster.Name], sc.Name)
}
}
}
log.Debugf("Page config : %v\n", page.Config)
if err := t.Execute(rw, page); err != nil {
log.Errorf("Template error: %s", err.Error())