diff --git a/api/schema.graphqls b/api/schema.graphqls index 8981c859..98fb8a16 100644 --- a/api/schema.graphqls +++ b/api/schema.graphqls @@ -143,6 +143,7 @@ type MetricConfig { alert: Float! lowerIsBetter: Boolean subClusters: [SubClusterConfig!]! + tooltip: String } type Tag { @@ -306,6 +307,7 @@ type GlobalMetricListItem { unit: Unit! scope: MetricScope! footprint: String + tooltip: String availability: [ClusterSupport!]! } diff --git a/internal/api/api_test.go b/internal/api/api_test.go index ec3f55ff..85682363 100644 --- a/internal/api/api_test.go +++ b/internal/api/api_test.go @@ -196,8 +196,8 @@ func cleanup() { func TestRestApi(t *testing.T) { restapi := setup(t) t.Cleanup(cleanup) - testData := schema.JobData{ - "load_one": map[schema.MetricScope]*schema.JobMetric{ + testData := schema.JobData{Metrics: map[string]schema.ScopedMetrics{ + "load_one": { schema.MetricScopeNode: { Unit: schema.Unit{Base: "load"}, Timestep: 60, @@ -210,7 +210,7 @@ func TestRestApi(t *testing.T) { }, }, }, - } + }} metricstore.TestLoadDataCallback = func(job *schema.Job, metrics []string, scopes []schema.MetricScope, ctx context.Context, resolution int) (schema.JobData, error) { return testData, nil @@ -497,8 +497,8 @@ func TestStopJobWithReusedJobId(t *testing.T) { restapi := setup(t) t.Cleanup(cleanup) - testData := schema.JobData{ - "load_one": map[schema.MetricScope]*schema.JobMetric{ + testData := schema.JobData{Metrics: map[string]schema.ScopedMetrics{ + "load_one": { schema.MetricScopeNode: { Unit: schema.Unit{Base: "load"}, Timestep: 60, @@ -511,7 +511,7 @@ func TestStopJobWithReusedJobId(t *testing.T) { }, }, }, - } + }} metricstore.TestLoadDataCallback = func(job *schema.Job, metrics []string, scopes []schema.MetricScope, ctx context.Context, resolution int) (schema.JobData, error) { return testData, nil diff --git a/internal/api/job.go b/internal/api/job.go index b5695f7a..30ce60ec 100644 --- a/internal/api/job.go +++ b/internal/api/job.go @@ -412,7 +412,7 @@ func (api *RestAPI) getJobByID(rw http.ResponseWriter, r *http.Request) { } res := []*JobMetricWithName{} - for name, md := range data { + for name, md := range data.Metrics { for scope, metric := range md { res = append(res, &JobMetricWithName{ Name: name, diff --git a/internal/api/nats_test.go b/internal/api/nats_test.go index b1d2a624..f6d4b5eb 100644 --- a/internal/api/nats_test.go +++ b/internal/api/nats_test.go @@ -531,8 +531,8 @@ func TestNatsHandleStopJob(t *testing.T) { }, } - testData := schema.JobData{ - "load_one": map[schema.MetricScope]*schema.JobMetric{ + testData := schema.JobData{Metrics: map[string]schema.ScopedMetrics{ + "load_one": { schema.MetricScopeNode: { Unit: schema.Unit{Base: "load"}, Timestep: 60, @@ -545,7 +545,7 @@ func TestNatsHandleStopJob(t *testing.T) { }, }, }, - } + }} metricstore.TestLoadDataCallback = func(job *schema.Job, metrics []string, scopes []schema.MetricScope, ctx context.Context, resolution int) (schema.JobData, error) { return testData, nil diff --git a/internal/archiver/archiver.go b/internal/archiver/archiver.go index 480972fd..c018e364 100644 --- a/internal/archiver/archiver.go +++ b/internal/archiver/archiver.go @@ -65,9 +65,9 @@ func ArchiveJob(job *schema.Job, ctx context.Context) (*schema.Job, error) { return nil, err } - job.Statistics = make(map[string]schema.JobStatistics) + job.Statistics = schema.JobStatisticsSet{Metrics: make(map[string]schema.JobStatistics, len(jobData.Metrics))} - for metric, data := range jobData { + for metric, data := range jobData.Metrics { avg, min, max := 0.0, math.MaxFloat32, -math.MaxFloat32 nodeData, ok := data["node"] if !ok { @@ -82,7 +82,7 @@ func ArchiveJob(job *schema.Job, ctx context.Context) (*schema.Job, error) { } // Round AVG Result to 2 Digits - job.Statistics[metric] = schema.JobStatistics{ + job.Statistics.Metrics[metric] = schema.JobStatistics{ Unit: schema.Unit{ Prefix: archive.GetMetricConfig(job.Cluster, metric).Unit.Prefix, Base: archive.GetMetricConfig(job.Cluster, metric).Unit.Base, diff --git a/internal/graph/generated/generated.go b/internal/graph/generated/generated.go index 913a1cc4..2258da4c 100644 --- a/internal/graph/generated/generated.go +++ b/internal/graph/generated/generated.go @@ -100,6 +100,7 @@ type ComplexityRoot struct { Footprint func(childComplexity int) int Name func(childComplexity int) int Scope func(childComplexity int) int + Tooltip func(childComplexity int) int Unit func(childComplexity int) int } @@ -219,6 +220,7 @@ type ComplexityRoot struct { Scope func(childComplexity int) int SubClusters func(childComplexity int) int Timestep func(childComplexity int) int + Tooltip func(childComplexity int) int Unit func(childComplexity int) int } @@ -698,6 +700,12 @@ func (e *executableSchema) Complexity(ctx context.Context, typeName, field strin } return e.ComplexityRoot.GlobalMetricListItem.Scope(childComplexity), true + case "GlobalMetricListItem.tooltip": + if e.ComplexityRoot.GlobalMetricListItem.Tooltip == nil { + break + } + + return e.ComplexityRoot.GlobalMetricListItem.Tooltip(childComplexity), true case "GlobalMetricListItem.unit": if e.ComplexityRoot.GlobalMetricListItem.Unit == nil { break @@ -1225,6 +1233,12 @@ func (e *executableSchema) Complexity(ctx context.Context, typeName, field strin } return e.ComplexityRoot.MetricConfig.Timestep(childComplexity), true + case "MetricConfig.tooltip": + if e.ComplexityRoot.MetricConfig.Tooltip == nil { + break + } + + return e.ComplexityRoot.MetricConfig.Tooltip(childComplexity), true case "MetricConfig.unit": if e.ComplexityRoot.MetricConfig.Unit == nil { break @@ -2426,6 +2440,7 @@ type MetricConfig { alert: Float! lowerIsBetter: Boolean subClusters: [SubClusterConfig!]! + tooltip: String } type Tag { @@ -2589,6 +2604,7 @@ type GlobalMetricListItem { unit: Unit! scope: MetricScope! footprint: String + tooltip: String availability: [ClusterSupport!]! } @@ -2973,6 +2989,8 @@ func (ec *executionContext) childFields_GlobalMetricListItem(ctx context.Context return ec.fieldContext_GlobalMetricListItem_scope(ctx, field) case "footprint": return ec.fieldContext_GlobalMetricListItem_footprint(ctx, field) + case "tooltip": + return ec.fieldContext_GlobalMetricListItem_tooltip(ctx, field) case "availability": return ec.fieldContext_GlobalMetricListItem_availability(ctx, field) } @@ -3203,6 +3221,8 @@ func (ec *executionContext) childFields_MetricConfig(ctx context.Context, field return ec.fieldContext_MetricConfig_lowerIsBetter(ctx, field) case "subClusters": return ec.fieldContext_MetricConfig_subClusters(ctx, field) + case "tooltip": + return ec.fieldContext_MetricConfig_tooltip(ctx, field) } return nil, fmt.Errorf("no field named %q was found under type MetricConfig", field.Name) } @@ -5187,6 +5207,29 @@ func (ec *executionContext) fieldContext_GlobalMetricListItem_footprint(_ contex return graphql.NewScalarFieldContext("GlobalMetricListItem", field, false, false, errors.New("field of type String does not have child fields")) } +func (ec *executionContext) _GlobalMetricListItem_tooltip(ctx context.Context, field graphql.CollectedField, obj *schema.GlobalMetricListItem) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return ec.fieldContext_GlobalMetricListItem_tooltip(ctx, field) + }, + func(ctx context.Context) (any, error) { + return obj.Tooltip, nil + }, + nil, + func(ctx context.Context, selections ast.SelectionSet, v string) graphql.Marshaler { + return ec.marshalOString2string(ctx, selections, v) + }, + true, + false, + ) +} +func (ec *executionContext) fieldContext_GlobalMetricListItem_tooltip(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + return graphql.NewScalarFieldContext("GlobalMetricListItem", field, false, false, errors.New("field of type String does not have child fields")) +} + func (ec *executionContext) _GlobalMetricListItem_availability(ctx context.Context, field graphql.CollectedField, obj *schema.GlobalMetricListItem) (ret graphql.Marshaler) { return graphql.ResolveField( ctx, @@ -7377,6 +7420,29 @@ func (ec *executionContext) fieldContext_MetricConfig_subClusters(_ context.Cont return fc, nil } +func (ec *executionContext) _MetricConfig_tooltip(ctx context.Context, field graphql.CollectedField, obj *schema.MetricConfig) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return ec.fieldContext_MetricConfig_tooltip(ctx, field) + }, + func(ctx context.Context) (any, error) { + return obj.Tooltip, nil + }, + nil, + func(ctx context.Context, selections ast.SelectionSet, v string) graphql.Marshaler { + return ec.marshalOString2string(ctx, selections, v) + }, + true, + false, + ) +} +func (ec *executionContext) fieldContext_MetricConfig_tooltip(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + return graphql.NewScalarFieldContext("MetricConfig", field, false, false, errors.New("field of type String does not have child fields")) +} + func (ec *executionContext) _MetricFootprints_metric(ctx context.Context, field graphql.CollectedField, obj *model.MetricFootprints) (ret graphql.Marshaler) { return graphql.ResolveField( ctx, @@ -13311,6 +13377,11 @@ func (ec *executionContext) _GlobalMetricListItem(ctx context.Context, sel ast.S if out.Values[i] == graphql.RequiredNull { out.Invalids++ } + case "tooltip": + out.Values[i] = ec._GlobalMetricListItem_tooltip(ctx, field, obj) + if out.Values[i] == graphql.RequiredNull { + out.Invalids++ + } case "availability": out.Values[i] = ec._GlobalMetricListItem_availability(ctx, field, obj) if out.Values[i] == graphql.Null { @@ -14340,6 +14411,11 @@ func (ec *executionContext) _MetricConfig(ctx context.Context, sel ast.Selection if out.Values[i] == graphql.Null { out.Invalids++ } + case "tooltip": + out.Values[i] = ec._MetricConfig_tooltip(ctx, field, obj) + if out.Values[i] == graphql.RequiredNull { + out.Invalids++ + } default: panic("unknown field " + strconv.Quote(field.Name)) } diff --git a/internal/graph/schema.resolvers.go b/internal/graph/schema.resolvers.go index 48b5e935..dc76ecce 100644 --- a/internal/graph/schema.resolvers.go +++ b/internal/graph/schema.resolvers.go @@ -528,7 +528,7 @@ func (r *queryResolver) JobMetrics(ctx context.Context, id string, metrics []str } res := []*model.JobMetricWithName{} - for name, md := range data { + for name, md := range data.Metrics { for scope, metric := range md { res = append(res, &model.JobMetricWithName{ Name: name, @@ -581,7 +581,7 @@ func (r *queryResolver) ScopedJobStats(ctx context.Context, id string, metrics [ } res := make([]*model.NamedStatsWithScope, 0) - for name, scoped := range data { + for name, scoped := range data.Metrics { for scope, stats := range scoped { mdlStats := make([]*model.ScopedStats, 0) @@ -939,7 +939,7 @@ func (r *queryResolver) NodeMetricsList(ctx context.Context, cluster string, sub cclog.Warnf("error in nodeMetrics resolver: %s", err) } - for metric, scopedMetrics := range data[hostname] { + for metric, scopedMetrics := range data[hostname].Metrics { for scope, scopedMetric := range scopedMetrics { host.Metrics = append(host.Metrics, &model.JobMetricWithName{ Name: metric, diff --git a/internal/graph/util.go b/internal/graph/util.go index 7c0c0346..8e90a09f 100644 --- a/internal/graph/util.go +++ b/internal/graph/util.go @@ -61,7 +61,7 @@ func (r *queryResolver) rooflineHeatmap( return nil, err } - flops_, membw_ := jobdata["flops_any"], jobdata["mem_bw"] + flops_, membw_ := jobdata.Metrics["flops_any"], jobdata.Metrics["mem_bw"] if flops_ == nil && membw_ == nil { cclog.Warnf("rooflineHeatmap(): 'flops_any' or 'mem_bw' missing for job %d", *job.ID) continue diff --git a/internal/importer/initDB.go b/internal/importer/initDB.go index 87d92cd3..56076f84 100644 --- a/internal/importer/initDB.go +++ b/internal/importer/initDB.go @@ -290,7 +290,7 @@ func SanityChecks(job *schema.Job) error { // // TODO: Either implement the metric normalization or remove this dead code. func checkJobData(d *schema.JobData) error { - for _, scopes := range *d { + for _, scopes := range d.Metrics { // var newUnit schema.Unit // TODO Add node scope if missing for _, metric := range scopes { diff --git a/internal/metricdispatch/dataLoader.go b/internal/metricdispatch/dataLoader.go index c1b7363b..16b6be4e 100644 --- a/internal/metricdispatch/dataLoader.go +++ b/internal/metricdispatch/dataLoader.go @@ -117,7 +117,7 @@ func LoadData(job *schema.Job, jd, err = ms.LoadData(job, metrics, scopes, ctx, resolution) if err != nil { - if len(jd) != 0 { + if len(jd.Metrics) != 0 { cclog.Warnf("partial error loading metrics from store for job %d (user: %s, project: %s, cluster: %s-%s): %s", job.JobID, job.User, job.Project, job.Cluster, job.SubCluster, err.Error()) } else { @@ -144,7 +144,7 @@ func LoadData(job *schema.Job, if rfErr != nil { return rfErr, 0, 0 } - for _, v := range jd { + for _, v := range jd.Metrics { for _, v_ := range v { timestep := int64(0) for i := 0; i < len(v_.Series); i += 1 { @@ -160,17 +160,20 @@ func LoadData(job *schema.Job, // Filter job data to only include requested metrics and scopes, avoiding unnecessary data transfer. if metrics != nil || scopes != nil { if metrics == nil { - metrics = make([]string, 0, len(jd)) - for k := range jd { + metrics = make([]string, 0, len(jd.Metrics)) + for k := range jd.Metrics { metrics = append(metrics, k) } } - res := schema.JobData{} + res := schema.JobData{ + Metrics: make(map[string]schema.ScopedMetrics, len(metrics)), + Groups: jd.Groups, + } for _, metric := range metrics { - if perscope, ok := jd[metric]; ok { + if perscope, ok := jd.Metrics[metric]; ok { if len(perscope) > 1 { - subset := make(map[schema.MetricScope]*schema.JobMetric) + subset := make(schema.ScopedMetrics) for _, scope := range scopes { if jm, ok := perscope[scope]; ok { subset[scope] = jm @@ -182,7 +185,7 @@ func LoadData(job *schema.Job, } } - res[metric] = perscope + res.Metrics[metric] = perscope } } jd = res @@ -199,7 +202,7 @@ func LoadData(job *schema.Job, // instead of overwhelming the UI with individual node lines. Note that newly calculated // statistics use min/median/max, while archived statistics may use min/mean/max. const maxSeriesSize int = 8 - for _, scopes := range jd { + for _, scopes := range jd.Metrics { for _, jm := range scopes { if jm.StatisticsSeries != nil || len(jm.Series) < maxSeriesSize { continue @@ -229,7 +232,7 @@ func LoadData(job *schema.Job, if err, ok := data.(error); ok { cclog.Errorf("error in cached dataset for job %d: %s", job.JobID, err.Error()) - return nil, err + return schema.JobData{}, err } return data.(schema.JobData), nil @@ -296,14 +299,14 @@ func LoadScopedJobStats( if err != nil { cclog.Errorf("failed to access metricDataRepo for cluster %s-%s: %s", job.Cluster, job.SubCluster, err.Error()) - return nil, err + return schema.ScopedJobStats{}, err } scopedStats, err := ms.LoadScopedStats(job, metrics, scopes, ctx) if err != nil { cclog.Warnf("failed to load scoped statistics from metric store for job %d (user: %s, project: %s, cluster: %s-%s): %s", job.JobID, job.User, job.Project, job.Cluster, job.SubCluster, err.Error()) - return nil, err + return schema.ScopedJobStats{}, err } // Round Resulting Stat Values @@ -451,7 +454,7 @@ func LoadNodeListData( // Statistics are calculated as min/median/max. const maxSeriesSize int = 8 for _, jd := range data { - for _, scopes := range jd { + for _, scopes := range jd.Metrics { for _, jm := range scopes { if jm.StatisticsSeries != nil || len(jm.Series) < maxSeriesSize { continue @@ -472,14 +475,32 @@ func LoadNodeListData( // archived data (e.g., during resampling). This ensures the cached archive data remains // immutable while allowing per-request transformations. func deepCopy(source schema.JobData) schema.JobData { - result := make(schema.JobData, len(source)) + result := schema.JobData{Metrics: copyScopedMetrics(source.Metrics)} + + for _, group := range source.Groups { + copied := schema.MetricGroup{Key: group.Key} + for _, inst := range group.Instances { + copied.Instances = append(copied.Instances, schema.MetricGroupInstance{ + Name: inst.Name, + Type: inst.Type, + Metrics: copyScopedMetrics(inst.Metrics), + }) + } + result.Groups = append(result.Groups, copied) + } + + return result +} + +func copyScopedMetrics(source map[string]schema.ScopedMetrics) map[string]schema.ScopedMetrics { + result := make(map[string]schema.ScopedMetrics, len(source)) for metricName, scopeMap := range source { - result[metricName] = make(map[schema.MetricScope]*schema.JobMetric, len(scopeMap)) - + scopes := make(schema.ScopedMetrics, len(scopeMap)) for scope, jobMetric := range scopeMap { - result[metricName][scope] = copyJobMetric(jobMetric) + scopes[scope] = copyJobMetric(jobMetric) } + result[metricName] = scopes } return result diff --git a/internal/metricdispatch/dataLoader_test.go b/internal/metricdispatch/dataLoader_test.go index 65a366f9..aadd8688 100644 --- a/internal/metricdispatch/dataLoader_test.go +++ b/internal/metricdispatch/dataLoader_test.go @@ -13,7 +13,7 @@ import ( func TestDeepCopy(t *testing.T) { nodeId := "0" - original := schema.JobData{ + original := schema.JobData{Metrics: map[string]schema.ScopedMetrics{ "cpu_load": { schema.MetricScopeNode: &schema.JobMetric{ Timestep: 60, @@ -42,42 +42,42 @@ func TestDeepCopy(t *testing.T) { }, }, }, - } + }} copied := deepCopy(original) - original["cpu_load"][schema.MetricScopeNode].Series[0].Data[0] = 999.0 - original["cpu_load"][schema.MetricScopeNode].StatisticsSeries.Min[0] = 888.0 - original["cpu_load"][schema.MetricScopeNode].StatisticsSeries.Percentiles[25][0] = 777.0 + original.Metrics["cpu_load"][schema.MetricScopeNode].Series[0].Data[0] = 999.0 + original.Metrics["cpu_load"][schema.MetricScopeNode].StatisticsSeries.Min[0] = 888.0 + original.Metrics["cpu_load"][schema.MetricScopeNode].StatisticsSeries.Percentiles[25][0] = 777.0 - if copied["cpu_load"][schema.MetricScopeNode].Series[0].Data[0] != 1.0 { + if copied.Metrics["cpu_load"][schema.MetricScopeNode].Series[0].Data[0] != 1.0 { t.Errorf("Series data was not deeply copied: got %v, want 1.0", - copied["cpu_load"][schema.MetricScopeNode].Series[0].Data[0]) + copied.Metrics["cpu_load"][schema.MetricScopeNode].Series[0].Data[0]) } - if copied["cpu_load"][schema.MetricScopeNode].StatisticsSeries.Min[0] != 1.0 { + if copied.Metrics["cpu_load"][schema.MetricScopeNode].StatisticsSeries.Min[0] != 1.0 { t.Errorf("StatisticsSeries was not deeply copied: got %v, want 1.0", - copied["cpu_load"][schema.MetricScopeNode].StatisticsSeries.Min[0]) + copied.Metrics["cpu_load"][schema.MetricScopeNode].StatisticsSeries.Min[0]) } - if copied["cpu_load"][schema.MetricScopeNode].StatisticsSeries.Percentiles[25][0] != 1.5 { + if copied.Metrics["cpu_load"][schema.MetricScopeNode].StatisticsSeries.Percentiles[25][0] != 1.5 { t.Errorf("Percentiles was not deeply copied: got %v, want 1.5", - copied["cpu_load"][schema.MetricScopeNode].StatisticsSeries.Percentiles[25][0]) + copied.Metrics["cpu_load"][schema.MetricScopeNode].StatisticsSeries.Percentiles[25][0]) } - if copied["cpu_load"][schema.MetricScopeNode].Timestep != 60 { + if copied.Metrics["cpu_load"][schema.MetricScopeNode].Timestep != 60 { t.Errorf("Timestep not copied correctly: got %v, want 60", - copied["cpu_load"][schema.MetricScopeNode].Timestep) + copied.Metrics["cpu_load"][schema.MetricScopeNode].Timestep) } - if copied["cpu_load"][schema.MetricScopeNode].Series[0].Hostname != "node001" { + if copied.Metrics["cpu_load"][schema.MetricScopeNode].Series[0].Hostname != "node001" { t.Errorf("Hostname not copied correctly: got %v, want node001", - copied["cpu_load"][schema.MetricScopeNode].Series[0].Hostname) + copied.Metrics["cpu_load"][schema.MetricScopeNode].Series[0].Hostname) } } func TestDeepCopyNilStatisticsSeries(t *testing.T) { - original := schema.JobData{ + original := schema.JobData{Metrics: map[string]schema.ScopedMetrics{ "mem_used": { schema.MetricScopeNode: &schema.JobMetric{ Timestep: 60, @@ -90,18 +90,18 @@ func TestDeepCopyNilStatisticsSeries(t *testing.T) { StatisticsSeries: nil, }, }, - } + }} copied := deepCopy(original) - if copied["mem_used"][schema.MetricScopeNode].StatisticsSeries != nil { + if copied.Metrics["mem_used"][schema.MetricScopeNode].StatisticsSeries != nil { t.Errorf("StatisticsSeries should be nil, got %v", - copied["mem_used"][schema.MetricScopeNode].StatisticsSeries) + copied.Metrics["mem_used"][schema.MetricScopeNode].StatisticsSeries) } } func TestDeepCopyEmptyPercentiles(t *testing.T) { - original := schema.JobData{ + original := schema.JobData{Metrics: map[string]schema.ScopedMetrics{ "cpu_load": { schema.MetricScopeNode: &schema.JobMetric{ Timestep: 60, @@ -115,11 +115,11 @@ func TestDeepCopyEmptyPercentiles(t *testing.T) { }, }, }, - } + }} copied := deepCopy(original) - if copied["cpu_load"][schema.MetricScopeNode].StatisticsSeries.Percentiles != nil { + if copied.Metrics["cpu_load"][schema.MetricScopeNode].StatisticsSeries.Percentiles != nil { t.Errorf("Percentiles should be nil when source is nil/empty") } } diff --git a/internal/metricstoreclient/cc-metric-store.go b/internal/metricstoreclient/cc-metric-store.go index 53ace245..11132a08 100644 --- a/internal/metricstoreclient/cc-metric-store.go +++ b/internal/metricstoreclient/cc-metric-store.go @@ -235,7 +235,7 @@ func (ccms *CCMetricStore) LoadData( queries, assignedScope, err := ccms.buildQueries(job, metrics, scopes, resolution) if err != nil { cclog.Errorf("Error while building queries for jobId %d, Metrics %v, Scopes %v: %s", job.JobID, metrics, scopes, err.Error()) - return nil, err + return schema.JobData{}, err } // Verify assignment is correct - log any inconsistencies for debugging @@ -256,11 +256,11 @@ func (ccms *CCMetricStore) LoadData( resBody, err := ccms.doRequest(ctx, &req) if err != nil { cclog.Errorf("Error while performing request for job %d: %s", job.JobID, err.Error()) - return nil, err + return schema.JobData{}, err } var errors []string - jobData := make(schema.JobData) + jobData := schema.JobData{Metrics: make(map[string]schema.ScopedMetrics)} // Add safety check for potential index out of range errors if len(resBody.Results) != len(req.Queries) || len(assignedScope) != len(req.Queries) { @@ -285,8 +285,8 @@ func (ccms *CCMetricStore) LoadData( continue } - if _, ok := jobData[metric]; !ok { - jobData[metric] = make(map[schema.MetricScope]*schema.JobMetric) + if _, ok := jobData.Metrics[metric]; !ok { + jobData.Metrics[metric] = make(schema.ScopedMetrics) } res := mc.Timestep @@ -294,14 +294,14 @@ func (ccms *CCMetricStore) LoadData( res = row[0].Resolution } - jobMetric, ok := jobData[metric][scope] + jobMetric, ok := jobData.Metrics[metric][scope] if !ok { jobMetric = &schema.JobMetric{ Unit: mc.Unit, Timestep: res, Series: make([]schema.Series, 0), } - jobData[metric][scope] = jobMetric + jobData.Metrics[metric][scope] = jobMetric } for ndx, res := range row { @@ -329,9 +329,9 @@ func (ccms *CCMetricStore) LoadData( // So that one can later check len(jobData): if len(jobMetric.Series) == 0 { - delete(jobData[metric], scope) - if len(jobData[metric]) == 0 { - delete(jobData, metric) + delete(jobData.Metrics[metric], scope) + if len(jobData.Metrics[metric]) == 0 { + delete(jobData.Metrics, metric) } } } @@ -426,7 +426,7 @@ func (ccms *CCMetricStore) LoadScopedStats( queries, assignedScope, err := ccms.buildQueries(job, metrics, scopes, 0) if err != nil { cclog.Errorf("Error while building queries for jobId %d, Metrics %v, Scopes %v: %s", job.JobID, metrics, scopes, err.Error()) - return nil, err + return schema.ScopedJobStats{}, err } req := APIQueryRequest{ @@ -441,23 +441,23 @@ func (ccms *CCMetricStore) LoadScopedStats( resBody, err := ccms.doRequest(ctx, &req) if err != nil { cclog.Errorf("Error while performing request for job %d: %s", job.JobID, err.Error()) - return nil, err + return schema.ScopedJobStats{}, err } var errors []string - scopedJobStats := make(schema.ScopedJobStats) + scopedJobStats := schema.ScopedJobStats{Metrics: make(map[string]schema.ScopedMetricStats)} for i, row := range resBody.Results { query := req.Queries[i] metric := query.Metric scope := assignedScope[i] - if _, ok := scopedJobStats[metric]; !ok { - scopedJobStats[metric] = make(map[schema.MetricScope][]*schema.ScopedStats) + if _, ok := scopedJobStats.Metrics[metric]; !ok { + scopedJobStats.Metrics[metric] = make(schema.ScopedMetricStats) } - if _, ok := scopedJobStats[metric][scope]; !ok { - scopedJobStats[metric][scope] = make([]*schema.ScopedStats, 0) + if _, ok := scopedJobStats.Metrics[metric][scope]; !ok { + scopedJobStats.Metrics[metric][scope] = make([]*schema.ScopedStats, 0) } for ndx, res := range row { @@ -471,7 +471,7 @@ func (ccms *CCMetricStore) LoadScopedStats( ms.SanitizeStats(&res.Avg, &res.Min, &res.Max) - scopedJobStats[metric][scope] = append(scopedJobStats[metric][scope], &schema.ScopedStats{ + scopedJobStats.Metrics[metric][scope] = append(scopedJobStats.Metrics[metric][scope], &schema.ScopedStats{ Hostname: query.Hostname, ID: id, Data: &schema.MetricStatistics{ @@ -483,10 +483,10 @@ func (ccms *CCMetricStore) LoadScopedStats( } // So that one can later check len(scopedJobStats[metric][scope]): Remove from map if empty - if len(scopedJobStats[metric][scope]) == 0 { - delete(scopedJobStats[metric], scope) - if len(scopedJobStats[metric]) == 0 { - delete(scopedJobStats, metric) + if len(scopedJobStats.Metrics[metric][scope]) == 0 { + delete(scopedJobStats.Metrics[metric], scope) + if len(scopedJobStats.Metrics[metric]) == 0 { + delete(scopedJobStats.Metrics, metric) } } } @@ -691,14 +691,14 @@ func (ccms *CCMetricStore) LoadNodeListData( // Init Nested Map Data Structures If Not Found hostData, ok := data[query.Hostname] if !ok { - hostData = make(schema.JobData) + hostData = schema.JobData{Metrics: make(map[string]schema.ScopedMetrics)} data[query.Hostname] = hostData } - metricData, ok := hostData[metric] + metricData, ok := hostData.Metrics[metric] if !ok { - metricData = make(map[schema.MetricScope]*schema.JobMetric) - data[query.Hostname][metric] = metricData + metricData = make(schema.ScopedMetrics) + hostData.Metrics[metric] = metricData } scopeData, ok := metricData[scope] @@ -708,7 +708,7 @@ func (ccms *CCMetricStore) LoadNodeListData( Timestep: res, Series: make([]schema.Series, 0), } - data[query.Hostname][metric][scope] = scopeData + metricData[scope] = scopeData } for ndx, res := range row { diff --git a/internal/repository/stats.go b/internal/repository/stats.go index fb0bc075..7fea939b 100644 --- a/internal/repository/stats.go +++ b/internal/repository/stats.go @@ -403,7 +403,7 @@ func (r *JobRepository) JobsStats( // // Returns the requested statistic value or 0.0 if not found. func LoadJobStat(job *schema.Job, metric string, statType string) float64 { - if stats, ok := job.Statistics[metric]; ok { + if stats, ok := job.Statistics.Metrics[metric]; ok { switch statType { case "avg": return stats.Avg diff --git a/internal/tagger/classifyJob.go b/internal/tagger/classifyJob.go index 81698337..9e72e58b 100644 --- a/internal/tagger/classifyJob.go +++ b/internal/tagger/classifyJob.go @@ -107,7 +107,7 @@ type JobClassTagger struct { // repo provides access to job database operations repo JobRepository // getStatistics retrieves job statistics for analysis - getStatistics func(job *schema.Job) (map[string]schema.JobStatistics, error) + getStatistics func(job *schema.Job) (schema.JobStatisticsSet, error) // getMetricConfig retrieves metric configuration (limits) for a cluster getMetricConfig func(cluster, subCluster string) map[string]*schema.Metric } @@ -361,7 +361,7 @@ func (t *JobClassTagger) Match(job *schema.Job) { // add metrics to env skipRule := false for _, m := range ri.metrics { - stats, ok := jobStats[m] + stats, ok := jobStats.Metrics[m] if !ok { cclog.Debugf("job classification: missing metric '%s' for rule %s on job %d", m, tag, job.JobID) skipRule = true diff --git a/internal/tagger/classifyJob_test.go b/internal/tagger/classifyJob_test.go index f82cf807..8f051ed4 100644 --- a/internal/tagger/classifyJob_test.go +++ b/internal/tagger/classifyJob_test.go @@ -64,10 +64,10 @@ func TestClassifyJobMatch(t *testing.T) { parameters: make(map[string]any), tagType: "jobClass", repo: mockRepo, - getStatistics: func(job *schema.Job) (map[string]schema.JobStatistics, error) { - return map[string]schema.JobStatistics{ + getStatistics: func(job *schema.Job) (schema.JobStatisticsSet, error) { + return schema.JobStatisticsSet{Metrics: map[string]schema.JobStatistics{ "flops_any": {Min: 0, Max: 200, Avg: 150}, - }, nil + }}, nil }, getMetricConfig: func(cluster, subCluster string) map[string]*schema.Metric { return map[string]*schema.Metric{ @@ -120,10 +120,10 @@ func TestMatch_NoMatch(t *testing.T) { parameters: make(map[string]any), tagType: "jobClass", repo: mockRepo, - getStatistics: func(job *schema.Job) (map[string]schema.JobStatistics, error) { - return map[string]schema.JobStatistics{ + getStatistics: func(job *schema.Job) (schema.JobStatisticsSet, error) { + return schema.JobStatisticsSet{Metrics: map[string]schema.JobStatistics{ "flops_any": {Min: 0, Max: 50, Avg: 20}, // Avg 20 < 100 - }, nil + }}, nil }, getMetricConfig: func(cluster, subCluster string) map[string]*schema.Metric { return map[string]*schema.Metric{ diff --git a/internal/taskmanager/updateFootprintService.go b/internal/taskmanager/updateFootprintService.go index 34a18bdd..7f2fb6dd 100644 --- a/internal/taskmanager/updateFootprintService.go +++ b/internal/taskmanager/updateFootprintService.go @@ -80,7 +80,7 @@ func RegisterFootprintWorker() { continue } - job.Statistics = make(map[string]schema.JobStatistics) + job.Statistics = schema.JobStatisticsSet{Metrics: make(map[string]schema.JobStatistics, len(allMetrics))} for _, metric := range allMetrics { avg, min, max := 0.0, 0.0, 0.0 @@ -98,7 +98,7 @@ func RegisterFootprintWorker() { } // Add values rounded to 2 digits: repo.LoadStats may return unrounded - job.Statistics[metric] = schema.JobStatistics{ + job.Statistics.Metrics[metric] = schema.JobStatistics{ Unit: schema.Unit{ Prefix: archive.GetMetricConfig(job.Cluster, metric).Unit.Prefix, Base: archive.GetMetricConfig(job.Cluster, metric).Unit.Base, diff --git a/pkg/archive/archive.go b/pkg/archive/archive.go index f993f025..94a9c3c8 100644 --- a/pkg/archive/archive.go +++ b/pkg/archive/archive.go @@ -299,7 +299,7 @@ func LoadAveragesFromArchive( } for i, m := range metrics { - if stat, ok := metaFile.Statistics[m]; ok { + if stat, ok := metaFile.Statistics.Metrics[m]; ok { data[i] = append(data[i], schema.Float(stat.Avg)) } else { data[i] = append(data[i], schema.NaN) @@ -323,7 +323,7 @@ func LoadStatsFromArchive( } for _, m := range metrics { - stat, ok := metaFile.Statistics[m] + stat, ok := metaFile.Statistics.Metrics[m] if !ok { data[m] = schema.MetricStatistics{Min: 0.0, Avg: 0.0, Max: 0.0} continue @@ -349,19 +349,19 @@ func LoadScopedStatsFromArchive( data, err := ar.LoadJobStats(job) if err != nil { cclog.Errorf("Error while loading job stats from archiveBackend: %s", err.Error()) - return nil, err + return schema.ScopedJobStats{}, err } return data, nil } -// GetStatistics returns all metric statistics for a job. -// Returns a map of metric names to their job-level statistics. -func GetStatistics(job *schema.Job) (map[string]schema.JobStatistics, error) { +// GetStatistics returns all metric statistics for a job, including the +// array-valued statistics groups (e.g. filesystems). +func GetStatistics(job *schema.Job) (schema.JobStatisticsSet, error) { metaFile, err := ar.LoadJobMeta(job) if err != nil { cclog.Errorf("Error while loading job metadata from archiveBackend: %s", err.Error()) - return nil, err + return schema.JobStatisticsSet{}, err } return metaFile.Statistics, nil diff --git a/pkg/archive/clusterConfig.go b/pkg/archive/clusterConfig.go index 48fc5e48..9e10c6e3 100644 --- a/pkg/archive/clusterConfig.go +++ b/pkg/archive/clusterConfig.go @@ -63,8 +63,11 @@ func initClusterConfig() error { if _, ok := metricLookup[mc.Name]; !ok { metricLookup[mc.Name] = schema.GlobalMetricListItem{ - Name: mc.Name, Scope: mc.Scope, Unit: mc.Unit, Footprint: mc.Footprint, + Name: mc.Name, Scope: mc.Scope, Unit: mc.Unit, Footprint: mc.Footprint, Tooltip: mc.Tooltip, } + } else if item := metricLookup[mc.Name]; item.Tooltip == "" && mc.Tooltip != "" { + item.Tooltip = mc.Tooltip + metricLookup[mc.Name] = item } availability := schema.ClusterSupport{Cluster: cluster.Name} @@ -139,8 +142,10 @@ func initClusterConfig() error { userItem, ok := userMetricLookup[mc.Name] if !ok { userItem = schema.GlobalMetricListItem{ - Name: mc.Name, Scope: mc.Scope, Unit: mc.Unit, Footprint: mc.Footprint, + Name: mc.Name, Scope: mc.Scope, Unit: mc.Unit, Footprint: mc.Footprint, Tooltip: mc.Tooltip, } + } else if userItem.Tooltip == "" && mc.Tooltip != "" { + userItem.Tooltip = mc.Tooltip } userItem.Availability = append(userItem.Availability, userAvailability) userMetricLookup[mc.Name] = userItem diff --git a/pkg/archive/fsBackend.go b/pkg/archive/fsBackend.go index dfc870b4..228f720d 100644 --- a/pkg/archive/fsBackend.go +++ b/pkg/archive/fsBackend.go @@ -93,7 +93,7 @@ func loadJobData(filename string, isCompressed bool) (schema.JobData, error) { f, err := os.Open(filename) if err != nil { cclog.Errorf("fsBackend LoadJobData()- %v", err) - return nil, err + return schema.JobData{}, err } defer f.Close() @@ -101,7 +101,7 @@ func loadJobData(filename string, isCompressed bool) (schema.JobData, error) { r, err := gzip.NewReader(f) if err != nil { cclog.Errorf(" %v", err) - return nil, err + return schema.JobData{}, err } defer r.Close() @@ -126,7 +126,7 @@ func loadJobStats(filename string, isCompressed bool) (schema.ScopedJobStats, er f, err := os.Open(filename) if err != nil { cclog.Errorf("fsBackend LoadJobStats()- %v", err) - return nil, err + return schema.ScopedJobStats{}, err } defer f.Close() @@ -134,13 +134,13 @@ func loadJobStats(filename string, isCompressed bool) (schema.ScopedJobStats, er r, err := gzip.NewReader(f) if err != nil { cclog.Errorf(" %v", err) - return nil, err + return schema.ScopedJobStats{}, err } defer r.Close() if config.Keys.Validate { if err := schema.Validate(schema.Data, r); err != nil { - return nil, fmt.Errorf("validate job data: %v", err) + return schema.ScopedJobStats{}, fmt.Errorf("validate job data: %v", err) } } @@ -148,7 +148,7 @@ func loadJobStats(filename string, isCompressed bool) (schema.ScopedJobStats, er } else { if config.Keys.Validate { if err := schema.Validate(schema.Data, bufio.NewReader(f)); err != nil { - return nil, fmt.Errorf("validate job data: %v", err) + return schema.ScopedJobStats{}, fmt.Errorf("validate job data: %v", err) } } return DecodeJobStats(bufio.NewReader(f), filename) diff --git a/pkg/archive/fsBackend_test.go b/pkg/archive/fsBackend_test.go index 05491f61..6d63a6fc 100644 --- a/pkg/archive/fsBackend_test.go +++ b/pkg/archive/fsBackend_test.go @@ -129,7 +129,7 @@ func TestLoadJobData(t *testing.T) { t.Fatal(err) } - for _, scopes := range data { + for _, scopes := range data.Metrics { // fmt.Printf("Metric name: %s\n", name) if _, exists := scopes[schema.MetricScopeNode]; !exists { diff --git a/pkg/archive/json.go b/pkg/archive/json.go index dd37075d..f1aa3bec 100644 --- a/pkg/archive/json.go +++ b/pkg/archive/json.go @@ -27,7 +27,7 @@ func DecodeJobData(r io.Reader, k string) (schema.JobData, error) { if err, ok := data.(error); ok { cclog.Warn("Error in decoded job data set") - return nil, err + return schema.JobData{}, err } return data.(schema.JobData), nil @@ -35,39 +35,59 @@ func DecodeJobData(r io.Reader, k string) (schema.JobData, error) { func DecodeJobStats(r io.Reader, k string) (schema.ScopedJobStats, error) { jobData, err := DecodeJobData(r, k) - // Convert schema.JobData to schema.ScopedJobStats - if jobData != nil { - scopedJobStats := make(schema.ScopedJobStats) - for metric, metricData := range jobData { - if _, ok := scopedJobStats[metric]; !ok { - scopedJobStats[metric] = make(map[schema.MetricScope][]*schema.ScopedStats) - } - - for scope, jobMetric := range metricData { - if _, ok := scopedJobStats[metric][scope]; !ok { - scopedJobStats[metric][scope] = make([]*schema.ScopedStats, 0) - } - - for _, series := range jobMetric.Series { - scopedJobStats[metric][scope] = append(scopedJobStats[metric][scope], &schema.ScopedStats{ - Hostname: series.Hostname, - ID: series.ID, - Data: &series.Statistics, - }) - } - - // So that one can later check len(scopedJobStats[metric][scope]): Remove from map if empty - if len(scopedJobStats[metric][scope]) == 0 { - delete(scopedJobStats[metric], scope) - if len(scopedJobStats[metric]) == 0 { - delete(scopedJobStats, metric) - } - } - } - } - return scopedJobStats, nil + if err != nil { + return schema.ScopedJobStats{}, err } - return nil, err + + // Convert schema.JobData to schema.ScopedJobStats + scopedJobStats := schema.ScopedJobStats{ + Metrics: scopedStatsFromMetrics(jobData.Metrics), + } + + for _, group := range jobData.Groups { + statsGroup := schema.ScopedStatsGroup{Key: group.Key} + for _, inst := range group.Instances { + statsGroup.Instances = append(statsGroup.Instances, schema.ScopedStatsGroupInstance{ + Name: inst.Name, + Type: inst.Type, + Metrics: scopedStatsFromMetrics(inst.Metrics), + }) + } + scopedJobStats.Groups = append(scopedJobStats.Groups, statsGroup) + } + + return scopedJobStats, nil +} + +// scopedStatsFromMetrics reduces the full time series of every metric/scope to +// the per-series statistics. Scopes without any series are dropped so that +// callers can rely on len(stats[metric][scope]) being non-zero when present. +func scopedStatsFromMetrics(metrics map[string]schema.ScopedMetrics) map[string]schema.ScopedMetricStats { + stats := make(map[string]schema.ScopedMetricStats, len(metrics)) + for metric, metricData := range metrics { + scoped := make(schema.ScopedMetricStats, len(metricData)) + for scope, jobMetric := range metricData { + if len(jobMetric.Series) == 0 { + continue + } + + series := make([]*schema.ScopedStats, 0, len(jobMetric.Series)) + for i := range jobMetric.Series { + series = append(series, &schema.ScopedStats{ + Hostname: jobMetric.Series[i].Hostname, + ID: jobMetric.Series[i].ID, + Data: &jobMetric.Series[i].Statistics, + }) + } + scoped[scope] = series + } + + if len(scoped) > 0 { + stats[metric] = scoped + } + } + + return stats } func DecodeJobMeta(r io.Reader) (*schema.Job, error) { diff --git a/pkg/archive/parquet/convert.go b/pkg/archive/parquet/convert.go index 43e611e4..4302fc8c 100644 --- a/pkg/archive/parquet/convert.go +++ b/pkg/archive/parquet/convert.go @@ -23,7 +23,7 @@ func JobToParquetRow(meta *schema.Job, data *schema.JobData) (*ParquetJobRow, er } var statisticsJSON []byte - if meta.Statistics != nil { + if len(meta.Statistics.Metrics) > 0 || len(meta.Statistics.Groups) > 0 { statisticsJSON, err = json.Marshal(meta.Statistics) if err != nil { return nil, fmt.Errorf("marshal statistics: %w", err) diff --git a/pkg/archive/parquet/convert_test.go b/pkg/archive/parquet/convert_test.go index 3b2848ba..fb691b19 100644 --- a/pkg/archive/parquet/convert_test.go +++ b/pkg/archive/parquet/convert_test.go @@ -32,9 +32,9 @@ func TestParquetRowToJob(t *testing.T) { {Hostname: "node001", HWThreads: []int{0, 1, 2, 3}}, {Hostname: "node002", HWThreads: []int{4, 5, 6, 7}}, }, - Statistics: map[string]schema.JobStatistics{ + Statistics: schema.JobStatisticsSet{Metrics: map[string]schema.JobStatistics{ "cpu_load": {Avg: 50.0, Min: 10.0, Max: 90.0}, - }, + }}, Tags: []*schema.Tag{ {Type: "test", Name: "tag1"}, }, @@ -49,7 +49,7 @@ func TestParquetRowToJob(t *testing.T) { }, } - data := &schema.JobData{ + data := &schema.JobData{Metrics: map[string]schema.ScopedMetrics{ "cpu_load": { schema.MetricScopeNode: &schema.JobMetric{ Unit: schema.Unit{Base: ""}, @@ -62,7 +62,7 @@ func TestParquetRowToJob(t *testing.T) { }, }, }, - } + }} // Convert to parquet row row, err := JobToParquetRow(meta, data) @@ -134,10 +134,10 @@ func TestParquetRowToJob(t *testing.T) { t.Errorf("Resources[0].HWThreads len = %d, want 4", len(gotMeta.Resources[0].HWThreads)) } - if len(gotMeta.Statistics) != 1 { - t.Fatalf("Statistics len = %d, want 1", len(gotMeta.Statistics)) + if len(gotMeta.Statistics.Metrics) != 1 { + t.Fatalf("Statistics len = %d, want 1", len(gotMeta.Statistics.Metrics)) } - if stat, ok := gotMeta.Statistics["cpu_load"]; !ok { + if stat, ok := gotMeta.Statistics.Metrics["cpu_load"]; !ok { t.Error("Statistics missing cpu_load") } else if stat.Avg != 50.0 { t.Errorf("Statistics[cpu_load].Avg = %f, want 50.0", stat.Avg) @@ -163,7 +163,7 @@ func TestParquetRowToJob(t *testing.T) { if gotData == nil { t.Fatal("JobData is nil") } - cpuLoad, ok := (*gotData)["cpu_load"] + cpuLoad, ok := gotData.Metrics["cpu_load"] if !ok { t.Fatal("JobData missing cpu_load") } @@ -201,7 +201,7 @@ func TestParquetRowToJobNilOptionalFields(t *testing.T) { }, } - data := &schema.JobData{ + data := &schema.JobData{Metrics: map[string]schema.ScopedMetrics{ "cpu_load": { schema.MetricScopeNode: &schema.JobMetric{ Timestep: 60, @@ -210,7 +210,7 @@ func TestParquetRowToJobNilOptionalFields(t *testing.T) { }, }, }, - } + }} row, err := JobToParquetRow(meta, data) if err != nil { @@ -228,8 +228,8 @@ func TestParquetRowToJobNilOptionalFields(t *testing.T) { if gotMeta.Tags != nil { t.Errorf("Tags should be nil, got %v", gotMeta.Tags) } - if gotMeta.Statistics != nil { - t.Errorf("Statistics should be nil, got %v", gotMeta.Statistics) + if len(gotMeta.Statistics.Metrics) != 0 || len(gotMeta.Statistics.Groups) != 0 { + t.Errorf("Statistics should be empty, got %v", gotMeta.Statistics) } if gotMeta.MetaData != nil { t.Errorf("MetaData should be nil, got %v", gotMeta.MetaData) @@ -299,7 +299,7 @@ func TestRoundTripThroughParquetFile(t *testing.T) { if gotData == nil { t.Fatal("JobData is nil") } - if _, ok := (*gotData)["cpu_load"]; !ok { + if _, ok := gotData.Metrics["cpu_load"]; !ok { t.Error("JobData missing cpu_load") } } diff --git a/pkg/archive/parquet/writer_test.go b/pkg/archive/parquet/writer_test.go index 9515edc3..67805b78 100644 --- a/pkg/archive/parquet/writer_test.go +++ b/pkg/archive/parquet/writer_test.go @@ -57,7 +57,7 @@ func makeTestJob(jobID int64) (*schema.Job, *schema.JobData) { }, } - data := schema.JobData{ + data := schema.JobData{Metrics: map[string]schema.ScopedMetrics{ "cpu_load": { schema.MetricScopeNode: &schema.JobMetric{ Unit: schema.Unit{Base: ""}, @@ -70,7 +70,7 @@ func makeTestJob(jobID int64) (*schema.Job, *schema.JobData) { }, }, }, - } + }} return meta, &data } @@ -132,7 +132,7 @@ func TestJobToParquetRowConversion(t *testing.T) { if err := json.Unmarshal(decompressed, &jobData); err != nil { t.Fatalf("unmarshal metric data: %v", err) } - if _, ok := jobData["cpu_load"]; !ok { + if _, ok := jobData.Metrics["cpu_load"]; !ok { t.Error("metric data missing cpu_load key") } } diff --git a/pkg/archive/s3Backend.go b/pkg/archive/s3Backend.go index 7b82d309..25db2309 100644 --- a/pkg/archive/s3Backend.go +++ b/pkg/archive/s3Backend.go @@ -330,7 +330,7 @@ func (s3a *S3Archive) LoadJobData(job *schema.Job) (schema.JobData, error) { }) if err != nil { cclog.Errorf("S3Archive LoadJobData() > GetObject error: %v", err) - return nil, err + return schema.JobData{}, err } defer result.Body.Close() @@ -349,7 +349,7 @@ func (s3a *S3Archive) LoadJobData(job *schema.Job) (schema.JobData, error) { r, err := gzip.NewReader(result.Body) if err != nil { cclog.Errorf("S3Archive LoadJobData() > gzip error: %v", err) - return nil, err + return schema.JobData{}, err } defer r.Close() @@ -381,14 +381,14 @@ func (s3a *S3Archive) LoadJobStats(job *schema.Job) (schema.ScopedJobStats, erro }) if err != nil { cclog.Errorf("S3Archive LoadJobStats() > GetObject error: %v", err) - return nil, err + return schema.ScopedJobStats{}, err } defer result.Body.Close() if config.Keys.Validate { b, _ := io.ReadAll(result.Body) if err := schema.Validate(schema.Data, bytes.NewReader(b)); err != nil { - return nil, fmt.Errorf("validate job data: %v", err) + return schema.ScopedJobStats{}, fmt.Errorf("validate job data: %v", err) } return DecodeJobStats(bytes.NewReader(b), key) } @@ -400,14 +400,14 @@ func (s3a *S3Archive) LoadJobStats(job *schema.Job) (schema.ScopedJobStats, erro r, err := gzip.NewReader(result.Body) if err != nil { cclog.Errorf("S3Archive LoadJobStats() > gzip error: %v", err) - return nil, err + return schema.ScopedJobStats{}, err } defer r.Close() if config.Keys.Validate { b, _ := io.ReadAll(r) if err := schema.Validate(schema.Data, bytes.NewReader(b)); err != nil { - return nil, fmt.Errorf("validate job data: %v", err) + return schema.ScopedJobStats{}, fmt.Errorf("validate job data: %v", err) } return DecodeJobStats(bytes.NewReader(b), keyGz) } diff --git a/pkg/archive/sqliteBackend.go b/pkg/archive/sqliteBackend.go index ff7bf333..3cb33ef3 100644 --- a/pkg/archive/sqliteBackend.go +++ b/pkg/archive/sqliteBackend.go @@ -252,7 +252,7 @@ func (sa *SqliteArchive) LoadJobData(job *schema.Job) (schema.JobData, error) { job.JobID, job.Cluster, job.StartTime).Scan(&dataBlob, &compressed) if err != nil { cclog.Errorf("SqliteArchive LoadJobData() > query error: %v", err) - return nil, err + return schema.JobData{}, err } key := fmt.Sprintf("%s:%d:%d", job.Cluster, job.JobID, job.StartTime) @@ -261,7 +261,7 @@ func (sa *SqliteArchive) LoadJobData(job *schema.Job) (schema.JobData, error) { gzipReader, err := gzip.NewReader(reader) if err != nil { cclog.Errorf("SqliteArchive LoadJobData() > gzip error: %v", err) - return nil, err + return schema.JobData{}, err } defer gzipReader.Close() reader = gzipReader @@ -285,7 +285,7 @@ func (sa *SqliteArchive) LoadJobStats(job *schema.Job) (schema.ScopedJobStats, e job.JobID, job.Cluster, job.StartTime).Scan(&dataBlob, &compressed) if err != nil { cclog.Errorf("SqliteArchive LoadJobStats() > query error: %v", err) - return nil, err + return schema.ScopedJobStats{}, err } key := fmt.Sprintf("%s:%d:%d", job.Cluster, job.JobID, job.StartTime) @@ -294,7 +294,7 @@ func (sa *SqliteArchive) LoadJobStats(job *schema.Job) (schema.ScopedJobStats, e gzipReader, err := gzip.NewReader(reader) if err != nil { cclog.Errorf("SqliteArchive LoadJobStats() > gzip error: %v", err) - return nil, err + return schema.ScopedJobStats{}, err } defer gzipReader.Close() reader = gzipReader @@ -303,7 +303,7 @@ func (sa *SqliteArchive) LoadJobStats(job *schema.Job) (schema.ScopedJobStats, e if config.Keys.Validate { data, _ := io.ReadAll(reader) if err := schema.Validate(schema.Data, bytes.NewReader(data)); err != nil { - return nil, fmt.Errorf("validate job data: %v", err) + return schema.ScopedJobStats{}, fmt.Errorf("validate job data: %v", err) } return DecodeJobStats(bytes.NewReader(data), key) } diff --git a/pkg/metricstore/query.go b/pkg/metricstore/query.go index 2acc92e2..81edd7dc 100644 --- a/pkg/metricstore/query.go +++ b/pkg/metricstore/query.go @@ -89,7 +89,7 @@ func (ccms *InternalMetricStore) LoadData( queries, assignedScope, err := buildQueries(job, metrics, scopes, int64(resolution)) if err != nil { cclog.Errorf("Error while building queries for jobId %d, Metrics %v, Scopes %v: %s", job.JobID, metrics, scopes, err.Error()) - return nil, err + return schema.JobData{}, err } // Verify assignment is correct - log any inconsistencies for debugging @@ -110,11 +110,11 @@ func (ccms *InternalMetricStore) LoadData( resBody, err := FetchData(req) if err != nil { cclog.Errorf("Error while fetching data : %s", err.Error()) - return nil, err + return schema.JobData{}, err } var errors []string - jobData := make(schema.JobData) + jobData := schema.JobData{Metrics: make(map[string]schema.ScopedMetrics)} // Add safety check for potential index out of range errors if len(resBody.Results) != len(req.Queries) || len(assignedScope) != len(req.Queries) { @@ -139,8 +139,8 @@ func (ccms *InternalMetricStore) LoadData( continue } - if _, ok := jobData[metric]; !ok { - jobData[metric] = make(map[schema.MetricScope]*schema.JobMetric) + if _, ok := jobData.Metrics[metric]; !ok { + jobData.Metrics[metric] = make(schema.ScopedMetrics) } res := mc.Timestep @@ -148,14 +148,14 @@ func (ccms *InternalMetricStore) LoadData( res = int(row[0].Resolution) } - jobMetric, ok := jobData[metric][scope] + jobMetric, ok := jobData.Metrics[metric][scope] if !ok { jobMetric = &schema.JobMetric{ Unit: mc.Unit, Timestep: res, Series: make([]schema.Series, 0), } - jobData[metric][scope] = jobMetric + jobData.Metrics[metric][scope] = jobMetric } for ndx, res := range row { @@ -181,11 +181,11 @@ func (ccms *InternalMetricStore) LoadData( }) } - // So that one can later check len(jobData): + // So that one can later check len(jobData.Metrics): if len(jobMetric.Series) == 0 { - delete(jobData[metric], scope) - if len(jobData[metric]) == 0 { - delete(jobData, metric) + delete(jobData.Metrics[metric], scope) + if len(jobData.Metrics[metric]) == 0 { + delete(jobData.Metrics, metric) } } } @@ -407,7 +407,7 @@ func (ccms *InternalMetricStore) LoadScopedStats( queries, assignedScope, err := buildQueries(job, metrics, scopes, 0) if err != nil { cclog.Errorf("Error while building queries for jobId %d, Metrics %v, Scopes %v: %s", job.JobID, metrics, scopes, err.Error()) - return nil, err + return schema.ScopedJobStats{}, err } req := APIQueryRequest{ @@ -422,11 +422,11 @@ func (ccms *InternalMetricStore) LoadScopedStats( resBody, err := FetchData(req) if err != nil { cclog.Errorf("Error while fetching data : %s", err.Error()) - return nil, err + return schema.ScopedJobStats{}, err } var errors []string - scopedJobStats := make(schema.ScopedJobStats) + scopedJobStats := schema.ScopedJobStats{Metrics: make(map[string]schema.ScopedMetricStats)} for i, row := range resBody.Results { if len(row) == 0 { @@ -437,12 +437,12 @@ func (ccms *InternalMetricStore) LoadScopedStats( metric := query.Metric scope := assignedScope[i] - if _, ok := scopedJobStats[metric]; !ok { - scopedJobStats[metric] = make(map[schema.MetricScope][]*schema.ScopedStats) + if _, ok := scopedJobStats.Metrics[metric]; !ok { + scopedJobStats.Metrics[metric] = make(schema.ScopedMetricStats) } - if _, ok := scopedJobStats[metric][scope]; !ok { - scopedJobStats[metric][scope] = make([]*schema.ScopedStats, 0) + if _, ok := scopedJobStats.Metrics[metric][scope]; !ok { + scopedJobStats.Metrics[metric][scope] = make([]*schema.ScopedStats, 0) } for ndx, res := range row { @@ -456,7 +456,7 @@ func (ccms *InternalMetricStore) LoadScopedStats( SanitizeStats(&res.Avg, &res.Min, &res.Max) - scopedJobStats[metric][scope] = append(scopedJobStats[metric][scope], &schema.ScopedStats{ + scopedJobStats.Metrics[metric][scope] = append(scopedJobStats.Metrics[metric][scope], &schema.ScopedStats{ Hostname: query.Hostname, ID: id, Data: &schema.MetricStatistics{ @@ -467,11 +467,11 @@ func (ccms *InternalMetricStore) LoadScopedStats( }) } - // So that one can later check len(scopedJobStats[metric][scope]): Remove from map if empty - if len(scopedJobStats[metric][scope]) == 0 { - delete(scopedJobStats[metric], scope) - if len(scopedJobStats[metric]) == 0 { - delete(scopedJobStats, metric) + // So that one can later check len(scopedJobStats.Metrics[metric][scope]): Remove from map if empty + if len(scopedJobStats.Metrics[metric][scope]) == 0 { + delete(scopedJobStats.Metrics[metric], scope) + if len(scopedJobStats.Metrics[metric]) == 0 { + delete(scopedJobStats.Metrics, metric) } } } @@ -697,14 +697,14 @@ func (ccms *InternalMetricStore) LoadNodeListData( // Init Nested Map Data Structures If Not Found hostData, ok := data[query.Hostname] if !ok { - hostData = make(schema.JobData) + hostData = schema.JobData{Metrics: make(map[string]schema.ScopedMetrics)} data[query.Hostname] = hostData } - metricData, ok := hostData[metric] + metricData, ok := hostData.Metrics[metric] if !ok { - metricData = make(map[schema.MetricScope]*schema.JobMetric) - data[query.Hostname][metric] = metricData + metricData = make(schema.ScopedMetrics) + hostData.Metrics[metric] = metricData } scopeData, ok := metricData[scope] @@ -714,7 +714,7 @@ func (ccms *InternalMetricStore) LoadNodeListData( Timestep: res, Series: make([]schema.Series, 0), } - data[query.Hostname][metric][scope] = scopeData + metricData[scope] = scopeData } for ndx, res := range row { diff --git a/tools/archive-manager/import_test.go b/tools/archive-manager/import_test.go index db8940c2..4273f489 100644 --- a/tools/archive-manager/import_test.go +++ b/tools/archive-manager/import_test.go @@ -231,9 +231,9 @@ func TestImportDataIntegrity(t *testing.T) { } // Verify metric data exists - if len(srcData) != len(dstData) { + if len(srcData.Metrics) != len(dstData.Metrics) { t.Errorf("Metric count mismatch for job %d: expected %d, got %d", - srcJob.Meta.JobID, len(srcData), len(dstData)) + srcJob.Meta.JobID, len(srcData.Metrics), len(dstData.Metrics)) } verifiedJobs++ diff --git a/web/frontend/public/global.css b/web/frontend/public/global.css index 7e4e805a..fb859a5b 100644 --- a/web/frontend/public/global.css +++ b/web/frontend/public/global.css @@ -70,3 +70,13 @@ footer { margin: 0rem 0.8rem; white-space: nowrap; } + +/* Fix: prevent Sveltestrap collapseIn from leaving the navbar invisible + when prefers-reduced-motion causes transition-duration = 0ms and Svelte + skips calling tick(), leaving the node stuck in .collapsing (height:0). */ +@media (prefers-reduced-motion: reduce) { + .navbar-collapse.collapsing { + height: auto !important; + overflow: visible !important; + } +} diff --git a/web/frontend/src/Jobs.root.svelte b/web/frontend/src/Jobs.root.svelte index 370f267c..17f87573 100644 --- a/web/frontend/src/Jobs.root.svelte +++ b/web/frontend/src/Jobs.root.svelte @@ -60,6 +60,8 @@ let presetProject = $derived(filterPresets?.project ? filterPresets.project : ""); let selectedCluster = $derived(filterPresets?.cluster ? filterPresets.cluster : null); let selectedSubCluster = $derived(filterPresets?.partition ? filterPresets.partition : null); + const maxClusters = $derived($initq?.data?.clusters?.length || 0); + const maxSubClusters = $derived($initq?.data?.clusters?.find((c) => c.name == selectedCluster)?.subClusters?.length || 0); let metrics = $derived.by(() => { if (thisInit && ccconfig) { if (selectedCluster) { @@ -243,6 +245,8 @@ presetMetrics={metrics} cluster={selectedCluster} subCluster={selectedSubCluster} + {maxClusters} + {maxSubClusters} configName="metricConfig_jobListMetrics" footprintSelect {globalMetrics} diff --git a/web/frontend/src/Systems.root.svelte b/web/frontend/src/Systems.root.svelte index b543b20f..81c0aa23 100644 --- a/web/frontend/src/Systems.root.svelte +++ b/web/frontend/src/Systems.root.svelte @@ -63,8 +63,9 @@ let pendingHostnameFilter = $state(""); let isMetricsSelectionOpen = $state(false); - /* Derived Init Return */ + /* Derived Init Returns */ const thisInit = $derived($initq?.data ? true : false); + const maxSubClusters = $derived($initq?.data?.clusters?.find((c) => c.name == cluster)?.subClusters?.length || 0); /* Derived States */ const ccconfig = $derived(thisInit ? getContext("cc-config") : null); @@ -268,6 +269,7 @@ {cluster} {subCluster} {globalMetrics} + maxSubClusters={subCluster? null: maxSubClusters} configName="nodeList_selectedMetrics" applyMetrics={(newMetrics) => selectedMetrics = [...newMetrics] diff --git a/web/frontend/src/User.root.svelte b/web/frontend/src/User.root.svelte index e1bc5483..98bd9ba9 100644 --- a/web/frontend/src/User.root.svelte +++ b/web/frontend/src/User.root.svelte @@ -90,6 +90,8 @@ const shortDuration = $derived(ccconfig?.jobList_hideShortRunningJobs); let selectedCluster = $derived(filterPresets?.cluster ? filterPresets.cluster : null); let selectedSubCluster = $derived(filterPresets?.partition ? filterPresets.partition : null); + const maxClusters = $derived($initq?.data?.clusters?.length || 0); + const maxSubClusters = $derived($initq?.data?.clusters?.find((c) => c.name == selectedCluster)?.subClusters?.length || 0); let metrics = $derived.by(() => { if (thisInit && ccconfig) { if (selectedCluster) { @@ -531,6 +533,8 @@ presetMetrics={metrics} cluster={selectedCluster} subCluster={selectedSubCluster} + {maxClusters} + {maxSubClusters} configName="metricConfig_jobListMetrics" footprintSelect {globalMetrics} diff --git a/web/frontend/src/generic/select/MetricSelection.svelte b/web/frontend/src/generic/select/MetricSelection.svelte index 8234b32c..83abd18b 100644 --- a/web/frontend/src/generic/select/MetricSelection.svelte +++ b/web/frontend/src/generic/select/MetricSelection.svelte @@ -22,6 +22,8 @@ ModalFooter, Button, ListGroup, + Icon, + Tooltip } from "@sveltestrap/sveltestrap"; import { gql, getContextClient, mutationStore } from "@urql/svelte"; @@ -33,6 +35,8 @@ presetMetrics = [], cluster = null, subCluster = null, + maxClusters = null, + maxSubClusters = null, footprintSelect = false, configName, globalMetrics, @@ -86,20 +90,45 @@ return availableMetrics; } + function printAvailabilityCount(metric, cluster) { + const avail = globalMetrics.find((gm) => gm.name === metric)?.availability + if (avail) { + if (!cluster) { + return `${avail.length} / ${maxClusters} Cluster` + } else { + const subAvail = avail.find((av) => av.cluster === cluster)?.subClusters + if (subAvail) { + return `${subAvail.length} / ${maxSubClusters} SubCluster` + } else { + return `0 / ${maxSubClusters} SubCluster` + } + } + } + return `0 / ${maxClusters} Cluster` + } + function printAvailability(metric, cluster) { const avail = globalMetrics.find((gm) => gm.name === metric)?.availability if (avail) { if (!cluster) { - return avail.map((av) => av.cluster).join(', ') + return avail.map((av) => av.cluster) } else { const subAvail = avail.find((av) => av.cluster === cluster)?.subClusters if (subAvail) { - return subAvail.join(', ') + return subAvail } else { - return `Not available for ${cluster}` + return [`Not available for ${cluster}`] } } } + return [`Not available for ${cluster}`] + } + + function printTooltip(metric) { + const toolt = globalMetrics.find((gm) => gm.name === metric)?.tooltip + if (toolt) { + return toolt + } return "" } @@ -172,7 +201,7 @@ (isOpen = !isOpen)}> - Configure columns (Metric availability shown) + Configure columns {#if footprintSelect} @@ -213,9 +242,34 @@ /> {/if} {metric} - - {printAvailability(metric, cluster)} - + {#if maxClusters !== null || maxSubClusters !== null} + + + + Availability +
    + {#each printAvailability(metric, cluster) as avail} +
  • {avail}
  • + {/each} +
+
+
+ {/if} + {#if printTooltip(metric) !== ""} + + + + Information +

+ { printTooltip(metric) } +

+
+
+ {/if} {/each}
diff --git a/web/frontend/src/generic/utils.js b/web/frontend/src/generic/utils.js index 6819bf56..f4b60034 100644 --- a/web/frontend/src/generic/utils.js +++ b/web/frontend/src/generic/utils.js @@ -81,6 +81,7 @@ export function init(extraInitQuery = "") { name scope footprint + tooltip unit { base, prefix } availability { cluster, subClusters } } diff --git a/web/frontend/src/header/NavbarLinks.svelte b/web/frontend/src/header/NavbarLinks.svelte index bb6bd0f4..275e68e3 100644 --- a/web/frontend/src/header/NavbarLinks.svelte +++ b/web/frontend/src/header/NavbarLinks.svelte @@ -41,7 +41,7 @@ {cn} - +