     1→package graph
     2→
     3→// This file will be automatically regenerated based on the schema, any resolver
     4→// implementations
     5→// will be copied through when generating and any unknown code will be moved to the end.
     6→// Code generated by github.com/99designs/gqlgen version v0.17.88
     7→
     8→import (
     9→	"context"
    10→	"errors"
    11→	"fmt"
    12→	"math"
    13→	"regexp"
    14→	"slices"
    15→	"strconv"
    16→	"strings"
    17→	"time"
    18→
    19→	"github.com/ClusterCockpit/cc-backend/internal/config"
    20→	"github.com/ClusterCockpit/cc-backend/internal/graph/generated"
    21→	"github.com/ClusterCockpit/cc-backend/internal/graph/model"
    22→	"github.com/ClusterCockpit/cc-backend/internal/metricdispatch"
    23→	"github.com/ClusterCockpit/cc-backend/internal/repository"
    24→	"github.com/ClusterCockpit/cc-backend/pkg/archive"
    25→	cclog "github.com/ClusterCockpit/cc-lib/v2/ccLogger"
    26→	ccunit "github.com/ClusterCockpit/cc-lib/v2/ccUnits"
    27→	"github.com/ClusterCockpit/cc-lib/v2/schema"
    28→)
    29→
    30→// Partitions is the resolver for the partitions field.
    31→func (r *clusterResolver) Partitions(ctx context.Context, obj *schema.Cluster) ([]string, error) {
    32→	return r.Repo.Partitions(obj.Name)
    33→}
    34→
    35→// StartTime is the resolver for the startTime field.
    36→func (r *jobResolver) StartTime(ctx context.Context, obj *schema.Job) (*time.Time, error) {
    37→	timestamp := time.Unix(obj.StartTime, 0)
    38→	return &timestamp, nil
    39→}
    40→
    41→// Tags is the resolver for the tags field.
    42→func (r *jobResolver) Tags(ctx context.Context, obj *schema.Job) ([]*schema.Tag, error) {
    43→	return r.Repo.GetTags(repository.GetUserFromContext(ctx), obj.ID)
    44→}
    45→
    46→// ConcurrentJobs is the resolver for the concurrentJobs field.
    47→func (r *jobResolver) ConcurrentJobs(ctx context.Context, obj *schema.Job) (*model.JobLinkResultList, error) {
    48→	// FIXME: Make the hardcoded duration configurable
    49→	if obj.Shared != "none" && obj.Duration > 600 {
    50→		return r.Repo.FindConcurrentJobs(ctx, obj)
    51→	}
    52→
    53→	return nil, nil
    54→}
    55→
    56→// Footprint is the resolver for the footprint field.
    57→func (r *jobResolver) Footprint(ctx context.Context, obj *schema.Job) ([]*model.FootprintValue, error) {
    58→	rawFootprint, err := r.Repo.FetchFootprint(obj)
    59→	if err != nil {
    60→		cclog.Warn("Error while fetching job footprint data")
    61→		return nil, err
    62→	}
    63→
    64→	res := []*model.FootprintValue{}
    65→	for name, value := range rawFootprint {
    66→
    67→		parts := strings.Split(name, "_")
    68→		statPart := parts[len(parts)-1]
    69→		nameParts := parts[:len(parts)-1]
    70→
    71→		res = append(res, &model.FootprintValue{
    72→			Name:  strings.Join(nameParts, "_"),
    73→			Stat:  statPart,
    74→			Value: value,
    75→		})
    76→	}
    77→
    78→	return res, err
    79→}
    80→
    81→// EnergyFootprint is the resolver for the energyFootprint field.
    82→func (r *jobResolver) EnergyFootprint(ctx context.Context, obj *schema.Job) ([]*model.EnergyFootprintValue, error) {
    83→	rawEnergyFootprint, err := r.Repo.FetchEnergyFootprint(obj)
    84→	if err != nil {
    85→		cclog.Warn("Error while fetching job energy footprint data")
    86→		return nil, err
    87→	}
    88→
    89→	res := []*model.EnergyFootprintValue{}
    90→	for name, value := range rawEnergyFootprint {
    91→		// Suboptimal: Nearly hardcoded metric name expectations
    92→		matchCPU := regexp.MustCompile(`cpu|Cpu|CPU`)
    93→		matchAcc := regexp.MustCompile(`acc|Acc|ACC`)
    94→		matchMem := regexp.MustCompile(`mem|Mem|MEM`)
    95→		matchCore := regexp.MustCompile(`core|Core|CORE`)
    96→
    97→		hwType := ""
    98→		switch test := name; { // NOtice ';' for var declaration
    99→		case matchCPU.MatchString(test):
   100→			hwType = "CPU"
   101→		case matchAcc.MatchString(test):
   102→			hwType = "Accelerator"
   103→		case matchMem.MatchString(test):
   104→			hwType = "Memory"
   105→		case matchCore.MatchString(test):
   106→			hwType = "Core"
   107→		default:
   108→			hwType = "Other"
   109→		}
   110→
   111→		res = append(res, &model.EnergyFootprintValue{
   112→			Hardware: hwType,
   113→			Metric:   name,
   114→			Value:    value,
   115→		})
   116→	}
   117→	return res, err
   118→}
   119→
   120→// MetaData is the resolver for the metaData field.
   121→func (r *jobResolver) MetaData(ctx context.Context, obj *schema.Job) (any, error) {
   122→	return r.Repo.FetchMetadata(obj)
   123→}
   124→
   125→// UserData is the resolver for the userData field.
   126→func (r *jobResolver) UserData(ctx context.Context, obj *schema.Job) (*model.User, error) {
   127→	return repository.GetUserRepository().FetchUserInCtx(ctx, obj.User)
   128→}
   129→
   130→// Name is the resolver for the name field.
   131→func (r *metricValueResolver) Name(ctx context.Context, obj *schema.MetricValue) (*string, error) {
   132→	panic(fmt.Errorf("not implemented: Name - name"))
   133→}
   134→
   135→// CreateTag is the resolver for the createTag field.
   136→func (r *mutationResolver) CreateTag(ctx context.Context, typeArg string, name string, scope string) (*schema.Tag, error) {
   137→	user := repository.GetUserFromContext(ctx)
   138→	if user == nil {
   139→		return nil, fmt.Errorf("no user in context")
   140→	}
   141→
   142→	// Test Access: Admins && Admin Tag OR Support/Admin and Global Tag OR Everyone && Private Tag
   143→	if user.HasRole(schema.RoleAdmin) && scope == "admin" ||
   144→		user.HasAnyRole([]schema.Role{schema.RoleAdmin, schema.RoleSupport}) && scope == "global" ||
   145→		user.Username == scope {
   146→		// Create in DB
   147→		id, err := r.Repo.CreateTag(typeArg, name, scope)
   148→		if err != nil {
   149→			cclog.Warn("Error while creating tag")
   150→			return nil, err
   151→		}
   152→		return &schema.Tag{ID: id, Type: typeArg, Name: name, Scope: scope}, nil
   153→	} else {
   154→		cclog.Warnf("Not authorized to create tag with scope: %s", scope)
   155→		return nil, fmt.Errorf("not authorized to create tag with scope: %s", scope)
   156→	}
   157→}
   158→
   159→// DeleteTag is the resolver for the deleteTag field.
   160→func (r *mutationResolver) DeleteTag(ctx context.Context, id string) (string, error) {
   161→	// This Uses ID string <-> ID string, removeTagFromList uses []string <-> []int
   162→	panic(fmt.Errorf("not implemented: DeleteTag - deleteTag"))
   163→}
   164→
   165→// AddTagsToJob is the resolver for the addTagsToJob field.
   166→func (r *mutationResolver) AddTagsToJob(ctx context.Context, job string, tagIds []string) ([]*schema.Tag, error) {
   167→	user := repository.GetUserFromContext(ctx)
   168→	if user == nil {
   169→		return nil, fmt.Errorf("no user in context")
   170→	}
   171→
   172→	jid, err := strconv.ParseInt(job, 10, 64)
   173→	if err != nil {
   174→		cclog.Warn("Error while adding tag to job")
   175→		return nil, err
   176→	}
   177→
   178→	tags := []*schema.Tag{}
   179→	for _, tagID := range tagIds {
   180→		// Get ID
   181→		tid, err := strconv.ParseInt(tagID, 10, 64)
   182→		if err != nil {
   183→			cclog.Warn("Error while parsing tag id")
   184→			return nil, err
   185→		}
   186→
   187→		// Test Exists
   188→		_, _, tscope, exists := r.Repo.TagInfo(tid)
   189→		if !exists {
   190→			cclog.Warnf("Tag does not exist (ID): %d", tid)
   191→			return nil, fmt.Errorf("tag does not exist (ID): %d", tid)
   192→		}
   193→
   194→		// Test Access: Admins && Admin Tag OR Support/Admin and Global Tag OR Everyone && Private Tag
   195→		if user.HasRole(schema.RoleAdmin) && tscope == "admin" ||
   196→			user.HasAnyRole([]schema.Role{schema.RoleAdmin, schema.RoleSupport}) && tscope == "global" ||
   197→			user.Username == tscope {
   198→			// Add to Job
   199→			if tags, err = r.Repo.AddTag(user, jid, tid); err != nil {
   200→				cclog.Warn("Error while adding tag")
   201→				return nil, err
   202→			}
   203→		} else {
   204→			cclog.Warnf("Not authorized to add tag: %d", tid)
   205→			return nil, fmt.Errorf("not authorized to add tag: %d", tid)
   206→		}
   207→	}
   208→
   209→	return tags, nil
   210→}
   211→
   212→// RemoveTagsFromJob is the resolver for the removeTagsFromJob field.
   213→func (r *mutationResolver) RemoveTagsFromJob(ctx context.Context, job string, tagIds []string) ([]*schema.Tag, error) {
   214→	user := repository.GetUserFromContext(ctx)
   215→	if user == nil {
   216→		return nil, fmt.Errorf("no user in context")
   217→	}
   218→
   219→	jid, err := strconv.ParseInt(job, 10, 64)
   220→	if err != nil {
   221→		cclog.Warn("Error while parsing job id")
   222→		return nil, err
   223→	}
   224→
   225→	tags := []*schema.Tag{}
   226→	for _, tagID := range tagIds {
   227→		// Get ID
   228→		tid, err := strconv.ParseInt(tagID, 10, 64)
   229→		if err != nil {
   230→			cclog.Warn("Error while parsing tag id")
   231→			return nil, err
   232→		}
   233→
   234→		// Test Exists
   235→		_, _, tscope, exists := r.Repo.TagInfo(tid)
   236→		if !exists {
   237→			cclog.Warnf("Tag does not exist (ID): %d", tid)
   238→			return nil, fmt.Errorf("tag does not exist (ID): %d", tid)
   239→		}
   240→
   241→		// Test Access: Admins && Admin Tag OR Support/Admin and Global Tag OR Everyone && Private Tag
   242→		if user.HasRole(schema.RoleAdmin) && tscope == "admin" ||
   243→			user.HasAnyRole([]schema.Role{schema.RoleAdmin, schema.RoleSupport}) && tscope == "global" ||
   244→			user.Username == tscope {
   245→			// Remove from Job
   246→			if tags, err = r.Repo.RemoveTag(user, jid, tid); err != nil {
   247→				cclog.Warn("Error while removing tag")
   248→				return nil, err
   249→			}
   250→		} else {
   251→			cclog.Warnf("Not authorized to remove tag: %d", tid)
   252→			return nil, fmt.Errorf("not authorized to remove tag: %d", tid)
   253→		}
   254→
   255→	}
   256→
   257→	return tags, nil
   258→}
   259→
   260→// RemoveTagFromList is the resolver for the removeTagFromList field.
   261→func (r *mutationResolver) RemoveTagFromList(ctx context.Context, tagIds []string) ([]int, error) {
   262→	// Needs Contextuser
   263→	user := repository.GetUserFromContext(ctx)
   264→	if user == nil {
   265→		return nil, fmt.Errorf("no user in context")
   266→	}
   267→
   268→	tags := []int{}
   269→	for _, tagID := range tagIds {
   270→		// Get ID
   271→		tid, err := strconv.ParseInt(tagID, 10, 64)
   272→		if err != nil {
   273→			cclog.Warn("Error while parsing tag id for removal")
   274→			return nil, err
   275→		}
   276→
   277→		// Test Exists
   278→		_, _, tscope, exists := r.Repo.TagInfo(tid)
   279→		if !exists {
   280→			cclog.Warnf("Tag does not exist (ID): %d", tid)
   281→			return nil, fmt.Errorf("tag does not exist (ID): %d", tid)
   282→		}
   283→
   284→		// Test Access: Admins && Admin Tag OR Everyone && Private Tag
   285→		if user.HasRole(schema.RoleAdmin) && (tscope == "global" || tscope == "admin") || user.Username == tscope {
   286→			// Remove from DB
   287→			if err = r.Repo.RemoveTagByID(tid); err != nil {
   288→				cclog.Warn("Error while removing tag")
   289→				return nil, err
   290→			} else {
   291→				tags = append(tags, int(tid))
   292→			}
   293→		} else {
   294→			cclog.Warnf("Not authorized to remove tag: %d", tid)
   295→			return nil, fmt.Errorf("not authorized to remove tag: %d", tid)
   296→		}
   297→	}
   298→	return tags, nil
   299→}
   300→
   301→// UpdateConfiguration is the resolver for the updateConfiguration field.
   302→func (r *mutationResolver) UpdateConfiguration(ctx context.Context, name string, value string) (*string, error) {
   303→	if err := repository.GetUserCfgRepo().UpdateConfig(name, value, repository.GetUserFromContext(ctx)); err != nil {
   304→		cclog.Warn("Error while updating user config")
   305→		return nil, err
   306→	}
   307→
   308→	return nil, nil
   309→}
   310→
   311→// ID is the resolver for the id field.
   312→func (r *nodeResolver) ID(ctx context.Context, obj *schema.Node) (string, error) {
   313→	panic(fmt.Errorf("not implemented: ID - id"))
   314→}
   315→
   316→// SchedulerState is the resolver for the schedulerState field.
   317→func (r *nodeResolver) SchedulerState(ctx context.Context, obj *schema.Node) (schema.SchedulerState, error) {
   318→	if obj.NodeState != "" {
   319→		return obj.NodeState, nil
   320→	} else {
   321→		return "", fmt.Errorf("resolver: no SchedulerState (NodeState) on node object")
   322→	}
   323→}
   324→
   325→// HealthState is the resolver for the healthState field.
   326→func (r *nodeResolver) HealthState(ctx context.Context, obj *schema.Node) (string, error) {
   327→	if obj.HealthState != "" {
   328→		return string(obj.HealthState), nil
   329→	} else {
   330→		return "", fmt.Errorf("resolver: no HealthState (NodeState) on node object")
   331→	}
   332→}
   333→
   334→// MetaData is the resolver for the metaData field.
   335→func (r *nodeResolver) MetaData(ctx context.Context, obj *schema.Node) (any, error) {
   336→	if obj.MetaData != nil {
   337→		return obj.MetaData, nil
   338→	} else {
   339→		cclog.Debug("resolver: no MetaData (NodeState) on node object")
   340→		emptyMeta := make(map[string]string, 0)
   341→		return emptyMeta, nil
   342→	}
   343→}
   344→
   345→// HealthData is the resolver for the healthData field.
   346→func (r *nodeResolver) HealthData(ctx context.Context, obj *schema.Node) (any, error) {
   347→	if obj.HealthData != nil {
   348→		return obj.HealthData, nil
   349→	} else {
   350→		cclog.Debug("resolver: no HealthData (NodeState) on node object")
   351→		emptyHealth := make(map[string][]string, 0)
   352→		return emptyHealth, nil
   353→	}
   354→}
   355→
   356→// Clusters is the resolver for the clusters field.
   357→func (r *queryResolver) Clusters(ctx context.Context) ([]*schema.Cluster, error) {
   358→	return archive.Clusters, nil
   359→}
   360→
   361→// Tags is the resolver for the tags field.
   362→func (r *queryResolver) Tags(ctx context.Context) ([]*schema.Tag, error) {
   363→	return r.Repo.GetTags(repository.GetUserFromContext(ctx), nil)
   364→}
   365→
   366→// GlobalMetrics is the resolver for the globalMetrics field.
   367→func (r *queryResolver) GlobalMetrics(ctx context.Context) ([]*schema.GlobalMetricListItem, error) {
   368→	user := repository.GetUserFromContext(ctx)
   369→
   370→	if user != nil {
   371→		if user.HasRole(schema.RoleUser) || user.HasRole(schema.RoleManager) {
   372→			return archive.GlobalUserMetricList, nil
   373→		}
   374→	}
   375→
   376→	return archive.GlobalMetricList, nil
   377→}
   378→
   379→// User is the resolver for the user field.
   380→func (r *queryResolver) User(ctx context.Context, username string) (*model.User, error) {
   381→	return repository.GetUserRepository().FetchUserInCtx(ctx, username)
   382→}
   383→
   384→// AllocatedNodes is the resolver for the allocatedNodes field.
   385→func (r *queryResolver) AllocatedNodes(ctx context.Context, cluster string) ([]*model.Count, error) {
   386→	data, err := r.Repo.AllocatedNodes(cluster)
   387→	if err != nil {
   388→		cclog.Warn("Error while fetching allocated nodes")
   389→		return nil, err
   390→	}
   391→
   392→	counts := make([]*model.Count, 0, len(data))
   393→	for subcluster, hosts := range data {
   394→		counts = append(counts, &model.Count{
   395→			Name:  subcluster,
   396→			Count: len(hosts),
   397→		})
   398→	}
   399→
   400→	return counts, nil
   401→}
   402→
   403→// Node is the resolver for the node field.
   404→func (r *queryResolver) Node(ctx context.Context, id string) (*schema.Node, error) {
   405→	repo := repository.GetNodeRepository()
   406→	numericID, err := strconv.ParseInt(id, 10, 64)
   407→	if err != nil {
   408→		cclog.Warn("Error while parsing job id")
   409→		return nil, err
   410→	}
   411→	return repo.GetNodeByID(numericID, false)
   412→}
   413→
   414→// Nodes is the resolver for the nodes field.
   415→func (r *queryResolver) Nodes(ctx context.Context, filter []*model.NodeFilter, order *model.OrderByInput) (*model.NodeStateResultList, error) {
   416→	repo := repository.GetNodeRepository()
   417→	nodes, err := repo.QueryNodes(ctx, filter, nil, order) // Ignore Paging, Order Unused
   418→	count := len(nodes)
   419→	return &model.NodeStateResultList{Items: nodes, Count: &count}, err
   420→}
   421→
   422→// NodesWithMeta is the resolver for the nodesWithMeta field.
   423→func (r *queryResolver) NodesWithMeta(ctx context.Context, filter []*model.NodeFilter, order *model.OrderByInput) (*model.NodeStateResultList, error) {
   424→	// Why Extra Handler? -> graphql.CollectAllFields(ctx) only returns toplevel fields (i.e.: items, count), and not subfields like item.metaData
   425→	repo := repository.GetNodeRepository()
   426→	nodes, err := repo.QueryNodesWithMeta(ctx, filter, nil, order) // Ignore Paging, Order Unused
   427→	count := len(nodes)
   428→	return &model.NodeStateResultList{Items: nodes, Count: &count}, err
   429→}
   430→
   431→// NodeStates is the resolver for the nodeStates field.
   432→func (r *queryResolver) NodeStates(ctx context.Context, filter []*model.NodeFilter) ([]*model.NodeStates, error) {
   433→	repo := repository.GetNodeRepository()
   434→
   435→	stateCounts, serr := repo.CountStates(ctx, filter, "node_state")
   436→	if serr != nil {
   437→		cclog.Warnf("Error while counting nodeStates: %s", serr.Error())
   438→		return nil, serr
   439→	}
   440→
   441→	healthCounts, herr := repo.CountStates(ctx, filter, "health_state")
   442→	if herr != nil {
   443→		cclog.Warnf("Error while counting healthStates: %s", herr.Error())
   444→		return nil, herr
   445→	}
   446→
   447→	allCounts := append(stateCounts, healthCounts...)
   448→
   449→	return allCounts, nil
   450→}
   451→
   452→// NodeStatesTimed is the resolver for the nodeStatesTimed field.
   453→func (r *queryResolver) NodeStatesTimed(ctx context.Context, filter []*model.NodeFilter, typeArg string) ([]*model.NodeStatesTimed, error) {
   454→	repo := repository.GetNodeRepository()
   455→
   456→	if typeArg == "node" {
   457→		stateCounts, serr := repo.CountStatesTimed(ctx, filter, "node_state")
   458→		if serr != nil {
   459→			cclog.Warnf("Error while counting nodeStates in time: %s", serr.Error())
   460→			return nil, serr
   461→		}
   462→		return stateCounts, nil
   463→	}
   464→
   465→	if typeArg == "health" {
   466→		healthCounts, herr := repo.CountStatesTimed(ctx, filter, "health_state")
   467→		if herr != nil {
   468→			cclog.Warnf("Error while counting healthStates in time: %s", herr.Error())
   469→			return nil, herr
   470→		}
   471→		return healthCounts, nil
   472→	}
   473→
   474→	return nil, errors.New("unknown Node State Query Type")
   475→}
   476→
   477→// Job is the resolver for the job field.
   478→func (r *queryResolver) Job(ctx context.Context, id string) (*schema.Job, error) {
   479→	numericID, err := strconv.ParseInt(id, 10, 64)
   480→	if err != nil {
   481→		cclog.Warn("Error while parsing job id")
   482→		return nil, err
   483→	}
   484→
   485→	job, err := r.Repo.FindByID(ctx, numericID)
   486→	if err != nil {
   487→		cclog.Warn("Error while finding job by id")
   488→		return nil, err
   489→	}
   490→
   491→	if user := repository.GetUserFromContext(ctx); user != nil &&
   492→		job.User != user.Username &&
   493→		user.HasNotRoles([]schema.Role{schema.RoleAdmin, schema.RoleSupport, schema.RoleManager}) {
   494→		return nil, errors.New("you are not allowed to see this job")
   495→	}
   496→
   497→	return job, nil
   498→}
   499→
   500→// JobMetrics is the resolver for the jobMetrics field.
   501→func (r *queryResolver) JobMetrics(ctx context.Context, id string, metrics []string, scopes []schema.MetricScope, resolution *int) ([]*model.JobMetricWithName, error) {
   502→	if resolution == nil { // Load from Config
   503→		if config.Keys.EnableResampling != nil {
   504→			defaultRes := slices.Max(config.Keys.EnableResampling.Resolutions)
   505→			resolution = &defaultRes
   506→		} else { // Set 0 (Loads configured metric timestep)
   507→			defaultRes := 0
   508→			resolution = &defaultRes
   509→		}
   510→	}
   511→
   512→	job, err := r.Query().Job(ctx, id)
   513→	if err != nil {
   514→		cclog.Warn("Error while querying job for metrics")
   515→		return nil, err
   516→	}
   517→
   518→	data, err := metricdispatch.LoadData(job, metrics, scopes, ctx, *resolution)
   519→	if err != nil {
   520→		cclog.Warn("Error while loading job data")
   521→		return nil, err
   522→	}
   523→
   524→	res := []*model.JobMetricWithName{}
   525→	for name, md := range data {
   526→		for scope, metric := range md {
   527→			res = append(res, &model.JobMetricWithName{
   528→				Name:   name,
   529→				Scope:  scope,
   530→				Metric: metric,
   531→			})
   532→		}
   533→	}
   534→
   535→	return res, err
   536→}
   537→
   538→// JobStats is the resolver for the jobStats field.
   539→func (r *queryResolver) JobStats(ctx context.Context, id string, metrics []string) ([]*model.NamedStats, error) {
   540→	job, err := r.Query().Job(ctx, id)
   541→	if err != nil {
   542→		cclog.Warnf("Error while querying job %s for metadata", id)
   543→		return nil, err
   544→	}
   545→
   546→	data, err := metricdispatch.LoadJobStats(job, metrics, ctx)
   547→	if err != nil {
   548→		cclog.Warnf("Error while loading jobStats data for job id %s", id)
   549→		return nil, err
   550→	}
   551→
   552→	res := []*model.NamedStats{}
   553→	for name, md := range data {
   554→		res = append(res, &model.NamedStats{
   555→			Name: name,
   556→			Data: &md,
   557→		})
   558→	}
   559→
   560→	return res, err
   561→}
   562→
   563→// ScopedJobStats is the resolver for the scopedJobStats field.
   564→func (r *queryResolver) ScopedJobStats(ctx context.Context, id string, metrics []string, scopes []schema.MetricScope) ([]*model.NamedStatsWithScope, error) {
   565→	job, err := r.Query().Job(ctx, id)
   566→	if err != nil {
   567→		cclog.Warnf("Error while querying job %s for metadata", id)
   568→		return nil, err
   569→	}
   570→
   571→	data, err := metricdispatch.LoadScopedJobStats(job, metrics, scopes, ctx)
   572→	if err != nil {
   573→		cclog.Warnf("Error while loading scopedJobStats data for job id %s", id)
   574→		return nil, err
   575→	}
   576→
   577→	res := make([]*model.NamedStatsWithScope, 0)
   578→	for name, scoped := range data {
   579→		for scope, stats := range scoped {
   580→
   581→			mdlStats := make([]*model.ScopedStats, 0)
   582→			for _, stat := range stats {
   583→				mdlStats = append(mdlStats, &model.ScopedStats{
   584→					Hostname: stat.Hostname,
   585→					ID:       stat.ID,
   586→					Data:     stat.Data,
   587→				})
   588→			}
   589→
   590→			res = append(res, &model.NamedStatsWithScope{
   591→				Name:  name,
   592→				Scope: scope,
   593→				Stats: mdlStats,
   594→			})
   595→		}
   596→	}
   597→
   598→	return res, nil
   599→}
   600→
   601→// Jobs is the resolver for the jobs field.
   602→func (r *queryResolver) Jobs(ctx context.Context, filter []*model.JobFilter, page *model.PageRequest, order *model.OrderByInput) (*model.JobResultList, error) {
   603→	if page == nil {
   604→		page = &model.PageRequest{
   605→			ItemsPerPage: 50,
   606→			Page:         1,
   607→		}
   608→	}
   609→
   610→	jobs, err := r.Repo.QueryJobs(ctx, filter, page, order)
   611→	if err != nil {
   612→		cclog.Warn("Error while querying jobs")
   613→		return nil, err
   614→	}
   615→
   616→	count, err := r.Repo.CountJobs(ctx, filter)
   617→	if err != nil {
   618→		cclog.Warn("Error while counting jobs")
   619→		return nil, err
   620→	}
   621→
   622→	// Note: Even if App-Default 'config.Keys.UiDefaults["job_list_usePaging"]' is set, always return hasNextPage boolean.
   623→	// Users can decide in frontend to use continuous scroll, even if app-default is paging!
   624→	// Skip if page.ItemsPerPage == -1 ("Load All" -> No Next Page required, Status Dashboards)
   625→	/*
   626→	  Example Page 4 @ 10 IpP : Does item 41 exist?
   627→	  Minimal Page 41 @ 1 IpP : If len(result) is 1, Page 5 @ 10 IpP exists.
   628→	*/
   629→	hasNextPage := false
   630→	if page.ItemsPerPage != -1 {
   631→		nextPage := &model.PageRequest{
   632→			ItemsPerPage: 1,
   633→			Page:         ((page.Page * page.ItemsPerPage) + 1),
   634→		}
   635→		nextJobs, err := r.Repo.QueryJobs(ctx, filter, nextPage, order)
   636→		if err != nil {
   637→			cclog.Warn("Error while querying next jobs")
   638→			return nil, err
   639→		}
   640→		hasNextPage = len(nextJobs) == 1
   641→	}
   642→
   643→	return &model.JobResultList{Items: jobs, Count: &count, HasNextPage: &hasNextPage}, nil
   644→}
   645→
   646→// JobsStatistics is the resolver for the jobsStatistics field.
   647→func (r *queryResolver) 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) {
   648→	var err error
   649→	var stats []*model.JobsStatistics
   650→
   651→	// Top Level Defaults
   652→	defaultDurationBins := "1h"
   653→	defaultMetricBins := 10
   654→
   655→	// Build requested fields map for selective column computation
   656→	statsFields := []string{
   657→		"totalJobs", "totalUsers", "totalWalltime", "totalNodes", "totalCores",
   658→		"totalAccs", "totalNodeHours", "totalCoreHours", "totalAccHours", "runningJobs", "shortJobs",
   659→	}
   660→	reqFields := make(map[string]bool, len(statsFields))
   661→	fetchedMainStats := false
   662→	for _, f := range statsFields {
   663→		if requireField(ctx, f) {
   664→			reqFields[f] = true
   665→			if f != "runningJobs" && f != "shortJobs" {
   666→				fetchedMainStats = true
   667→			}
   668→		}
   669→	}
   670→
   671→	if fetchedMainStats {
   672→		if groupBy == nil {
   673→			stats, err = r.Repo.JobsStats(ctx, filter, reqFields)
   674→		} else {
   675→			startGrouped := time.Now()
   676→			// Use request-scoped cache: multiple aliases with same (filter, groupBy)
   677→			// but different sortBy/page hit the DB only once.
   678→			if cache := getStatsGroupCache(ctx); cache != nil {
   679→				key := statsCacheKey(filter, groupBy, reqFields)
   680→				var allStats []*model.JobsStatistics
   681→				allStats, err = cache.getOrCompute(key, func() ([]*model.JobsStatistics, error) {
   682→					return r.Repo.JobsStatsGrouped(ctx, filter, nil, nil, groupBy, reqFields)
   683→				})
   684→				if err == nil {
   685→					stats = sortAndPageStats(allStats, sortBy, page)
   686→				}
   687→			} else {
   688→				stats, err = r.Repo.JobsStatsGrouped(ctx, filter, page, sortBy, groupBy, reqFields)
   689→			}
   690→			cclog.Infof("Timer JobsStatsGrouped call: %s", time.Since(startGrouped))
   691→		}
   692→	} else {
   693→		stats = make([]*model.JobsStatistics, 0, 1)
   694→		stats = append(stats, &model.JobsStatistics{})
   695→	}
   696→
   697→	// runningJobs and shortJobs are already inlined in JobsStats/JobsStatsGrouped.
   698→	// Only run separate count queries if main stats were not fetched.
   699→	if !fetchedMainStats {
   700→		if groupBy != nil {
   701→			if requireField(ctx, "shortJobs") {
   702→				stats, err = r.Repo.AddJobCountGrouped(ctx, filter, groupBy, stats, "short")
   703→			}
   704→			if requireField(ctx, "runningJobs") {
   705→				stats, err = r.Repo.AddJobCountGrouped(ctx, filter, groupBy, stats, "running")
   706→			}
   707→		} else {
   708→			if requireField(ctx, "shortJobs") {
   709→				stats, err = r.Repo.AddJobCount(ctx, filter, stats, "short")
   710→			}
   711→			if requireField(ctx, "runningJobs") {
   712→				stats, err = r.Repo.AddJobCount(ctx, filter, stats, "running")
   713→			}
   714→		}
   715→	}
   716→
   717→	if err != nil {
   718→		return nil, err
   719→	}
   720→
   721→	if requireField(ctx, "histDuration") || requireField(ctx, "histNumNodes") || requireField(ctx, "histNumCores") || requireField(ctx, "histNumAccs") {
   722→
   723→		if numDurationBins == nil {
   724→			numDurationBins = &defaultDurationBins
   725→		}
   726→
   727→		if groupBy == nil {
   728→			stats[0], err = r.Repo.AddHistograms(ctx, filter, stats[0], numDurationBins)
   729→			if err != nil {
   730→				return nil, err
   731→			}
   732→		} else {
   733→			return nil, errors.New("histograms only implemented without groupBy argument")
   734→		}
   735→	}
   736→
   737→	if requireField(ctx, "histMetrics") {
   738→
   739→		if numMetricBins == nil {
   740→			numMetricBins = &defaultMetricBins
   741→		}
   742→
   743→		if groupBy == nil {
   744→			stats[0], err = r.Repo.AddMetricHistograms(ctx, filter, metrics, stats[0], numMetricBins)
   745→			if err != nil {
   746→				return nil, err
   747→			}
   748→		} else {
   749→			return nil, errors.New("metric histograms only implemented without groupBy argument")
   750→		}
   751→	}
   752→
   753→	return stats, nil
   754→}
   755→
   756→// JobsMetricStats is the resolver for the jobsMetricStats field.
   757→func (r *queryResolver) JobsMetricStats(ctx context.Context, filter []*model.JobFilter, metrics []string) ([]*model.JobStats, error) {
   758→	// No Paging, Fixed Order by StartTime ASC
   759→	order := &model.OrderByInput{
   760→		Field: "startTime",
   761→		Type:  "col",
   762→		Order: "ASC",
   763→	}
   764→
   765→	jobs, err := r.Repo.QueryJobs(ctx, filter, nil, order)
   766→	if err != nil {
   767→		cclog.Warn("Error while querying jobs for comparison")
   768→		return nil, err
   769→	}
   770→
   771→	res := []*model.JobStats{}
   772→	for _, job := range jobs {
   773→		data, err := metricdispatch.LoadJobStats(job, metrics, ctx)
   774→		if err != nil {
   775→			cclog.Warnf("Error while loading comparison jobStats data for job id %d", job.JobID)
   776→			continue
   777→			// return nil, err
   778→		}
   779→
   780→		sres := []*model.NamedStats{}
   781→		for name, md := range data {
   782→			sres = append(sres, &model.NamedStats{
   783→				Name: name,
   784→				Data: &md,
   785→			})
   786→		}
   787→
   788→		numThreadsInt := int(job.NumHWThreads)
   789→		numAccsInt := int(job.NumAcc)
   790→		res = append(res, &model.JobStats{
   791→			ID:              int(*job.ID),
   792→			JobID:           strconv.Itoa(int(job.JobID)),
   793→			StartTime:       int(job.StartTime),
   794→			Duration:        int(job.Duration),
   795→			Cluster:         job.Cluster,
   796→			SubCluster:      job.SubCluster,
   797→			NumNodes:        int(job.NumNodes),
   798→			NumHWThreads:    &numThreadsInt,
   799→			NumAccelerators: &numAccsInt,
   800→			Stats:           sres,
   801→		})
   802→	}
   803→	return res, err
   804→}
   805→
   806→// JobsFootprints is the resolver for the jobsFootprints field.
   807→func (r *queryResolver) JobsFootprints(ctx context.Context, filter []*model.JobFilter, metrics []string) (*model.Footprints, error) {
   808→	// NOTE: Legacy Naming! This resolver is for normalized histograms in analysis view only - *Not* related to DB "footprint" column!
   809→	return r.jobsFootprints(ctx, filter, metrics)
   810→}
   811→
   812→// RooflineHeatmap is the resolver for the rooflineHeatmap field.
   813→func (r *queryResolver) RooflineHeatmap(ctx context.Context, filter []*model.JobFilter, rows int, cols int, minX float64, minY float64, maxX float64, maxY float64) ([][]float64, error) {
   814→	return r.rooflineHeatmap(ctx, filter, rows, cols, minX, minY, maxX, maxY)
   815→}
   816→
   817→// NodeMetrics is the resolver for the nodeMetrics field.
   818→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) {
   819→	user := repository.GetUserFromContext(ctx)
   820→	if user != nil && !user.HasAnyRole([]schema.Role{schema.RoleAdmin, schema.RoleSupport}) {
   821→		return nil, errors.New("you need to be administrator or support staff for this query")
   822→	}
   823→
   824→	defaultMetrics := make([]string, 0)
   825→	for _, mc := range archive.GetCluster(cluster).MetricConfig {
   826→		defaultMetrics = append(defaultMetrics, mc.Name)
   827→	}
   828→	if metrics == nil {
   829→		metrics = defaultMetrics
   830→	} else {
   831→		metrics = slices.DeleteFunc(metrics, func(metric string) bool {
   832→			return !slices.Contains(defaultMetrics, metric) // Remove undefined metrics.
   833→		})
   834→	}
   835→
   836→	data, err := metricdispatch.LoadNodeData(cluster, metrics, nodes, scopes, from, to, ctx)
   837→	if err != nil {
   838→		cclog.Warn("error while loading node data")
   839→		return nil, err
   840→	}
   841→
   842→	nodeRepo := repository.GetNodeRepository()
   843→	stateMap, _ := nodeRepo.MapNodes(cluster)
   844→
   845→	nodeMetrics := make([]*model.NodeMetrics, 0, len(data))
   846→	for hostname, metrics := range data {
   847→		host := &model.NodeMetrics{
   848→			Host:    hostname,
   849→			State:   stateMap[hostname],
   850→			Metrics: make([]*model.JobMetricWithName, 0, len(metrics)*len(scopes)),
   851→		}
   852→		host.SubCluster, err = archive.GetSubClusterByNode(cluster, hostname)
   853→		if err != nil {
   854→			cclog.Warnf("error in nodeMetrics resolver: %s", err)
   855→		}
   856→
   857→		for metric, scopedMetrics := range metrics {
   858→			for _, scopedMetric := range scopedMetrics {
   859→				host.Metrics = append(host.Metrics, &model.JobMetricWithName{
   860→					Name:   metric,
   861→					Scope:  schema.MetricScopeNode,
   862→					Metric: scopedMetric,
   863→				})
   864→			}
   865→		}
   866→
   867→		nodeMetrics = append(nodeMetrics, host)
   868→	}
   869→
   870→	return nodeMetrics, nil
   871→}
   872→
   873→// NodeMetricsList is the resolver for the nodeMetricsList field.
   874→func (r *queryResolver) NodeMetricsList(ctx context.Context, cluster string, subCluster string, stateFilter string, nodeFilter string, scopes []schema.MetricScope, metrics []string, from time.Time, to time.Time, page *model.PageRequest, resolution *int) (*model.NodesResultList, error) {
   875→	if resolution == nil { // Load from Config
   876→		if config.Keys.EnableResampling != nil {
   877→			defaultRes := slices.Max(config.Keys.EnableResampling.Resolutions)
   878→			resolution = &defaultRes
   879→		} else { // Set 0 (Loads configured metric timestep)
   880→			defaultRes := 0
   881→			resolution = &defaultRes
   882→		}
   883→	}
   884→
   885→	user := repository.GetUserFromContext(ctx)
   886→	if user != nil && !user.HasAnyRole([]schema.Role{schema.RoleAdmin, schema.RoleSupport}) {
   887→		return nil, errors.New("you need to be administrator or support staff for this query")
   888→	}
   889→
   890→	nodeRepo := repository.GetNodeRepository()
   891→	// nodes -> array hostname
   892→	nodes, stateMap, countNodes, hasNextPage, nerr := nodeRepo.GetNodesForList(ctx, cluster, subCluster, stateFilter, nodeFilter, page)
   893→	if nerr != nil {
   894→		return nil, errors.New("could not retrieve node list required for resolving NodeMetricsList")
   895→	}
   896→
   897→	if metrics == nil {
   898→		for _, mc := range archive.GetCluster(cluster).MetricConfig {
   899→			metrics = append(metrics, mc.Name)
   900→		}
   901→	}
   902→
   903→	// data -> map hostname:jobdata
   904→	data, err := metricdispatch.LoadNodeListData(cluster, subCluster, nodes, metrics, scopes, *resolution, from, to, ctx)
   905→	if err != nil {
   906→		cclog.Warn("error while loading node data (Resolver.NodeMetricsList")
   907→		return nil, err
   908→	}
   909→
   910→	nodeMetricsList := make([]*model.NodeMetrics, 0, len(data))
   911→	for _, hostname := range nodes {
   912→		host := &model.NodeMetrics{
   913→			Host:    hostname,
   914→			State:   stateMap[hostname],
   915→			Metrics: make([]*model.JobMetricWithName, 0),
   916→		}
   917→		host.SubCluster, err = archive.GetSubClusterByNode(cluster, hostname)
   918→		if err != nil {
   919→			cclog.Warnf("error in nodeMetrics resolver: %s", err)
   920→		}
   921→
   922→		for metric, scopedMetrics := range data[hostname] {
   923→			for scope, scopedMetric := range scopedMetrics {
   924→				host.Metrics = append(host.Metrics, &model.JobMetricWithName{
   925→					Name:   metric,
   926→					Scope:  scope,
   927→					Metric: scopedMetric,
   928→				})
   929→			}
   930→		}
   931→
   932→		nodeMetricsList = append(nodeMetricsList, host)
   933→	}
   934→
   935→	nodeMetricsListResult := &model.NodesResultList{
   936→		Items: nodeMetricsList,
   937→		// TotalNodes depends on sum of nodes grouped on latest timestamp, see repo/node.go:357
   938→		TotalNodes:  &countNodes,
   939→		HasNextPage: &hasNextPage,
   940→	}
   941→
   942→	return nodeMetricsListResult, nil
   943→}
   944→
   945→// ClusterMetrics is the resolver for the clusterMetrics field.
   946→func (r *queryResolver) ClusterMetrics(ctx context.Context, cluster string, metrics []string, from time.Time, to time.Time) (*model.ClusterMetrics, error) {
   947→	user := repository.GetUserFromContext(ctx)
   948→	if user != nil && !user.HasAnyRole([]schema.Role{schema.RoleAdmin, schema.RoleSupport}) {
   949→		return nil, errors.New("you need to be administrator or support staff for this query")
   950→	}
   951→
   952→	if metrics == nil {
   953→		for _, mc := range archive.GetCluster(cluster).MetricConfig {
   954→			metrics = append(metrics, mc.Name)
   955→		}
   956→	}
   957→
   958→	// 'nodes' == nil -> Defaults to all nodes of cluster for existing query workflow
   959→	scopes := []schema.MetricScope{"node"}
   960→	data, err := metricdispatch.LoadNodeData(cluster, metrics, nil, scopes, from, to, ctx)
   961→	if err != nil {
   962→		cclog.Warn("error while loading node data")
   963→		return nil, err
   964→	}
   965→
   966→	clusterMetricData := make([]*model.ClusterMetricWithName, 0)
   967→	clusterMetrics := model.ClusterMetrics{NodeCount: 0, Metrics: clusterMetricData}
   968→
   969→	collectorTimestep := make(map[string]int)
   970→	collectorUnit := make(map[string]schema.Unit)
   971→	collectorData := make(map[string][]schema.Float)
   972→
   973→	for _, metrics := range data {
   974→		clusterMetrics.NodeCount += 1
   975→		for metric, scopedMetrics := range metrics {
   976→			for _, scopedMetric := range scopedMetrics {
   977→				// Collect Info Once
   978→				_, okTimestep := collectorTimestep[metric]
   979→				if !okTimestep {
   980→					collectorTimestep[metric] = scopedMetric.Timestep
   981→				}
   982→				_, okUnit := collectorUnit[metric]
   983→				if !okUnit {
   984→					collectorUnit[metric] = scopedMetric.Unit
   985→				}
   986→				// Collect Data
   987→				for _, ser := range scopedMetric.Series {
   988→					_, okData := collectorData[metric]
   989→					// Init With Datasize > 0
   990→					if !okData && len(ser.Data) != 0 {
   991→						collectorData[metric] = make([]schema.Float, len(ser.Data))
   992→					} else if !okData {
   993→						cclog.Debugf("[SCHEMARESOLVER] clusterMetrics skip init: no data -> %s at %s; size %d", metric, ser.Hostname, len(ser.Data))
   994→					}
   995→					// Sum if init'd and matching size
   996→					if okData && len(ser.Data) == len(collectorData[metric]) {
   997→						for i, val := range ser.Data {
   998→							if val.IsNaN() {
   999→								continue
  1000→							} else {
  1001→								collectorData[metric][i] += val
  1002→							}
  1003→						}
  1004→					} else if okData {
  1005→						cclog.Debugf("[SCHEMARESOLVER] clusterMetrics skip sum: data diff -> %s at %s; want size %d, have size %d", metric, ser.Hostname, len(collectorData[metric]), len(ser.Data))
  1006→					}
  1007→				}
  1008→			}
  1009→		}
  1010→	}
  1011→
  1012→	for metricName, data := range collectorData {
  1013→		// use ccUnits for backend normalization to "Tera"
  1014→		p_old := ccunit.NewPrefix(collectorUnit[metricName].Prefix)
  1015→		p_new := ccunit.NewPrefix("T")
  1016→		convFunc := ccunit.GetPrefixPrefixFactor(p_old, p_new)
  1017→		u_new := schema.Unit{Prefix: p_new.Prefix(), Base: collectorUnit[metricName].Base}
  1018→
  1019→		roundedData := make([]schema.Float, 0)
  1020→		for _, v_old := range data {
  1021→			v_new := math.Round(convFunc(float64(v_old)).(float64)*100.0) / 100.0
  1022→			roundedData = append(roundedData, schema.Float(v_new))
  1023→		}
  1024→
  1025→		cm := model.ClusterMetricWithName{
  1026→			Name:     metricName,
  1027→			Unit:     &u_new,
  1028→			Timestep: collectorTimestep[metricName],
  1029→			Data:     roundedData,
  1030→		}
  1031→
  1032→		clusterMetrics.Metrics = append(clusterMetrics.Metrics, &cm)
  1033→	}
  1034→
  1035→	return &clusterMetrics, nil
  1036→}
  1037→
  1038→// NumberOfNodes is the resolver for the numberOfNodes field.
  1039→func (r *subClusterResolver) NumberOfNodes(ctx context.Context, obj *schema.SubCluster) (int, error) {
  1040→	nodeList, err := archive.ParseNodeList(obj.Nodes)
  1041→	if err != nil {
  1042→		return 0, err
  1043→	}
  1044→	return nodeList.NodeCount(), nil
  1045→}
  1046→
  1047→// Cluster returns generated.ClusterResolver implementation.
  1048→func (r *Resolver) Cluster() generated.ClusterResolver { return &clusterResolver{r} }
  1049→
  1050→// Job returns generated.JobResolver implementation.
  1051→func (r *Resolver) Job() generated.JobResolver { return &jobResolver{r} }
  1052→
  1053→// MetricValue returns generated.MetricValueResolver implementation.
  1054→func (r *Resolver) MetricValue() generated.MetricValueResolver { return &metricValueResolver{r} }
  1055→
  1056→// Mutation returns generated.MutationResolver implementation.
  1057→func (r *Resolver) Mutation() generated.MutationResolver { return &mutationResolver{r} }
  1058→
  1059→// Node returns generated.NodeResolver implementation.
  1060→func (r *Resolver) Node() generated.NodeResolver { return &nodeResolver{r} }
  1061→
  1062→// Query returns generated.QueryResolver implementation.
  1063→func (r *Resolver) Query() generated.QueryResolver { return &queryResolver{r} }
  1064→
  1065→// SubCluster returns generated.SubClusterResolver implementation.
  1066→func (r *Resolver) SubCluster() generated.SubClusterResolver { return &subClusterResolver{r} }
  1067→
  1068→type clusterResolver struct{ *Resolver }
  1069→type jobResolver struct{ *Resolver }
  1070→type metricValueResolver struct{ *Resolver }
  1071→type mutationResolver struct{ *Resolver }
  1072→type nodeResolver struct{ *Resolver }
  1073→type queryResolver struct{ *Resolver }
  1074→type subClusterResolver struct{ *Resolver }
  1075→


     1→scalar Time
     2→scalar Any
     3→
     4→scalar NullableFloat
     5→scalar MetricScope
     6→scalar JobState
     7→scalar SchedulerState
     8→scalar MonitoringState
     9→
    10→type Node {
    11→  id: ID!
    12→  hostname: String!
    13→  cluster: String!
    14→  subCluster: String!
    15→  jobsRunning: Int!
    16→  cpusAllocated: Int
    17→  memoryAllocated: Int
    18→  gpusAllocated: Int
    19→  schedulerState: SchedulerState!
    20→  healthState: MonitoringState!
    21→  metaData: Any
    22→  healthData: Any
    23→}
    24→
    25→type NodeStates {
    26→  state: String!
    27→  count: Int!
    28→}
    29→
    30→type NodeStatesTimed {
    31→  state: String!
    32→  counts: [Int!]!
    33→  times: [Int!]!
    34→}
    35→
    36→type Job {
    37→  id: ID!
    38→  jobId: Int!
    39→  user: String!
    40→  project: String!
    41→  cluster: String!
    42→  subCluster: String!
    43→  startTime: Time!
    44→  duration: Int!
    45→  walltime: Int!
    46→  numNodes: Int!
    47→  numHWThreads: Int!
    48→  numAcc: Int!
    49→  energy: Float!
    50→  SMT: Int!
    51→  shared: String!
    52→  partition: String!
    53→  arrayJobId: Int!
    54→  monitoringStatus: Int!
    55→  state: JobState!
    56→  tags: [Tag!]!
    57→  resources: [Resource!]!
    58→  concurrentJobs: JobLinkResultList
    59→  footprint: [FootprintValue]
    60→  energyFootprint: [EnergyFootprintValue]
    61→  metaData: Any
    62→  userData: User
    63→}
    64→
    65→type JobLink {
    66→  id: ID!
    67→  jobId: Int!
    68→}
    69→
    70→type Cluster {
    71→  name: String!
    72→  partitions: [String!]! # Slurm partitions
    73→  subClusters: [SubCluster!]! # Hardware partitions/subclusters
    74→}
    75→
    76→type SubCluster {
    77→  name: String!
    78→  nodes: String!
    79→  numberOfNodes: Int!
    80→  processorType: String!
    81→  socketsPerNode: Int!
    82→  coresPerSocket: Int!
    83→  threadsPerCore: Int!
    84→  flopRateScalar: MetricValue!
    85→  flopRateSimd: MetricValue!
    86→  memoryBandwidth: MetricValue!
    87→  topology: Topology!
    88→  metricConfig: [MetricConfig!]!
    89→  footprint: [String!]!
    90→}
    91→
    92→type FootprintValue {
    93→  name: String!
    94→  stat: String!
    95→  value: Float!
    96→}
    97→
    98→type EnergyFootprintValue {
    99→  hardware: String!
   100→  metric: String!
   101→  value: Float!
   102→}
   103→
   104→type MetricValue {
   105→  name: String
   106→  unit: Unit!
   107→  value: Float!
   108→}
   109→
   110→type Topology {
   111→  node: [Int!]
   112→  socket: [[Int!]!]
   113→  memoryDomain: [[Int!]!]
   114→  die: [[Int!]!]
   115→  core: [[Int!]!]
   116→  accelerators: [Accelerator!]
   117→}
   118→
   119→type Accelerator {
   120→  id: String!
   121→  type: String!
   122→  model: String!
   123→}
   124→
   125→type SubClusterConfig {
   126→  name: String!
   127→  peak: Float
   128→  normal: Float
   129→  caution: Float
   130→  alert: Float
   131→  remove: Boolean
   132→}
   133→
   134→type MetricConfig {
   135→  name: String!
   136→  unit: Unit!
   137→  scope: MetricScope!
   138→  aggregation: String!
   139→  timestep: Int!
   140→  peak: Float!
   141→  normal: Float
   142→  caution: Float!
   143→  alert: Float!
   144→  lowerIsBetter: Boolean
   145→  subClusters: [SubClusterConfig!]!
   146→}
   147→
   148→type Tag {
   149→  id: ID!
   150→  type: String!
   151→  name: String!
   152→  scope: String!
   153→}
   154→
   155→type Resource {
   156→  hostname: String!
   157→  hwthreads: [Int!]
   158→  accelerators: [String!]
   159→  configuration: String
   160→}
   161→
   162→type JobMetricWithName {
   163→  name: String!
   164→  scope: MetricScope!
   165→  metric: JobMetric!
   166→}
   167→
   168→type ClusterMetricWithName {
   169→  name: String!
   170→  unit: Unit
   171→  timestep: Int!
   172→  data: [NullableFloat!]!
   173→}
   174→
   175→type JobMetric {
   176→  unit: Unit
   177→  timestep: Int!
   178→  series: [Series!]
   179→  statisticsSeries: StatsSeries
   180→}
   181→
   182→type Series {
   183→  hostname: String!
   184→  id: String
   185→  statistics: MetricStatistics
   186→  data: [NullableFloat!]!
   187→}
   188→
   189→type StatsSeries {
   190→  mean: [NullableFloat!]!
   191→  median: [NullableFloat!]!
   192→  min: [NullableFloat!]!
   193→  max: [NullableFloat!]!
   194→}
   195→
   196→type NamedStatsWithScope {
   197→  name: String!
   198→  scope: MetricScope!
   199→  stats: [ScopedStats!]!
   200→}
   201→
   202→type ScopedStats {
   203→  hostname: String!
   204→  id: String
   205→  data: MetricStatistics!
   206→}
   207→
   208→type JobStats {
   209→  id: Int!
   210→  jobId: String!
   211→  startTime: Int!
   212→  duration: Int!
   213→  cluster: String!
   214→  subCluster: String!
   215→  numNodes: Int!
   216→  numHWThreads: Int
   217→  numAccelerators: Int
   218→  stats: [NamedStats!]!
   219→}
   220→
   221→type NamedStats {
   222→  name: String!
   223→  data: MetricStatistics!
   224→}
   225→
   226→type Unit {
   227→  base: String!
   228→  prefix: String
   229→}
   230→
   231→type MetricStatistics {
   232→  avg: Float!
   233→  min: Float!
   234→  max: Float!
   235→}
   236→
   237→type MetricFootprints {
   238→  metric: String!
   239→  data: [NullableFloat!]!
   240→}
   241→
   242→type Footprints {
   243→  timeWeights: TimeWeights!
   244→  metrics: [MetricFootprints!]!
   245→}
   246→
   247→type TimeWeights {
   248→  nodeHours: [NullableFloat!]!
   249→  accHours: [NullableFloat!]!
   250→  coreHours: [NullableFloat!]!
   251→}
   252→
   253→enum Aggregate {
   254→  USER
   255→  PROJECT
   256→  CLUSTER
   257→  SUBCLUSTER
   258→}
   259→enum SortByAggregate {
   260→  TOTALWALLTIME
   261→  TOTALJOBS
   262→  TOTALUSERS
   263→  TOTALNODES
   264→  TOTALNODEHOURS
   265→  TOTALCORES
   266→  TOTALCOREHOURS
   267→  TOTALACCS
   268→  TOTALACCHOURS
   269→}
   270→
   271→type NodeMetrics {
   272→  host: String!
   273→  state: String!
   274→  subCluster: String!
   275→  metrics: [JobMetricWithName!]!
   276→}
   277→
   278→type ClusterMetrics {
   279→  nodeCount: Int!
   280→  metrics: [ClusterMetricWithName!]!
   281→}
   282→
   283→type NodesResultList {
   284→  items: [NodeMetrics!]!
   285→  offset: Int
   286→  limit: Int
   287→  count: Int
   288→  totalNodes: Int
   289→  hasNextPage: Boolean
   290→}
   291→
   292→type ClusterSupport {
   293→  cluster: String!
   294→  subClusters: [String!]!
   295→}
   296→
   297→type GlobalMetricListItem {
   298→  name: String!
   299→  unit: Unit!
   300→  scope: MetricScope!
   301→  footprint: String
   302→  availability: [ClusterSupport!]!
   303→}
   304→
   305→type Count {
   306→  name: String!
   307→  count: Int!
   308→}
   309→
   310→type User {
   311→  username: String!
   312→  name: String!
   313→  email: String!
   314→}
   315→
   316→input MetricStatItem {
   317→  metricName: String!
   318→  range: FloatRange!
   319→}
   320→
   321→type Query {
   322→  clusters: [Cluster!]! # List of all clusters
   323→  tags: [Tag!]! # List of all tags
   324→  globalMetrics: [GlobalMetricListItem!]!
   325→
   326→  user(username: String!): User
   327→  allocatedNodes(cluster: String!): [Count!]!
   328→
   329→  ## Node Queries New
   330→  node(id: ID!): Node
   331→  nodes(filter: [NodeFilter!], order: OrderByInput): NodeStateResultList!
   332→  nodesWithMeta(filter: [NodeFilter!], order: OrderByInput): NodeStateResultList!
   333→  nodeStates(filter: [NodeFilter!]): [NodeStates!]!
   334→  nodeStatesTimed(filter: [NodeFilter!], type: String!): [NodeStatesTimed!]!
   335→
   336→  job(id: ID!): Job
   337→  jobMetrics(
   338→    id: ID!
   339→    metrics: [String!]
   340→    scopes: [MetricScope!]
   341→    resolution: Int
   342→  ): [JobMetricWithName!]!
   343→
   344→  jobStats(id: ID!, metrics: [String!]): [NamedStats!]!
   345→
   346→  scopedJobStats(
   347→    id: ID!
   348→    metrics: [String!]
   349→    scopes: [MetricScope!]
   350→  ): [NamedStatsWithScope!]!
   351→
   352→  jobs(
   353→    filter: [JobFilter!]
   354→    page: PageRequest
   355→    order: OrderByInput
   356→  ): JobResultList!
   357→
   358→  jobsStatistics(
   359→    filter: [JobFilter!]
   360→    metrics: [String!]
   361→    page: PageRequest
   362→    sortBy: SortByAggregate
   363→    groupBy: Aggregate
   364→    numDurationBins: String
   365→    numMetricBins: Int
   366→  ): [JobsStatistics!]!
   367→
   368→  jobsMetricStats(filter: [JobFilter!], metrics: [String!]): [JobStats!]!
   369→  jobsFootprints(filter: [JobFilter!], metrics: [String!]!): Footprints
   370→
   371→  rooflineHeatmap(
   372→    filter: [JobFilter!]!
   373→    rows: Int!
   374→    cols: Int!
   375→    minX: Float!
   376→    minY: Float!
   377→    maxX: Float!
   378→    maxY: Float!
   379→  ): [[Float!]!]!
   380→
   381→  nodeMetrics(
   382→    cluster: String!
   383→    nodes: [String!]
   384→    scopes: [MetricScope!]
   385→    metrics: [String!]
   386→    from: Time!
   387→    to: Time!
   388→  ): [NodeMetrics!]!
   389→
   390→  nodeMetricsList(
   391→    cluster: String!
   392→    subCluster: String!
   393→    stateFilter: String!
   394→    nodeFilter: String!
   395→    scopes: [MetricScope!]
   396→    metrics: [String!]
   397→    from: Time!
   398→    to: Time!
   399→    page: PageRequest
   400→    resolution: Int
   401→  ): NodesResultList!
   402→
   403→  clusterMetrics(
   404→    cluster: String!
   405→    metrics: [String!]
   406→    from: Time!
   407→    to: Time!
   408→  ): ClusterMetrics!
   409→}
   410→
   411→type Mutation {
   412→  createTag(type: String!, name: String!, scope: String!): Tag!
   413→  deleteTag(id: ID!): ID!
   414→  addTagsToJob(job: ID!, tagIds: [ID!]!): [Tag!]!
   415→  removeTagsFromJob(job: ID!, tagIds: [ID!]!): [Tag!]!
   416→  removeTagFromList(tagIds: [ID!]!): [Int!]!
   417→
   418→  updateConfiguration(name: String!, value: String!): String
   419→}
   420→
   421→type IntRangeOutput {
   422→  from: Int!
   423→  to: Int!
   424→}
   425→type TimeRangeOutput {
   426→  range: String
   427→  from: Time!
   428→  to: Time!
   429→}
   430→
   431→input NodeFilter {
   432→  hostname: StringInput
   433→  cluster: StringInput
   434→  subCluster: StringInput
   435→  schedulerState: SchedulerState
   436→  healthState: MonitoringState
   437→  timeStart: Int
   438→}
   439→
   440→input JobFilter {
   441→  tags: [ID!]
   442→  dbId: [ID!]
   443→  jobId: StringInput
   444→  arrayJobId: Int
   445→  user: StringInput
   446→  project: StringInput
   447→  jobName: StringInput
   448→  cluster: StringInput
   449→  subCluster: StringInput
   450→  partition: StringInput
   451→  duration: IntRange
   452→  energy: FloatRange
   453→
   454→  minRunningFor: Int
   455→
   456→  numNodes: IntRange
   457→  numAccelerators: IntRange
   458→  numHWThreads: IntRange
   459→
   460→  startTime: TimeRange
   461→  state: [JobState!]
   462→  metricStats: [MetricStatItem!]
   463→  shared: String
   464→  schedule: String
   465→  node: StringInput
   466→}
   467→
   468→input OrderByInput {
   469→  field: String!
   470→  type: String!
   471→  order: SortDirectionEnum! = ASC
   472→}
   473→
   474→enum SortDirectionEnum {
   475→  DESC
   476→  ASC
   477→}
   478→
   479→input StringInput {
   480→  eq: String
   481→  neq: String
   482→  contains: String
   483→  startsWith: String
   484→  endsWith: String
   485→  in: [String!]
   486→}
   487→
   488→input IntRange {
   489→  from: Int!
   490→  to: Int!
   491→}
   492→input TimeRange {
   493→  range: String
   494→  from: Time
   495→  to: Time
   496→}
   497→
   498→input FloatRange {
   499→  from: Float!
   500→  to: Float!
   501→}
   502→
   503→type NodeStateResultList {
   504→  items: [Node!]!
   505→  count: Int
   506→}
   507→
   508→type JobResultList {
   509→  items: [Job!]!
   510→  offset: Int
   511→  limit: Int
   512→  count: Int
   513→  hasNextPage: Boolean
   514→}
   515→
   516→type JobLinkResultList {
   517→  listQuery: String
   518→  items: [JobLink!]!
   519→  count: Int
   520→}
   521→
   522→type HistoPoint {
   523→  count: Int!
   524→  value: Int!
   525→}
   526→
   527→type MetricHistoPoints {
   528→  metric: String!
   529→  unit: String!
   530→  stat: String
   531→  data: [MetricHistoPoint!]
   532→}
   533→
   534→type MetricHistoPoint {
   535→  bin: Int
   536→  count: Int!
   537→  min: Int
   538→  max: Int
   539→}
   540→
   541→type JobsStatistics {
   542→  id: ID! # If `groupBy` was used, ID of the user/project/cluster/subcluster
   543→  name: String! # if User-Statistics: Given Name of Account (ID) Owner
   544→  totalUsers: Int! # if *not* User-Statistics: Number of active users (based on running jobs)
   545→  totalJobs: Int! # Number of jobs
   546→  runningJobs: Int! # Number of running jobs
   547→  shortJobs: Int! # Number of jobs with a duration of less than config'd ShortRunningJobsDuration
   548→  totalWalltime: Int! # Sum of the duration of all matched jobs in hours
   549→  totalNodes: Int! # Sum of the nodes of all matched jobs
   550→  totalNodeHours: Int! # Sum of the node hours of all matched jobs
   551→  totalCores: Int! # Sum of the cores of all matched jobs
   552→  totalCoreHours: Int! # Sum of the core hours of all matched jobs
   553→  totalAccs: Int! # Sum of the accs of all matched jobs
   554→  totalAccHours: Int! # Sum of the gpu hours of all matched jobs
   555→  histDuration: [HistoPoint!]! # value: hour, count: number of jobs with a rounded duration of value
   556→  histNumNodes: [HistoPoint!]! # value: number of nodes, count: number of jobs with that number of nodes
   557→  histNumCores: [HistoPoint!]! # value: number of cores, count: number of jobs with that number of cores
   558→  histNumAccs: [HistoPoint!]! # value: number of accs, count: number of jobs with that number of accs
   559→  histMetrics: [MetricHistoPoints!]! # metric: metricname, data array of histopoints: value: metric average bin, count: number of jobs with that metric average
   560→}
   561→
   562→input PageRequest {
   563→  itemsPerPage: Int!
   564→  page: Int!
   565→}
   566→


     1→// Copyright (C) NHR@FAU, University Erlangen-Nuremberg.
     2→// All rights reserved. This file is part of cc-backend.
     3→// Use of this source code is governed by a MIT-style
     4→// license that can be found in the LICENSE file.
     5→
     6→package graph
     7→
     8→import (
     9→	"context"
    10→	"fmt"
    11→	"math"
    12→	"slices"
    13→
    14→	"github.com/99designs/gqlgen/graphql"
    15→	"github.com/ClusterCockpit/cc-backend/internal/graph/model"
    16→	"github.com/ClusterCockpit/cc-backend/internal/metricdispatch"
    17→	cclog "github.com/ClusterCockpit/cc-lib/v2/ccLogger"
    18→	"github.com/ClusterCockpit/cc-lib/v2/schema"
    19→)
    20→
    21→const MAX_JOBS_FOR_ANALYSIS = 500
    22→
    23→// Helper function for the rooflineHeatmap GraphQL query placed here so that schema.resolvers.go is not too full.
    24→func (r *queryResolver) rooflineHeatmap(
    25→	ctx context.Context,
    26→	filter []*model.JobFilter,
    27→	rows int, cols int,
    28→	minX float64, minY float64, maxX float64, maxY float64,
    29→) ([][]float64, error) {
    30→	jobs, err := r.Repo.QueryJobs(ctx, filter, &model.PageRequest{Page: 1, ItemsPerPage: MAX_JOBS_FOR_ANALYSIS + 1}, nil)
    31→	if err != nil {
    32→		cclog.Error("Error while querying jobs for roofline")
    33→		return nil, err
    34→	}
    35→	if len(jobs) > MAX_JOBS_FOR_ANALYSIS {
    36→		return nil, fmt.Errorf("GRAPH/UTIL > too many jobs matched (max: %d)", MAX_JOBS_FOR_ANALYSIS)
    37→	}
    38→
    39→	fcols, frows := float64(cols), float64(rows)
    40→	minX, minY, maxX, maxY = math.Log10(minX), math.Log10(minY), math.Log10(maxX), math.Log10(maxY)
    41→	tiles := make([][]float64, rows)
    42→	for i := range tiles {
    43→		tiles[i] = make([]float64, cols)
    44→	}
    45→
    46→	for _, job := range jobs {
    47→		if job.MonitoringStatus == schema.MonitoringStatusDisabled || job.MonitoringStatus == schema.MonitoringStatusArchivingFailed {
    48→			continue
    49→		}
    50→
    51→		// metricConfigs := archive.GetCluster(job.Cluster).MetricConfig
    52→		// resolution := 0
    53→
    54→		// for _, mc := range metricConfigs {
    55→		// 	resolution = max(resolution, mc.Timestep)
    56→		// }
    57→
    58→		jobdata, err := metricdispatch.LoadData(job, []string{"flops_any", "mem_bw"}, []schema.MetricScope{schema.MetricScopeNode}, ctx, 0)
    59→		if err != nil {
    60→			cclog.Warnf("Error while loading roofline metrics for job %d", *job.ID)
    61→			return nil, err
    62→		}
    63→
    64→		flops_, membw_ := jobdata["flops_any"], jobdata["mem_bw"]
    65→		if flops_ == nil && membw_ == nil {
    66→			cclog.Warnf("rooflineHeatmap(): 'flops_any' or 'mem_bw' missing for job %d", *job.ID)
    67→			continue
    68→			// return nil, fmt.Errorf("GRAPH/UTIL > 'flops_any' or 'mem_bw' missing for job %d", job.ID)
    69→		}
    70→
    71→		flops, ok1 := flops_["node"]
    72→		membw, ok2 := membw_["node"]
    73→		if !ok1 || !ok2 {
    74→			cclog.Info("rooflineHeatmap() query not implemented for where flops_any or mem_bw not available at 'node' level")
    75→			continue
    76→			// TODO/FIXME:
    77→			// return nil, errors.New("GRAPH/UTIL > todo: rooflineHeatmap() query not implemented for where flops_any or mem_bw not available at 'node' level")
    78→		}
    79→
    80→		for n := 0; n < len(flops.Series); n++ {
    81→			flopsSeries, membwSeries := flops.Series[n], membw.Series[n]
    82→			for i := 0; i < len(flopsSeries.Data); i++ {
    83→				if i >= len(membwSeries.Data) {
    84→					break
    85→				}
    86→
    87→				x, y := math.Log10(float64(flopsSeries.Data[i]/membwSeries.Data[i])), math.Log10(float64(flopsSeries.Data[i]))
    88→				if math.IsNaN(x) || math.IsNaN(y) || x < minX || x >= maxX || y < minY || y > maxY {
    89→					continue
    90→				}
    91→
    92→				x, y = math.Floor(((x-minX)/(maxX-minX))*fcols), math.Floor(((y-minY)/(maxY-minY))*frows)
    93→				if x < 0 || x >= fcols || y < 0 || y >= frows {
    94→					continue
    95→				}
    96→
    97→				tiles[int(y)][int(x)] += 1
    98→			}
    99→		}
   100→	}
   101→
   102→	return tiles, nil
   103→}
   104→
   105→// Helper function for the jobsFootprints GraphQL query placed here so that schema.resolvers.go is not too full.
   106→func (r *queryResolver) jobsFootprints(ctx context.Context, filter []*model.JobFilter, metrics []string) (*model.Footprints, error) {
   107→	jobs, err := r.Repo.QueryJobs(ctx, filter, &model.PageRequest{Page: 1, ItemsPerPage: MAX_JOBS_FOR_ANALYSIS + 1}, nil)
   108→	if err != nil {
   109→		cclog.Error("Error while querying jobs for footprint")
   110→		return nil, err
   111→	}
   112→	if len(jobs) > MAX_JOBS_FOR_ANALYSIS {
   113→		return nil, fmt.Errorf("GRAPH/UTIL > too many jobs matched (max: %d)", MAX_JOBS_FOR_ANALYSIS)
   114→	}
   115→
   116→	avgs := make([][]schema.Float, len(metrics))
   117→	for i := range avgs {
   118→		avgs[i] = make([]schema.Float, 0, len(jobs))
   119→	}
   120→
   121→	timeweights := new(model.TimeWeights)
   122→	timeweights.NodeHours = make([]schema.Float, 0, len(jobs))
   123→	timeweights.AccHours = make([]schema.Float, 0, len(jobs))
   124→	timeweights.CoreHours = make([]schema.Float, 0, len(jobs))
   125→
   126→	for _, job := range jobs {
   127→		if job.MonitoringStatus == schema.MonitoringStatusDisabled || job.MonitoringStatus == schema.MonitoringStatusArchivingFailed {
   128→			continue
   129→		}
   130→
   131→		if err := metricdispatch.LoadAverages(job, metrics, avgs, ctx); err != nil {
   132→			cclog.Error("Error while loading averages for footprint")
   133→			return nil, err
   134→		}
   135→
   136→		// #166 collect arrays: Null values or no null values?
   137→		timeweights.NodeHours = append(timeweights.NodeHours, schema.Float(float64(job.Duration)/60.0*float64(job.NumNodes)))
   138→		if job.NumAcc > 0 {
   139→			timeweights.AccHours = append(timeweights.AccHours, schema.Float(float64(job.Duration)/60.0*float64(job.NumAcc)))
   140→		} else {
   141→			timeweights.AccHours = append(timeweights.AccHours, schema.Float(1.0))
   142→		}
   143→		if job.NumHWThreads > 0 {
   144→			timeweights.CoreHours = append(timeweights.CoreHours, schema.Float(float64(job.Duration)/60.0*float64(job.NumHWThreads))) // SQLite HWThreads == Cores; numCoresForJob(job)
   145→		} else {
   146→			timeweights.CoreHours = append(timeweights.CoreHours, schema.Float(1.0))
   147→		}
   148→	}
   149→
   150→	res := make([]*model.MetricFootprints, len(avgs))
   151→	for i, arr := range avgs {
   152→		res[i] = &model.MetricFootprints{
   153→			Metric: metrics[i],
   154→			Data:   arr,
   155→		}
   156→	}
   157→
   158→	return &model.Footprints{
   159→		TimeWeights: timeweights,
   160→		Metrics:     res,
   161→	}, nil
   162→}
   163→
   164→// func numCoresForJob(job *schema.Job) (numCores int) {
   165→
   166→// 	subcluster, scerr := archive.GetSubCluster(job.Cluster, job.SubCluster)
   167→// 	if scerr != nil {
   168→// 		return 1
   169→// 	}
   170→
   171→// 	totalJobCores := 0
   172→// 	topology := subcluster.Topology
   173→
   174→// 	for _, host := range job.Resources {
   175→// 		hwthreads := host.HWThreads
   176→// 		if hwthreads == nil {
   177→// 			hwthreads = topology.Node
   178→// 		}
   179→
   180→// 		hostCores, _ := topology.GetCoresFromHWThreads(hwthreads)
   181→// 		totalJobCores += len(hostCores)
   182→// 	}
   183→
   184→// 	return totalJobCores
   185→// }
   186→
   187→func requireField(ctx context.Context, name string) bool {
   188→	fields := graphql.CollectAllFields(ctx)
   189→
   190→	return slices.Contains(fields, name)
   191→}
   192→


/Users/jan/prg/ClusterCockpit/cc-backend/internal/graph/stats_cache.go-73-	c.mu.Unlock()
/Users/jan/prg/ClusterCockpit/cc-backend/internal/graph/stats_cache.go-74-
/Users/jan/prg/ClusterCockpit/cc-backend/internal/graph/stats_cache.go-75-	entry.once.Do(func() {
/Users/jan/prg/ClusterCockpit/cc-backend/internal/graph/stats_cache.go-76-		entry.result, entry.err = compute()
/Users/jan/prg/ClusterCockpit/cc-backend/internal/graph/stats_cache.go-77-	})
/Users/jan/prg/ClusterCockpit/cc-backend/internal/graph/stats_cache.go-78-	return entry.result, entry.err
/Users/jan/prg/ClusterCockpit/cc-backend/internal/graph/stats_cache.go-79-}
/Users/jan/prg/ClusterCockpit/cc-backend/internal/graph/stats_cache.go-80-
/Users/jan/prg/ClusterCockpit/cc-backend/internal/graph/stats_cache.go-81-// sortAndPageStats sorts a copy of allStats by the given sortBy field (descending)
/Users/jan/prg/ClusterCockpit/cc-backend/internal/graph/stats_cache.go-82-// and returns the requested page slice.
internal/graph/stats_cache.go:83:func sortAndPageStats(allStats []*model.JobsStatistics, sortBy *model.SortByAggregate, page *model.PageRequest) []*model.JobsStatistics {
/Users/jan/prg/ClusterCockpit/cc-backend/internal/graph/stats_cache.go-84-	// Work on a shallow copy so the cached slice order is not mutated.
internal/graph/stats_cache.go-85-	sorted := make([]*model.JobsStatistics, len(allStats))
/Users/jan/prg/ClusterCockpit/cc-backend/internal/graph/stats_cache.go-86-	copy(sorted, allStats)
/Users/jan/prg/ClusterCockpit/cc-backend/internal/graph/stats_cache.go-87-
/Users/jan/prg/ClusterCockpit/cc-backend/internal/graph/stats_cache.go-88-	if sortBy != nil {
internal/graph/stats_cache.go-89-		getter := statsFieldGetter(*sortBy)
/Users/jan/prg/ClusterCockpit/cc-backend/internal/graph/stats_cache.go-90-		slices.SortFunc(sorted, func(a, b *model.JobsStatistics) int {
/Users/jan/prg/ClusterCockpit/cc-backend/internal/graph/stats_cache.go-91-			return getter(b) - getter(a) // descending
/Users/jan/prg/ClusterCockpit/cc-backend/internal/graph/stats_cache.go-92-		})
/Users/jan/prg/ClusterCockpit/cc-backend/internal/graph/stats_cache.go-93-	}


     1→<!--
     2→  @component Main cluster status view component; renders current system-usage information
     3→
     4→  Properties:
     5→  - `presetCluster String`: The cluster to show status information for
     6→-->
     7→
     8→ <script>
     9→  import { getContext } from "svelte";
    10→  import {
    11→    Row,
    12→    Col,
    13→    Spinner,
    14→    Card,
    15→    Icon,
    16→    Button,
    17→  } from "@sveltestrap/sveltestrap";
    18→  import {
    19→    queryStore,
    20→    gql,
    21→    getContextClient,
    22→  } from "@urql/svelte";
    23→  import {
    24→    convert2uplot,
    25→  } from "../../generic/utils.js";
    26→  import PlotGrid from "../../generic/PlotGrid.svelte";
    27→  import Histogram from "../../generic/plots/Histogram.svelte";
    28→  import HistogramSelection from "../../generic/select/HistogramSelection.svelte";
    29→  import Refresher from "../../generic/helper/Refresher.svelte";
    30→
    31→  /* Svelte 5 Props */
    32→  let {
    33→    presetCluster,
    34→    loadMe = false,
    35→  } = $props();
    36→
    37→  /* Const Init */
    38→  const ccconfig = getContext("cc-config");
    39→  const globalMetrics = getContext("globalMetrics");
    40→  const client = getContextClient();
    41→
    42→  /* State Init */
    43→  // Histogram
    44→  let isHistogramSelectionOpen = $state(false);
    45→
    46→  /* Derived */
    47→  let cluster = $derived(presetCluster);
    48→  let selectedHistograms = $derived(cluster
    49→    ? ccconfig[`statusView_selectedHistograms:${cluster}`] || ( ccconfig['statusView_selectedHistograms'] || [] )
    50→    : ccconfig['statusView_selectedHistograms'] || []);
    51→
    52→  // Note: nodeMetrics are requested on configured $timestep resolution
    53→  const metricStatusQuery = $derived(loadMe ? queryStore({
    54→    client: client,
    55→    query: gql`
    56→      query (
    57→        $filter: [JobFilter!]!
    58→        $selectedHistograms: [String!]
    59→      ) {
    60→        jobsStatistics(filter: $filter, metrics: $selectedHistograms) {
    61→          histMetrics {
    62→            metric
    63→            unit
    64→            data {
    65→              min
    66→              max
    67→              count
    68→              bin
    69→            }
    70→          }
    71→        }
    72→      }
    73→    `,
    74→    variables: {
    75→      filter: [{ cluster: { eq: cluster} }, { state: ["running"] }],
    76→      selectedHistograms: selectedHistograms
    77→    },
    78→    requestPolicy: "network-only"
    79→  }) : null);
    80→</script>
    81→
    82→<!-- Loading indicators & Metric Sleect -->
    83→<Row class="justify-content-between">
    84→  <Col class="mb-2 mb-md-0" xs="12" md="5" lg="4" xl="3">
    85→    <Button
    86→      outline
    87→      color="secondary"
    88→      onclick={() => (isHistogramSelectionOpen = true)}
    89→    >
    90→      <Icon name="bar-chart-line" /> Select Histograms
    91→    </Button>
    92→  </Col>
    93→  <Col xs="12" md="5" lg="4" xl="3">
    94→    <Refresher
    95→      initially={120}
    96→      onRefresh={() => {
    97→        selectedHistograms = [...$state.snapshot(selectedHistograms)]
    98→      }}
    99→    />
   100→  </Col>
   101→</Row>
   102→
   103→<Row cols={1} class="text-center mt-3">
   104→  {#if $metricStatusQuery?.fetching}
   105→    <Col>
   106→      <Spinner />
   107→    </Col>
   108→  {:else if $metricStatusQuery?.error}
   109→    <Col>
   110→      <Card body color="danger">{$metricStatusQuery.error.message}</Card>
   111→    </Col>
   112→  {/if}
   113→</Row>
   114→
   115→{#if $metricStatusQuery?.data}
   116→  <!-- Selectable Stats as Histograms : Average Values of Running Jobs -->
   117→  {#if selectedHistograms}
   118→    <!-- Note: Ignore '#snippet' Error in IDE -->
   119→    {#snippet gridContent(item)}
   120→      <Histogram
   121→        data={convert2uplot(item.data)}
   122→        title="Distribution of '{item.metric}' averages"
   123→        xlabel={`${item.metric} bin maximum ${item?.unit ? `[${item.unit}]` : ``}`}
   124→        xunit={item.unit}
   125→        ylabel="Number of Jobs"
   126→        yunit="Jobs"
   127→        usesBins
   128→        enableFlip
   129→      />
   130→    {/snippet}
   131→
   132→    {#key $metricStatusQuery?.data?.jobsStatistics[0]?.histMetrics}
   133→      <PlotGrid
   134→        items={$metricStatusQuery?.data?.jobsStatistics[0]?.histMetrics}
   135→        itemsPerRow={2}
   136→        {gridContent}
   137→      />
   138→    {/key}
   139→  {/if}
   140→{/if}
   141→
   142→<HistogramSelection
   143→  {cluster}
   144→  {globalMetrics}
   145→  bind:isOpen={isHistogramSelectionOpen}
   146→  presetSelectedHistograms={selectedHistograms}
   147→  configName="statusView_selectedHistograms"
   148→  applyChange={(newSelection) => {
   149→    selectedHistograms = [...newSelection];
   150→  }}
   151→/>
   152→


1→
     7→
     8→
   335→
   336→<Row>
   337→  {#if $initq.fetching || $statsQuery.fetching || $footprintsQuery.fetching}
   338→    <Col xs="auto">
   339→      <Spinner />
   340→    </Col>
   341→  {/if}
   342→  <Col xs="auto" class="mb-2 mb-lg-0">
   343→    {#if $initq.error}
   344→      <Card body color="danger">{$initq.error.message}</Card>
   345→    {:else if cluster}
   346→      <PlotSelection
   347→        availableMetrics={availableMetrics.map((av) => av.name)}
   348→        presetMetricsInHistograms={metricsInHistograms}
   349→        presetMetricsInScatterplots={metricsInScatterplots}
   350→        applyHistograms={(metrics) => metricsInHistograms = [...metrics]}
   351→        applyScatter={(metrics) => metricsInScatterplots = [...metrics]}
   352→      />
   353→    {/if}
   354→  </Col>
   355→  <Col xs="auto">
   356→    <Filters
   357→      disableClusterSelection
   358→      startTimeQuickSelect
   359→      bind:this={filterComponent}
   360→      {filterPresets}
   361→      applyFilters={(detail) => {
   362→        jobFilters = detail.filters;
   363→      }}
   364→    />
   365→  </Col>
   366→</Row>
   367→
   368→

   369→{#if $statsQuery.error}
   370→  <Row>
   371→    <Col>
   372→      <Card body color="danger">{$statsQuery.error.message}</Card>
   373→    </Col>
   374→  </Row>
   375→{:else if $statsQuery.data}
   376→  <Row cols={3} style="margin-bottom: 2rem;">
   377→    <Col>
   378→

   379→

   380→          Total Jobs
   381→          {$statsQuery.data.stats[0].totalJobs}
   382→

   383→

   384→          Short Jobs
   385→          {$statsQuery.data.stats[0].shortJobs}
   386→

   387→

   388→          Total Walltime
   389→          {$statsQuery.data.stats[0].totalWalltime}
   390→

   391→

   392→          Total Node Hours
   393→          {$statsQuery.data.stats[0].totalNodeHours}
   394→

   395→

   396→          Total Core Hours
   397→          {$statsQuery.data.stats[0].totalCoreHours}
   398→

   399→

   400→          Total Accelerator Hours
   401→          {$statsQuery.data.stats[0].totalAccHours}
   402→

   403→

   404→    </Col>
   405→    <Col>
   406→

   407→

   408→          Top
   409→          <select class="p-0" bind:value={groupSelection}>
   410→            {#each groupOptions as option}
   411→              <option value={option}>
   412→                {option.key.charAt(0).toUpperCase() + option.key.slice(1)}s
   413→              </option>
   414→            {/each}
   415→          </select>
   416→

   417→        {#key $topQuery.data}
   418→          {#if $topQuery.fetching}
   419→            <Spinner />
   420→          {:else if $topQuery.error}
   421→            <Card body color="danger">{$topQuery.error.message}</Card>
   422→          {:else}
   423→            <Pie
   424→              canvasId={`pie-${groupSelection.key}`}
   425→              size={colWidth1 / 1.9}
   426→              sliceLabel={sortSelection.label}
   427→              quantities={$topQuery.data.topList.map(
   428→                (t) => t[sortSelection.key],
   429→              )}
   430→              entities={$topQuery.data.topList.map((t) => scrambleNames ? scramble(t.id) : t.id)}
   431→            />
   432→          {/if}
   433→        {/key}
   434→

   435→    </Col>
   436→    <Col>
   437→      {#key $topQuery.data}
   438→        {#if $topQuery.fetching}
   439→          <Spinner />
   440→        {:else if $topQuery.error}
   441→          <Card body color="danger">{$topQuery.error.message}</Card>
   442→        {:else}
   443→

   444→

   445→              Legend
   446→              {groupSelection.label}
   447→
   448→                <select class="p-0" bind:value={sortSelection}>
   449→                  {#each sortOptions as option}
   450→                    <option value={option}>
   451→                      {option.label}
   452→                    </option>
   453→                  {/each}
   454→                </select>
   455→
   456→

   457→            {#each $topQuery.data.topList as te, i}
   458→

   459→                <Icon name="circle-fill" style="color: {colors['colorblind'][i]};" />
   460→                {#if groupSelection.key == "user"}
   461→                  {scrambleNames ? scramble(te.id) : te.id}
   466→                  {#if te?.name}
   467→                    <Tooltip
   468→                      target={`topName-${te.id}`}
   469→                      placement="left"
   470→                      >{scrambleNames ? scramble(te.name) : te.name}</Tooltip
   471→                    >
   472→                  {/if}
   473→                {:else}
   474→                  {scrambleNames ? scramble(te.id) : te.id}
   480→                {/if}
   481→                {te[sortSelection.key]}
   482→

   483→            {/each}
   484→

   485→        {/if}
   486→      {/key}
   487→    </Col>
   488→  </Row>
   489→  <Row cols={3} class="mb-2">
   490→    <Col>
   491→      {#if $rooflineQuery.fetching}
   492→        <Spinner />
   493→      {:else if $rooflineQuery.error}
   494→        <Card body color="danger">{$rooflineQuery.error.message}</Card>
   495→      {:else if $rooflineQuery.data && cluster}
   496→

   497→
Job Roofline Heatmap

   498→          <Icon
   499→            style="cursor:help; margin-left:0.5rem;"
   500→            name="info-circle"
   501→            title="Most Populated Areas By Selected Jobs' Average Values"
   502→          />
   503→

   504→

   505→          {#key $rooflineQuery.data}
   506→            <RooflineHeatmap
   507→              width={colWidth2}
   508→              height={280}
   509→              tiles={$rooflineQuery.data.rooflineHeatmap}
   510→              subCluster={cluster.subClusters.length >= 1
   511→                ? cluster.subClusters[0]
   512→                : null}
   513→              maxY={rooflineMaxY}
   514→            />
   515→          {/key}
   516→

   517→      {/if}
   518→    </Col>
   519→    <Col>
   520→      {#key $statsQuery.data.stats[0].histDuration}
   521→        <Histogram
   522→          height={300}
   523→          data={convert2uplot($statsQuery.data.stats[0].histDuration)}
   524→          title="Duration Distribution"
   525→          xlabel="Current Job Runtimes"
   526→          xunit="Runtime"
   527→          ylabel="Number of Jobs"
   528→          yunit="Jobs"
   529→          usesBins
   530→          xtime
   531→        />
   532→      {/key}
   533→    </Col>
   534→    <Col>
   535→      {#key $statsQuery.data.stats[0].histNumCores}
   536→        <Histogram
   537→          height={300}
   538→          data={convert2uplot($statsQuery.data.stats[0].histNumCores)}
   539→          title="Number of Cores Distribution"
   540→          xlabel="Allocated Cores"
   541→          xunit="Cores"
   542→          ylabel="Number of Jobs"
   543→          yunit="Jobs"
   544→        />
   545→      {/key}
   546→    </Col>
   547→  </Row>
   548→{/if}
   549→
   550→

   551→
   552→{#if $footprintsQuery.error}
   553→  <Row>
   554→    <Col>
   555→      <Card body color="danger">{$footprintsQuery.error.message}</Card>
   556→    </Col>
   557→  </Row>
   558→{:else if $footprintsQuery.data && $initq.data}
   559→  <Row>
   560→    <Col>
   561→      <Card body>
   562→        These histograms show the distribution of the averages of all jobs
   563→        matching the filters. Each job/average is weighted by its node hours by
   564→        default (Accelerator hours for native accelerator scope metrics,
   565→        coreHours for native core scope metrics). Note that some metrics could
   566→        be disabled for specific subclusters as per metricConfig and thus could
   567→        affect shown average values.
   568→      </Card>
   569→

   570→    </Col>
   571→  </Row>
   572→  <Row>
   573→    <Col>
   574→
   575→      {#snippet histoGridContent(item)}
   576→        <Histogram
   577→          usesBins
   578→          data={convert2uplot(item.bins)}
   579→          title="Average Distribution of '{item.metric}'"
   580→          xlabel={`${item.metric} bin maximum [${metricUnits[item.metric]}]`}
   581→          xunit={`${metricUnits[item.metric]}`}
   582→          ylabel="Normalized Hours"
   583→          yunit="Hours"
   584→        />
   585→      {/snippet}
   586→
   587→      <PlotGrid
   588→        items={metricsInHistograms.map((metric) => ({
   589→          metric,
   590→          ...binsFromFootprint(
   591→            $footprintsQuery.data.footprints.timeWeights,
   592→            metricScopes[metric],
   593→            $footprintsQuery.data.footprints.metrics.find(
   594→              (f) => f.metric == metric,
   595→            ).data,
   596→            numBins,
   597→          ),
   598→        }))}
   599→        itemsPerRow={ccconfig.plotConfiguration_plotsPerRow}
   600→        gridContent={histoGridContent}
   601→      />
   602→    </Col>
   603→  </Row>
   604→

   605→  <Row>
   606→    <Col>
   607→      <Card body>
   608→        Each circle represents one job. The size of a circle is proportional to
   609→        its node hours. Darker circles mean multiple jobs have the same averages
   610→        for the respective metrics. Note that some metrics could be disabled for
   611→        specific subclusters as per metricConfig and thus could affect shown
   612→        average values.
   613→      </Card>
   614→

   615→    </Col>
   616→  </Row>
   617→  <Row>
   618→    <Col>
   619→      {#snippet metricsGridContent(item)}
   620→        <ScatterPlot
   621→          height={250}
   622→          color={"rgba(0, 102, 204, 0.33)"}
   623→          xLabel={`${item.m1} [${metricUnits[item.m1]}]`}
   624→          yLabel={`${item.m2} [${metricUnits[item.m2]}]`}
   625→          X={item.f1}
   626→          Y={item.f2}
   627→          S={$footprintsQuery.data.footprints.timeWeights.nodeHours}
   628→        />
   629→      {/snippet}
   630→
   631→      <PlotGrid
   632→        items={metricsInScatterplots.map(([m1, m2]) => ({
   633→          m1,
   634→          f1: $footprintsQuery.data.footprints.metrics.find(
   635→            (f) => f.metric == m1,
   636→          ).data,
   637→          m2,
   638→          f2: $footprintsQuery.data.footprints.metrics.find(
   639→            (f) => f.metric == m2,
   640→          ).data,
   641→        }))}
   642→        itemsPerRow={ccconfig.plotConfiguration_plotsPerRow}
   643→        gridContent={metricsGridContent}
   644→      />
   645→    </Col>
   646→  </Row>
   647→{/if}
   648→
   649→
   654→


     1→// Copyright (C) NHR@FAU, University Erlangen-Nuremberg.
     2→// All rights reserved. This file is part of cc-backend.
     3→// Use of this source code is governed by a MIT-style
     4→// license that can be found in the LICENSE file.
     5→
     6→package graph
     7→
     8→import (
     9→	"context"
    10→	"fmt"
    11→	"slices"
    12→	"sync"
    13→
    14→	"github.com/ClusterCockpit/cc-backend/internal/graph/model"
    15→)
    16→
    17→// statsGroupCache is a per-request cache for grouped JobsStatistics results.
    18→// It deduplicates identical (filter+groupBy) SQL queries that arise when the
    19→// frontend requests multiple sort/page slices of the same underlying data
    20→// (e.g. topUserJobs, topUserNodes, topUserAccs all group by USER).
    21→type statsGroupCache struct {
    22→	mu      sync.Mutex
    23→	entries map[string]*cacheEntry
    24→}
    25→
    26→type cacheEntry struct {
    27→	once   sync.Once
    28→	result []*model.JobsStatistics
    29→	err    error
    30→}
    31→
    32→type ctxKey int
    33→
    34→const statsGroupCacheKey ctxKey = iota
    35→
    36→// newStatsGroupCache creates a new empty cache.
    37→func newStatsGroupCache() *statsGroupCache {
    38→	return &statsGroupCache{
    39→		entries: make(map[string]*cacheEntry),
    40→	}
    41→}
    42→
    43→// WithStatsGroupCache injects a new cache into the context.
    44→func WithStatsGroupCache(ctx context.Context) context.Context {
    45→	return context.WithValue(ctx, statsGroupCacheKey, newStatsGroupCache())
    46→}
    47→
    48→// getStatsGroupCache retrieves the cache from context, or nil if absent.
    49→func getStatsGroupCache(ctx context.Context) *statsGroupCache {
    50→	if c, ok := ctx.Value(statsGroupCacheKey).(*statsGroupCache); ok {
    51→		return c
    52→	}
    53→	return nil
    54→}
    55→
    56→// cacheKey builds a deterministic string key from filter + groupBy + reqFields.
    57→func statsCacheKey(filter []*model.JobFilter, groupBy *model.Aggregate, reqFields map[string]bool) string {
    58→	return fmt.Sprintf("%v|%v|%v", filter, *groupBy, reqFields)
    59→}
    60→
    61→// getOrCompute returns cached results for the given key, computing them on
    62→// first access via the provided function.
    63→func (c *statsGroupCache) getOrCompute(
    64→	key string,
    65→	compute func() ([]*model.JobsStatistics, error),
    66→) ([]*model.JobsStatistics, error) {
    67→	c.mu.Lock()
    68→	entry, ok := c.entries[key]
    69→	if !ok {
    70→		entry = &cacheEntry{}
    71→		c.entries[key] = entry
    72→	}
    73→	c.mu.Unlock()
    74→
    75→	entry.once.Do(func() {
    76→		entry.result, entry.err = compute()
    77→	})
    78→	return entry.result, entry.err
    79→}
    80→
    81→// sortAndPageStats sorts a copy of allStats by the given sortBy field (descending)
    82→// and returns the requested page slice.
    83→func sortAndPageStats(allStats []*model.JobsStatistics, sortBy *model.SortByAggregate, page *model.PageRequest) []*model.JobsStatistics {
    84→	// Work on a shallow copy so the cached slice order is not mutated.
    85→	sorted := make([]*model.JobsStatistics, len(allStats))
    86→	copy(sorted, allStats)
    87→
    88→	if sortBy != nil {
    89→		getter := statsFieldGetter(*sortBy)
    90→		slices.SortFunc(sorted, func(a, b *model.JobsStatistics) int {
    91→			return getter(b) - getter(a) // descending
    92→		})
    93→	}
    94→
    95→	if page != nil && page.ItemsPerPage != -1 {
    96→		start := (page.Page - 1) * page.ItemsPerPage
    97→		if start >= len(sorted) {
    98→			return nil
    99→		}
   100→		end := start + page.ItemsPerPage
   101→		if end > len(sorted) {
   102→			end = len(sorted)
   103→		}
   104→		sorted = sorted[start:end]
   105→	}
   106→
   107→	return sorted
   108→}
   109→
   110→// statsFieldGetter returns a function that extracts the sortable int field
   111→// from a JobsStatistics struct for the given sort key.
   112→func statsFieldGetter(sortBy model.SortByAggregate) func(*model.JobsStatistics) int {
   113→	switch sortBy {
   114→	case model.SortByAggregateTotaljobs:
   115→		return func(s *model.JobsStatistics) int { return s.TotalJobs }
   116→	case model.SortByAggregateTotalusers:
   117→		return func(s *model.JobsStatistics) int { return s.TotalUsers }
   118→	case model.SortByAggregateTotalwalltime:
   119→		return func(s *model.JobsStatistics) int { return s.TotalWalltime }
   120→	case model.SortByAggregateTotalnodes:
   121→		return func(s *model.JobsStatistics) int { return s.TotalNodes }
   122→	case model.SortByAggregateTotalnodehours:
   123→		return func(s *model.JobsStatistics) int { return s.TotalNodeHours }
   124→	case model.SortByAggregateTotalcores:
   125→		return func(s *model.JobsStatistics) int { return s.TotalCores }
   126→	case model.SortByAggregateTotalcorehours:
   127→		return func(s *model.JobsStatistics) int { return s.TotalCoreHours }
   128→	case model.SortByAggregateTotalaccs:
   129→		return func(s *model.JobsStatistics) int { return s.TotalAccs }
   130→	case model.SortByAggregateTotalacchours:
   131→		return func(s *model.JobsStatistics) int { return s.TotalAccHours }
   132→	default:
   133→		return func(s *model.JobsStatistics) int { return s.TotalJobs }
   134→	}
   135→}
   136→


1→
     8→
     9→
   172→
   173→<Row cols={{ xs: 1, md: 2}}>
   174→  <Col xs="12" md="5" lg="4" xl="3" class="mb-2 mb-md-0">
   175→    <InputGroup>
   176→      <Button disabled outline>
   177→        Search {type.toLowerCase()}s
   178→      </Button>
   179→      <Input
   180→        bind:value={nameFilter}
   181→        placeholder="Filter by {{
   182→          USER: 'username',
   183→          PROJECT: 'project',
   184→        }[type]}"
   185→      />
   186→      <Button
   187→        color="success"
   188→        title="Export current view as CSV"
   189→        disabled={!$stats.data}
   190→        onclick={() => exportCsv()}
   191→      >
   192→        <Icon name="download" /> CSV
   193→      </Button>
   194→    </InputGroup>
   195→  </Col>
   196→  <Col xs="12" md="7" lg="8" xl="9">
   197→    <Filters
   198→      startTimeQuickSelect
   199→      bind:this={filterComponent}
   200→      {filterPresets}
   201→      menuText="Only {type.toLowerCase()}s with matching jobs will be displayed."
   202→      applyFilters={(detail) => {
   203→        jobFilters = detail.filters;
   204→      }}
   205→    />
   206→  </Col>
   207→</Row>
   208→

   209→

   210→

   211→
   212→        {#if type === 'USER'}
   213→          Username
   214→        {:else if type === 'PROJECT'}
   215→          Project Name
   216→        {/if}
   217→        <Button
   218→          color={sorting.field == "id" ? "primary" : "light"}
   219→          size="sm"
   220→          onclick={() => changeSorting("id")}
   221→        >
   222→          {#if sorting?.field == "id"}
   223→
   224→            <Icon name={`sort-alpha-${sorting?.direction == 'desc' ? 'down' : 'down-alt'}`} />
   225→          {:else}
   226→            <Icon name="three-dots-vertical" />
   227→          {/if}
   228→        </Button>
   229→
   230→      {#if type == "USER"}
   231→
   232→          Name
   233→          <Button
   234→            color={sorting.field == "name" ? "primary" : "light"}
   235→            size="sm"
   236→            onclick={() => changeSorting("name")}
   237→          >
   238→            {#if sorting?.field == "name"}
   239→              <Icon name={`sort-alpha-${sorting?.direction == 'desc' ? 'down' : 'down-alt'}`} />
   240→            {:else}
   241→              <Icon name="three-dots-vertical" />
   242→            {/if}
   243→          </Button>
   244→
   245→      {/if}
   246→
   247→        Total Jobs
   248→        <Button
   249→          color={sorting.field == "totalJobs" ? "primary" : "light"}
   250→          size="sm"
   251→          onclick={() => changeSorting("totalJobs")}
   252→        >
   253→          {#if sorting?.field == "totalJobs"}
   254→
   255→            <Icon name={`sort-numeric-${sorting?.direction == 'desc' ? 'down-alt' : 'down'}`} />
   256→          {:else}
   257→            <Icon name="three-dots-vertical" />
   258→          {/if}
   259→        </Button>
   260→
   261→
   262→
   263→          Short Jobs
   264→          <Icon id="shortjobs-info" style="cursor:help;" size="sm" name="info-circle"/>
   265→
   266→        <Tooltip target={`shortjobs-info`} placement="top">
   267→          Job duration less than {formatDurationTime(shortDuration)}
   268→        </Tooltip>
   269→
   270→        <Button
   271→          color={sorting.field == "shortJobs" ? "primary" : "light"}
   272→          size="sm"
   273→          onclick={() => changeSorting("shortJobs")}
   274→        >
   275→          {#if sorting?.field == "shortJobs"}
   276→
   277→            <Icon name={`sort-numeric-${sorting?.direction == 'desc' ? 'down-alt' : 'down'}`} />
   278→          {:else}
   279→            <Icon name="three-dots-vertical" />
   280→          {/if}
   281→        </Button>
   282→
   283→      {#if fetchRunning}
   284→
   285→          Total Cores
   286→          <Button
   287→            color={sorting.field == "totalCores" ? "primary" : "light"}
   288→            size="sm"
   289→            onclick={() => changeSorting("totalCores")}
   290→          >
   291→            {#if sorting?.field == "totalJCores"}
   292→
   293→              <Icon name={`sort-numeric-${sorting?.direction == 'desc' ? 'down-alt' : 'down'}`} />
   294→            {:else}
   295→              <Icon name="three-dots-vertical" />
   296→            {/if}
   297→          </Button>
   298→
   299→
   300→          Total Accelerators


1→
     8→
     9→
   186→
   187→
   188→{#if $initq.fetching}
   189→  <Row>
   190→    <Col>
   191→      <Spinner />
   192→    </Col>
   193→  </Row>
   194→{:else if $initq.error}
   195→  <Row>
   196→    <Col>
   197→      <Card body color="danger">{$initq.error.message}</Card>
   198→    </Col>
   199→  </Row>
   200→{/if}
   201→
   202→
   203→<Row cols={{ xs: 1, md: 2, lg: 6}} class="mb-3">
   204→  <Col class="mb-2 mb-lg-0">
   205→    <ButtonGroup class="w-100">
   206→      <Button outline color="primary" onclick={() => (isSortingOpen = true)} disabled={showCompare}>
   207→        <Icon name="sort-up" /> Sorting
   208→      </Button>
   209→      <Button
   210→        outline
   211→        color="primary"
   212→        onclick={() => (isMetricsSelectionOpen = true)}
   213→      >
   214→        <Icon name="graph-up" /> Metrics
   215→      </Button>
   216→    </ButtonGroup>
   217→  </Col>
   218→  <Col lg="4" class="mb-1 mb-lg-0">
   219→    <Filters
   220→      bind:this={filterComponent}
   221→      {filterPresets}
   222→      startTimeQuickSelect
   223→      shortJobQuickSelect={(filterBuffer.length > 0)}
   224→      shortJobCutoff={ccconfig?.jobList_hideShortRunningJobs}
   225→      showFilter={!showCompare}
   226→      matchedJobs={showCompare? matchedCompareJobs: matchedListJobs}
   227→      applyFilters={(detail) => {
   228→        jobFilters = [...detail.filters, { user: { eq: user.username } }];
   229→        selectedCluster = jobFilters[0]?.cluster
   230→          ? jobFilters[0].cluster.eq
   231→          : null;
   232→        selectedSubCluster = jobFilters[1]?.partition
   233→          ? jobFilters[1].partition.eq
   234→          : null;
   235→        filterBuffer = [...jobFilters]
   236→        if (showCompare) {
   237→          jobCompare.queryJobs(jobFilters);
   238→        } else {
   239→          jobList.queryJobs(jobFilters);
   240→        }
   241→      }}
   242→    />
   243→  </Col>
   244→  <Col class="mb-2 mb-lg-0">
   245→     {#if !showCompare}
   246→      <InputGroup>
   247→        <InputGroupText>
   248→          <Icon name="bar-chart-line-fill" />
   249→        </InputGroupText>
   250→        <InputGroupText>
   251→          Duration Bin Size
   252→        </InputGroupText>
   253→        <Input type="select" bind:value={numDurationBins} style="max-width: 120px;">
   254→          {#each durationBinOptions as dbin}
   255→            <option value={dbin}>{dbin}</option>
   256→          {/each}
   257→        </Input>
   258→      </InputGroup>
   259→    {/if}
   260→  </Col>
   261→  <Col class="mb-2 mb-lg-0">
   262→    {#if !showCompare}
   263→      <TextFilter
   264→        {filterBuffer}
   265→        setFilter={(filter) => filterComponent.updateFilters(filter)}
   266→      />
   267→    {/if}
   268→  </Col>
   269→  <Col class="mb-1 mb-lg-0">
   270→    {#if !showCompare}
   271→      <Refresher onRefresh={() => {
   272→        jobList.refreshJobs()
   273→        jobList.refreshAllMetrics()
   274→      }} />
   275→    {/if}
   276→  </Col>
   277→</Row>
   278→
   279→
   280→{#if !showCompare}
   281→  <Row cols={{ xs: 1, md: 3}} class="mb-2">
   282→    {#if $stats.error}
   283→      <Col>
   284→        <Card body color="danger">{$stats.error.message}</Card>
   285→      </Col>
   286→    {:else if !$stats.data}
   287→      <Col>
   288→        <Spinner secondary />
   289→      </Col>
   290→    {:else}
   291→      <Col>
   292→

   293→

   294→

   295→              Username
   296→              {scrambleNames ? scramble(user.username) : user.username}
   297→

   298→            {#if user.name}
   299→

   300→                Name


No matches found


/Users/jan/prg/ClusterCockpit/cc-backend/web/frontend/src/Analysis.root.svelte-160-        query (
web/frontend/src/Analysis.root.svelte-161-          $jobFilters: [JobFilter!]!
web/frontend/src/Analysis.root.svelte-162-          $paging: PageRequest!
web/frontend/src/Analysis.root.svelte:163:          $sortBy: SortByAggregate!
web/frontend/src/Analysis.root.svelte-164-          $groupBy: Aggregate!
/Users/jan/prg/ClusterCockpit/cc-backend/web/frontend/src/Analysis.root.svelte-165-        ) {
web/frontend/src/Analysis.root.svelte-166-          topList: jobsStatistics(