diff --git a/api/schema.graphqls b/api/schema.graphqls index d703990..568c15d 100644 --- a/api/schema.graphqls +++ b/api/schema.graphqls @@ -272,6 +272,7 @@ input JobFilter { input OrderByInput { field: String! + type: String!, order: SortDirectionEnum! = ASC } @@ -319,6 +320,7 @@ type HistoPoint { type MetricHistoPoints { metric: String! unit: String! + stat: String data: [MetricHistoPoint!] } diff --git a/cmd/cc-backend/server.go b/cmd/cc-backend/server.go index 5531415..d2b62e2 100644 --- a/cmd/cc-backend/server.go +++ b/cmd/cc-backend/server.go @@ -38,6 +38,15 @@ var ( apiHandle *api.RestApi ) +func onFailureResponse(rw http.ResponseWriter, r *http.Request, err error) { + rw.Header().Add("Content-Type", "application/json") + rw.WriteHeader(http.StatusUnauthorized) + json.NewEncoder(rw).Encode(map[string]string{ + "status": http.StatusText(http.StatusUnauthorized), + "error": err.Error(), + }) +} + func serverInit() { // Setup the http.Handler/Router used by the server graph.Init() @@ -166,64 +175,32 @@ func serverInit() { return authHandle.AuthApi( // On success; next, - // On failure: JSON Response - func(rw http.ResponseWriter, r *http.Request, err error) { - rw.Header().Add("Content-Type", "application/json") - rw.WriteHeader(http.StatusUnauthorized) - json.NewEncoder(rw).Encode(map[string]string{ - "status": http.StatusText(http.StatusUnauthorized), - "error": err.Error(), - }) - }) + onFailureResponse) }) userapi.Use(func(next http.Handler) http.Handler { return authHandle.AuthUserApi( // On success; next, - // On failure: JSON Response - func(rw http.ResponseWriter, r *http.Request, err error) { - rw.Header().Add("Content-Type", "application/json") - rw.WriteHeader(http.StatusUnauthorized) - json.NewEncoder(rw).Encode(map[string]string{ - "status": http.StatusText(http.StatusUnauthorized), - "error": err.Error(), - }) - }) + onFailureResponse) }) configapi.Use(func(next http.Handler) http.Handler { return authHandle.AuthConfigApi( // On success; next, - // On failure: JSON Response - func(rw http.ResponseWriter, r *http.Request, err error) { - rw.Header().Add("Content-Type", "application/json") - rw.WriteHeader(http.StatusUnauthorized) - json.NewEncoder(rw).Encode(map[string]string{ - "status": http.StatusText(http.StatusUnauthorized), - "error": err.Error(), - }) - }) + onFailureResponse) }) frontendapi.Use(func(next http.Handler) http.Handler { return authHandle.AuthFrontendApi( // On success; next, - // On failure: JSON Response - func(rw http.ResponseWriter, r *http.Request, err error) { - rw.Header().Add("Content-Type", "application/json") - rw.WriteHeader(http.StatusUnauthorized) - json.NewEncoder(rw).Encode(map[string]string{ - "status": http.StatusText(http.StatusUnauthorized), - "error": err.Error(), - }) - }) + onFailureResponse) }) } diff --git a/internal/api/rest.go b/internal/api/rest.go index 01eb429..c8f4e7a 100644 --- a/internal/api/rest.go +++ b/internal/api/rest.go @@ -119,7 +119,6 @@ func (api *RestApi) MountFrontendApiRoutes(r *mux.Router) { if api.Authentication != nil { r.HandleFunc("/jwt/", api.getJWT).Methods(http.MethodGet) r.HandleFunc("/configuration/", api.updateConfiguration).Methods(http.MethodPost) - r.HandleFunc("/jobs/metrics/{id}", api.getJobMetrics).Methods(http.MethodGet) // Fetched in Job.svelte: Needs All-User-Access-Session-Auth } } diff --git a/internal/graph/generated/generated.go b/internal/graph/generated/generated.go index d54ddb1..9ca0a60 100644 --- a/internal/graph/generated/generated.go +++ b/internal/graph/generated/generated.go @@ -211,6 +211,7 @@ type ComplexityRoot struct { MetricHistoPoints struct { Data func(childComplexity int) int Metric func(childComplexity int) int + Stat func(childComplexity int) int Unit func(childComplexity int) int } @@ -1104,6 +1105,13 @@ func (e *executableSchema) Complexity(typeName, field string, childComplexity in return e.complexity.MetricHistoPoints.Metric(childComplexity), true + case "MetricHistoPoints.stat": + if e.complexity.MetricHistoPoints.Stat == nil { + break + } + + return e.complexity.MetricHistoPoints.Stat(childComplexity), true + case "MetricHistoPoints.unit": if e.complexity.MetricHistoPoints.Unit == nil { break @@ -2100,6 +2108,7 @@ input JobFilter { input OrderByInput { field: String! + type: String!, order: SortDirectionEnum! = ASC } @@ -2147,6 +2156,7 @@ type HistoPoint { type MetricHistoPoints { metric: String! unit: String! + stat: String data: [MetricHistoPoint!] } @@ -6445,6 +6455,8 @@ func (ec *executionContext) fieldContext_JobsStatistics_histMetrics(_ context.Co return ec.fieldContext_MetricHistoPoints_metric(ctx, field) case "unit": return ec.fieldContext_MetricHistoPoints_unit(ctx, field) + case "stat": + return ec.fieldContext_MetricHistoPoints_stat(ctx, field) case "data": return ec.fieldContext_MetricHistoPoints_data(ctx, field) } @@ -7295,6 +7307,47 @@ func (ec *executionContext) fieldContext_MetricHistoPoints_unit(_ context.Contex return fc, nil } +func (ec *executionContext) _MetricHistoPoints_stat(ctx context.Context, field graphql.CollectedField, obj *model.MetricHistoPoints) (ret graphql.Marshaler) { + fc, err := ec.fieldContext_MetricHistoPoints_stat(ctx, field) + if err != nil { + return graphql.Null + } + ctx = graphql.WithFieldContext(ctx, fc) + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + ret = graphql.Null + } + }() + resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) { + ctx = rctx // use context from middleware stack in children + return obj.Stat, nil + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + return graphql.Null + } + res := resTmp.(*string) + fc.Result = res + return ec.marshalOString2ᚖstring(ctx, field.Selections, res) +} + +func (ec *executionContext) fieldContext_MetricHistoPoints_stat(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "MetricHistoPoints", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type String does not have child fields") + }, + } + return fc, nil +} + func (ec *executionContext) _MetricHistoPoints_data(ctx context.Context, field graphql.CollectedField, obj *model.MetricHistoPoints) (ret graphql.Marshaler) { fc, err := ec.fieldContext_MetricHistoPoints_data(ctx, field) if err != nil { @@ -13217,7 +13270,7 @@ func (ec *executionContext) unmarshalInputOrderByInput(ctx context.Context, obj asMap["order"] = "ASC" } - fieldsInOrder := [...]string{"field", "order"} + fieldsInOrder := [...]string{"field", "type", "order"} for _, k := range fieldsInOrder { v, ok := asMap[k] if !ok { @@ -13231,6 +13284,13 @@ func (ec *executionContext) unmarshalInputOrderByInput(ctx context.Context, obj return it, err } it.Field = data + case "type": + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("type")) + data, err := ec.unmarshalNString2string(ctx, v) + if err != nil { + return it, err + } + it.Type = data case "order": ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("order")) data, err := ec.unmarshalNSortDirectionEnum2githubᚗcomᚋClusterCockpitᚋccᚑbackendᚋinternalᚋgraphᚋmodelᚐSortDirectionEnum(ctx, v) @@ -14673,6 +14733,8 @@ func (ec *executionContext) _MetricHistoPoints(ctx context.Context, sel ast.Sele if out.Values[i] == graphql.Null { out.Invalids++ } + case "stat": + out.Values[i] = ec._MetricHistoPoints_stat(ctx, field, obj) case "data": out.Values[i] = ec._MetricHistoPoints_data(ctx, field, obj) default: diff --git a/internal/graph/model/models_gen.go b/internal/graph/model/models_gen.go index e3b4a11..6c731a2 100644 --- a/internal/graph/model/models_gen.go +++ b/internal/graph/model/models_gen.go @@ -123,6 +123,7 @@ type MetricHistoPoint struct { type MetricHistoPoints struct { Metric string `json:"metric"` Unit string `json:"unit"` + Stat *string `json:"stat,omitempty"` Data []*MetricHistoPoint `json:"data,omitempty"` } @@ -142,6 +143,7 @@ type NodeMetrics struct { type OrderByInput struct { Field string `json:"field"` + Type string `json:"type"` Order SortDirectionEnum `json:"order"` } diff --git a/internal/metricdata/metricdata.go b/internal/metricdata/metricdata.go index c826113..eba9dee 100644 --- a/internal/metricdata/metricdata.go +++ b/internal/metricdata/metricdata.go @@ -307,6 +307,10 @@ func ArchiveJob(job *schema.Job, ctx context.Context) (*schema.JobMeta, error) { scopes = append(scopes, schema.MetricScopeCore) } + if job.NumAcc > 0 { + scopes = append(scopes, schema.MetricScopeAccelerator) + } + jobData, err := LoadData(job, allMetrics, scopes, ctx) if err != nil { log.Error("Error wile loading job data for archiving") diff --git a/internal/repository/jobQuery.go b/internal/repository/jobQuery.go index c52577d..7b575ef 100644 --- a/internal/repository/jobQuery.go +++ b/internal/repository/jobQuery.go @@ -31,14 +31,28 @@ func (r *JobRepository) QueryJobs( if order != nil { field := toSnakeCase(order.Field) - - switch order.Order { - case model.SortDirectionEnumAsc: - query = query.OrderBy(fmt.Sprintf("job.%s ASC", field)) - case model.SortDirectionEnumDesc: - query = query.OrderBy(fmt.Sprintf("job.%s DESC", field)) - default: - return nil, errors.New("REPOSITORY/QUERY > invalid sorting order") + if order.Type == "col" { + // "col": Fixed column name query + switch order.Order { + case model.SortDirectionEnumAsc: + query = query.OrderBy(fmt.Sprintf("job.%s ASC", field)) + case model.SortDirectionEnumDesc: + query = query.OrderBy(fmt.Sprintf("job.%s DESC", field)) + default: + return nil, errors.New("REPOSITORY/QUERY > invalid sorting order for column") + } + } else { + // "foot": Order by footprint JSON field values + // Verify and Search Only in Valid Jsons + query = query.Where("JSON_VALID(meta_data)") + switch order.Order { + case model.SortDirectionEnumAsc: + query = query.OrderBy(fmt.Sprintf("JSON_EXTRACT(footprint, \"$.%s\") ASC", field)) + case model.SortDirectionEnumDesc: + query = query.OrderBy(fmt.Sprintf("JSON_EXTRACT(footprint, \"$.%s\") DESC", field)) + default: + return nil, errors.New("REPOSITORY/QUERY > invalid sorting order for footprint") + } } } @@ -177,8 +191,8 @@ func BuildWhereClause(filter *model.JobFilter, query sq.SelectBuilder) sq.Select query = buildStringCondition("job.resources", filter.Node, query) } if filter.MetricStats != nil { - for _, m := range filter.MetricStats { - query = buildFloatJsonCondition("job.metric_stats", m.Range, query) + for _, ms := range filter.MetricStats { + query = buildFloatJsonCondition(ms.MetricName, ms.Range, query) } } return query @@ -200,8 +214,10 @@ func buildTimeCondition(field string, cond *schema.TimeRange, query sq.SelectBui } } -func buildFloatJsonCondition(field string, cond *model.FloatRange, query sq.SelectBuilder) sq.SelectBuilder { - return query.Where("JSON_EXTRACT(footprint, '$."+field+"') BETWEEN ? AND ?", cond.From, cond.To) +func buildFloatJsonCondition(condName string, condRange *model.FloatRange, query sq.SelectBuilder) sq.SelectBuilder { + // Verify and Search Only in Valid Jsons + query = query.Where("JSON_VALID(footprint)") + return query.Where("JSON_EXTRACT(footprint, \"$."+condName+"\") BETWEEN ? AND ?", condRange.From, condRange.To) } func buildStringCondition(field string, cond *model.StringInput, query sq.SelectBuilder) sq.SelectBuilder { diff --git a/internal/repository/migrations/sqlite3/08_add-footprint.down.sql b/internal/repository/migrations/sqlite3/08_add-footprint.down.sql index e69de29..8c99eb5 100644 --- a/internal/repository/migrations/sqlite3/08_add-footprint.down.sql +++ b/internal/repository/migrations/sqlite3/08_add-footprint.down.sql @@ -0,0 +1,21 @@ +ALTER TABLE job DROP energy; +ALTER TABLE job DROP energy_footprint; +ALTER TABLE job ADD COLUMN flops_any_avg; +ALTER TABLE job ADD COLUMN mem_bw_avg; +ALTER TABLE job ADD COLUMN mem_used_max; +ALTER TABLE job ADD COLUMN load_avg; +ALTER TABLE job ADD COLUMN net_bw_avg; +ALTER TABLE job ADD COLUMN net_data_vol_total; +ALTER TABLE job ADD COLUMN file_bw_avg; +ALTER TABLE job ADD COLUMN file_data_vol_total; + +UPDATE job SET flops_any_avg = json_extract(footprint, '$.flops_any_avg'); +UPDATE job SET mem_bw_avg = json_extract(footprint, '$.mem_bw_avg'); +UPDATE job SET mem_used_max = json_extract(footprint, '$.mem_used_max'); +UPDATE job SET load_avg = json_extract(footprint, '$.cpu_load_avg'); +UPDATE job SET net_bw_avg = json_extract(footprint, '$.net_bw_avg'); +UPDATE job SET net_data_vol_total = json_extract(footprint, '$.net_data_vol_total'); +UPDATE job SET file_bw_avg = json_extract(footprint, '$.file_bw_avg'); +UPDATE job SET file_data_vol_total = json_extract(footprint, '$.file_data_vol_total'); + +ALTER TABLE job DROP footprint; diff --git a/internal/repository/migrations/sqlite3/08_add-footprint.up.sql b/internal/repository/migrations/sqlite3/08_add-footprint.up.sql index 643b87e..bcd6494 100644 --- a/internal/repository/migrations/sqlite3/08_add-footprint.up.sql +++ b/internal/repository/migrations/sqlite3/08_add-footprint.up.sql @@ -1,12 +1,26 @@ +CREATE INDEX IF NOT EXISTS job_by_project ON job (project); +CREATE INDEX IF NOT EXISTS job_list_projects ON job (project, job_state); + ALTER TABLE job ADD COLUMN energy REAL NOT NULL DEFAULT 0.0; +ALTER TABLE job ADD COLUMN energy_footprint TEXT DEFAULT NULL; ALTER TABLE job ADD COLUMN footprint TEXT DEFAULT NULL; UPDATE job SET footprint = '{"flops_any_avg": 0.0}'; + UPDATE job SET footprint = json_replace(footprint, '$.flops_any_avg', job.flops_any_avg); UPDATE job SET footprint = json_insert(footprint, '$.mem_bw_avg', job.mem_bw_avg); UPDATE job SET footprint = json_insert(footprint, '$.mem_used_max', job.mem_used_max); UPDATE job SET footprint = json_insert(footprint, '$.cpu_load_avg', job.load_avg); +UPDATE job SET footprint = json_insert(footprint, '$.net_bw_avg', job.net_bw_avg) WHERE job.net_bw_avg != 0; +UPDATE job SET footprint = json_insert(footprint, '$.net_data_vol_total', job.net_data_vol_total) WHERE job.net_data_vol_total != 0; +UPDATE job SET footprint = json_insert(footprint, '$.file_bw_avg', job.file_bw_avg) WHERE job.file_bw_avg != 0; +UPDATE job SET footprint = json_insert(footprint, '$.file_data_vol_total', job.file_data_vol_total) WHERE job.file_data_vol_total != 0; + ALTER TABLE job DROP flops_any_avg; ALTER TABLE job DROP mem_bw_avg; ALTER TABLE job DROP mem_used_max; ALTER TABLE job DROP load_avg; +ALTER TABLE job DROP net_bw_avg; +ALTER TABLE job DROP net_data_vol_total; +ALTER TABLE job DROP file_bw_avg; +ALTER TABLE job DROP file_data_vol_total; diff --git a/internal/repository/stats.go b/internal/repository/stats.go index 33cafa0..81ca8d1 100644 --- a/internal/repository/stats.go +++ b/internal/repository/stats.go @@ -552,12 +552,14 @@ func (r *JobRepository) jobsMetricStatisticsHistogram( var metricConfig *schema.MetricConfig var peak float64 = 0.0 var unit string = "" + var footprintStat string = "" for _, f := range filters { if f.Cluster != nil { metricConfig = archive.GetMetricConfig(*f.Cluster.Eq, metric) peak = metricConfig.Peak unit = metricConfig.Unit.Prefix + metricConfig.Unit.Base + footprintStat = metricConfig.Footprint log.Debugf("Cluster %s filter found with peak %f for %s", *f.Cluster.Eq, peak, metric) } } @@ -572,21 +574,26 @@ func (r *JobRepository) jobsMetricStatisticsHistogram( if unit == "" { unit = m.Unit.Prefix + m.Unit.Base } + if footprintStat == "" { + footprintStat = m.Footprint + } } } } } - // log.Debugf("Metric %s: DB %s, Peak %f, Unit %s", metric, dbMetric, peak, unit) + // log.Debugf("Metric %s, Peak %f, Unit %s, Aggregation %s", metric, peak, unit, aggreg) // Make bins, see https://jereze.com/code/sql-histogram/ start := time.Now() - jm := fmt.Sprintf(`json_extract(footprint, "$.%s")`, metric) + jm := fmt.Sprintf(`json_extract(footprint, "$.%s")`, (metric + "_" + footprintStat)) crossJoinQuery := sq.Select( fmt.Sprintf(`max(%s) as max`, jm), fmt.Sprintf(`min(%s) as min`, jm), ).From("job").Where( + "JSON_VALID(footprint)", + ).Where( fmt.Sprintf(`%s is not null`, jm), ).Where( fmt.Sprintf(`%s <= %f`, jm, peak), @@ -651,7 +658,7 @@ func (r *JobRepository) jobsMetricStatisticsHistogram( points = append(points, &point) } - result := model.MetricHistoPoints{Metric: metric, Unit: unit, Data: points} + result := model.MetricHistoPoints{Metric: metric, Unit: unit, Stat: &footprintStat, Data: points} log.Debugf("Timer jobsStatisticsHistogram %s", time.Since(start)) return &result, nil diff --git a/pkg/schema/cluster.go b/pkg/schema/cluster.go index a77bd32..e9aa178 100644 --- a/pkg/schema/cluster.go +++ b/pkg/schema/cluster.go @@ -47,11 +47,11 @@ type SubCluster struct { type SubClusterConfig struct { Name string `json:"name"` + Footprint string `json:"footprint,omitempty"` Peak float64 `json:"peak"` Normal float64 `json:"normal"` Caution float64 `json:"caution"` Alert float64 `json:"alert"` - Footprint string `json:"footprint,omitempty"` Remove bool `json:"remove"` LowerIsBetter bool `json:"lowerIsBetter"` Energy bool `json:"energy"` @@ -62,14 +62,14 @@ type MetricConfig struct { Name string `json:"name"` Scope MetricScope `json:"scope"` Aggregation string `json:"aggregation"` + Footprint string `json:"footprint,omitempty"` SubClusters []*SubClusterConfig `json:"subClusters,omitempty"` - Timestep int `json:"timestep"` Peak float64 `json:"peak"` Normal float64 `json:"normal"` Caution float64 `json:"caution"` Alert float64 `json:"alert"` + Timestep int `json:"timestep"` LowerIsBetter bool `json:"lowerIsBetter"` - Footprint string `json:"footprint,omitempty"` Energy bool `json:"energy"` } diff --git a/pkg/schema/job.go b/pkg/schema/job.go index 83064c7..2a2ea95 100644 --- a/pkg/schema/job.go +++ b/pkg/schema/job.go @@ -32,7 +32,7 @@ type BaseJob struct { Footprint map[string]float64 `json:"footprint"` MetaData map[string]string `json:"metaData"` ConcurrentJobs JobLinkResultList `json:"concurrentJobs"` - Energy float64 `json:"energy"` + Energy float64 `json:"energy" db:"energy"` ArrayJobId int64 `json:"arrayJobId,omitempty" db:"array_job_id" example:"123000"` Walltime int64 `json:"walltime,omitempty" db:"walltime" example:"86400" minimum:"1"` JobID int64 `json:"jobId" db:"job_id" example:"123000"` diff --git a/pkg/schema/schemas/cluster.schema.json b/pkg/schema/schemas/cluster.schema.json index e745f99..81b138a 100644 --- a/pkg/schema/schemas/cluster.schema.json +++ b/pkg/schema/schemas/cluster.schema.json @@ -1,284 +1,319 @@ { - "$schema": "http://json-schema.org/draft/2020-12/schema", - "$id": "embedfs://cluster.schema.json", - "title": "HPC cluster description", - "description": "Meta data information of a HPC cluster", - "type": "object", - "properties": { - "name": { - "description": "The unique identifier of a cluster", - "type": "string" - }, - "metricConfig": { - "description": "Metric specifications", - "type": "array", - "items": { - "type": "object", - "properties": { - "name": { - "description": "Metric name", - "type": "string" - }, - "unit": { - "description": "Metric unit", - "$ref": "embedfs://unit.schema.json" - }, - "scope": { - "description": "Native measurement resolution", - "type": "string" - }, - "timestep": { - "description": "Frequency of timeseries points", - "type": "integer" - }, - "aggregation": { - "description": "How the metric is aggregated", - "type": "string", - "enum": [ - "sum", - "avg" - ] - }, - "peak": { - "description": "Metric peak threshold (Upper metric limit)", - "type": "number" - }, - "normal": { - "description": "Metric normal threshold", - "type": "number" - }, - "caution": { - "description": "Metric caution threshold (Suspicious but does not require immediate action)", - "type": "number" - }, - "alert": { - "description": "Metric alert threshold (Requires immediate action)", - "type": "number" - }, - "subClusters": { - "description": "Array of cluster hardware partition metric thresholds", - "type": "array", - "items": { - "type": "object", - "properties": { - "name": { - "description": "Hardware partition name", - "type": "string" - }, - "peak": { - "type": "number" - }, - "normal": { - "type": "number" - }, - "caution": { - "type": "number" - }, - "alert": { - "type": "number" - }, - "remove": { - "type": "boolean" - } - }, - "required": [ - "name" - ] - } - } - }, - "required": [ - "name", - "unit", - "scope", - "timestep", - "aggregation", - "peak", - "normal", - "caution", - "alert" - ] - }, - "minItems": 1 - }, - "subClusters": { - "description": "Array of cluster hardware partitions", - "type": "array", - "items": { - "type": "object", - "properties": { - "name": { - "description": "Hardware partition name", - "type": "string" - }, - "processorType": { - "description": "Processor type", - "type": "string" - }, - "socketsPerNode": { - "description": "Number of sockets per node", - "type": "integer" - }, - "coresPerSocket": { - "description": "Number of cores per socket", - "type": "integer" - }, - "threadsPerCore": { - "description": "Number of SMT threads per core", - "type": "integer" - }, - "flopRateScalar": { - "description": "Theoretical node peak flop rate for scalar code in GFlops/s", - "type": "object", - "properties": { - "unit": { - "description": "Metric unit", - "$ref": "embedfs://unit.schema.json" - }, - "value": { - "type": "number" - } - } - }, - "flopRateSimd": { - "description": "Theoretical node peak flop rate for SIMD code in GFlops/s", - "type": "object", - "properties": { - "unit": { - "description": "Metric unit", - "$ref": "embedfs://unit.schema.json" - }, - "value": { - "type": "number" - } - } - }, - "memoryBandwidth": { - "description": "Theoretical node peak memory bandwidth in GB/s", - "type": "object", - "properties": { - "unit": { - "description": "Metric unit", - "$ref": "embedfs://unit.schema.json" - }, - "value": { - "type": "number" - } - } - }, - "nodes": { - "description": "Node list expression", - "type": "string" - }, - "topology": { - "description": "Node topology", - "type": "object", - "properties": { - "node": { - "description": "HwTread lists of node", - "type": "array", - "items": { - "type": "integer" - } - }, - "socket": { - "description": "HwTread lists of sockets", - "type": "array", - "items": { - "type": "array", - "items": { - "type": "integer" - } - } - }, - "memoryDomain": { - "description": "HwTread lists of memory domains", - "type": "array", - "items": { - "type": "array", - "items": { - "type": "integer" - } - } - }, - "die": { - "description": "HwTread lists of dies", - "type": "array", - "items": { - "type": "array", - "items": { - "type": "integer" - } - } - }, - "core": { - "description": "HwTread lists of cores", - "type": "array", - "items": { - "type": "array", - "items": { - "type": "integer" - } - } - }, - "accelerators": { - "type": "array", - "description": "List of of accelerator devices", - "items": { - "type": "object", - "properties": { - "id": { - "type": "string", - "description": "The unique device id" - }, - "type": { - "type": "string", - "description": "The accelerator type", - "enum": [ - "Nvidia GPU", - "AMD GPU", - "Intel GPU" - ] - }, - "model": { - "type": "string", - "description": "The accelerator model" - } - }, - "required": [ - "id", - "type", - "model" - ] - } - } - }, - "required": [ - "node", - "socket", - "memoryDomain" - ] - } - }, - "required": [ - "name", - "nodes", - "topology", - "processorType", - "socketsPerNode", - "coresPerSocket", - "threadsPerCore", - "flopRateScalar", - "flopRateSimd", - "memoryBandwidth" - ] - }, - "minItems": 1 - } + "$schema": "http://json-schema.org/draft/2020-12/schema", + "$id": "embedfs://cluster.schema.json", + "title": "HPC cluster description", + "description": "Meta data information of a HPC cluster", + "type": "object", + "properties": { + "name": { + "description": "The unique identifier of a cluster", + "type": "string" }, - "required": [ - "name", - "metricConfig", - "subClusters" - ] + "metricConfig": { + "description": "Metric specifications", + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "description": "Metric name", + "type": "string" + }, + "unit": { + "description": "Metric unit", + "$ref": "embedfs://unit.schema.json" + }, + "scope": { + "description": "Native measurement resolution", + "type": "string" + }, + "timestep": { + "description": "Frequency of timeseries points", + "type": "integer" + }, + "aggregation": { + "description": "How the metric is aggregated", + "type": "string", + "enum": [ + "sum", + "avg" + ] + }, + "footprint": { + "description": "Is it a footprint metric and what type", + "type": "string", + "enum": [ + "avg", + "max", + "min" + ] + }, + "energy": { + "description": "Is it used to calculate job energy", + "type": "boolean" + }, + "lowerIsBetter": { + "description": "Is lower better.", + "type": "boolean" + }, + "peak": { + "description": "Metric peak threshold (Upper metric limit)", + "type": "number" + }, + "normal": { + "description": "Metric normal threshold", + "type": "number" + }, + "caution": { + "description": "Metric caution threshold (Suspicious but does not require immediate action)", + "type": "number" + }, + "alert": { + "description": "Metric alert threshold (Requires immediate action)", + "type": "number" + }, + "subClusters": { + "description": "Array of cluster hardware partition metric thresholds", + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "description": "Hardware partition name", + "type": "string" + }, + "footprint": { + "description": "Is it a footprint metric and what type. Overwrite global setting", + "type": "string", + "enum": [ + "avg", + "max", + "min" + ] + }, + "energy": { + "description": "Is it used to calculate job energy. Overwrite global", + "type": "boolean" + }, + "lowerIsBetter": { + "description": "Is lower better. Overwrite global", + "type": "boolean" + }, + "peak": { + "type": "number" + }, + "normal": { + "type": "number" + }, + "caution": { + "type": "number" + }, + "alert": { + "type": "number" + }, + "remove": { + "description": "Remove this metric for this subcluster", + "type": "boolean" + } + }, + "required": [ + "name" + ] + } + } + }, + "required": [ + "name", + "unit", + "scope", + "timestep", + "aggregation", + "peak", + "normal", + "caution", + "alert" + ] + }, + "minItems": 1 + }, + "subClusters": { + "description": "Array of cluster hardware partitions", + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "description": "Hardware partition name", + "type": "string" + }, + "processorType": { + "description": "Processor type", + "type": "string" + }, + "socketsPerNode": { + "description": "Number of sockets per node", + "type": "integer" + }, + "coresPerSocket": { + "description": "Number of cores per socket", + "type": "integer" + }, + "threadsPerCore": { + "description": "Number of SMT threads per core", + "type": "integer" + }, + "flopRateScalar": { + "description": "Theoretical node peak flop rate for scalar code in GFlops/s", + "type": "object", + "properties": { + "unit": { + "description": "Metric unit", + "$ref": "embedfs://unit.schema.json" + }, + "value": { + "type": "number" + } + } + }, + "flopRateSimd": { + "description": "Theoretical node peak flop rate for SIMD code in GFlops/s", + "type": "object", + "properties": { + "unit": { + "description": "Metric unit", + "$ref": "embedfs://unit.schema.json" + }, + "value": { + "type": "number" + } + } + }, + "memoryBandwidth": { + "description": "Theoretical node peak memory bandwidth in GB/s", + "type": "object", + "properties": { + "unit": { + "description": "Metric unit", + "$ref": "embedfs://unit.schema.json" + }, + "value": { + "type": "number" + } + } + }, + "nodes": { + "description": "Node list expression", + "type": "string" + }, + "topology": { + "description": "Node topology", + "type": "object", + "properties": { + "node": { + "description": "HwTread lists of node", + "type": "array", + "items": { + "type": "integer" + } + }, + "socket": { + "description": "HwTread lists of sockets", + "type": "array", + "items": { + "type": "array", + "items": { + "type": "integer" + } + } + }, + "memoryDomain": { + "description": "HwTread lists of memory domains", + "type": "array", + "items": { + "type": "array", + "items": { + "type": "integer" + } + } + }, + "die": { + "description": "HwTread lists of dies", + "type": "array", + "items": { + "type": "array", + "items": { + "type": "integer" + } + } + }, + "core": { + "description": "HwTread lists of cores", + "type": "array", + "items": { + "type": "array", + "items": { + "type": "integer" + } + } + }, + "accelerators": { + "type": "array", + "description": "List of of accelerator devices", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "The unique device id" + }, + "type": { + "type": "string", + "description": "The accelerator type", + "enum": [ + "Nvidia GPU", + "AMD GPU", + "Intel GPU" + ] + }, + "model": { + "type": "string", + "description": "The accelerator model" + } + }, + "required": [ + "id", + "type", + "model" + ] + } + } + }, + "required": [ + "node", + "socket", + "memoryDomain" + ] + } + }, + "required": [ + "name", + "nodes", + "topology", + "processorType", + "socketsPerNode", + "coresPerSocket", + "threadsPerCore", + "flopRateScalar", + "flopRateSimd", + "memoryBandwidth" + ] + }, + "minItems": 1 + } + }, + "required": [ + "name", + "metricConfig", + "subClusters" + ] } diff --git a/web/frontend/src/Analysis.root.svelte b/web/frontend/src/Analysis.root.svelte index 0592f28..b210dcb 100644 --- a/web/frontend/src/Analysis.root.svelte +++ b/web/frontend/src/Analysis.root.svelte @@ -1,5 +1,11 @@ + + @@ -285,7 +310,7 @@ {$initq.error.message} {:else if cluster} mc.name)} + availableMetrics={availableMetrics.map((av) => av.name)} bind:metricsInHistograms bind:metricsInScatterplots /> @@ -297,7 +322,7 @@ {filterPresets} disableClusterSelection={true} startTimeQuickSelect={true} - on:update={({ detail }) => { + on:update-filters={({ detail }) => { jobFilters = detail.filters; }} /> @@ -430,7 +455,7 @@ width={colWidth2} height={300} tiles={$rooflineQuery.data.rooflineHeatmap} - cluster={cluster.subClusters.length == 1 + subCluster={cluster.subClusters.length == 1 ? cluster.subClusters[0] : null} maxY={rooflineMaxY} @@ -506,7 +531,7 @@ metric, ...binsFromFootprint( $footprintsQuery.data.footprints.timeWeights, - metricConfig(cluster.name, metric)?.scope, + metricScopes[metric], $footprintsQuery.data.footprints.metrics.find( (f) => f.metric == metric, ).data, @@ -521,22 +546,8 @@ height={250} usesBins={true} title="Average Distribution of '{item.metric}'" - xlabel={`${item.metric} bin maximum ${ - (metricConfig(cluster.name, item.metric)?.unit?.prefix - ? "[" + metricConfig(cluster.name, item.metric)?.unit?.prefix - : "") + - (metricConfig(cluster.name, item.metric)?.unit?.base - ? metricConfig(cluster.name, item.metric)?.unit?.base + "]" - : "") - }`} - xunit={`${ - (metricConfig(cluster.name, item.metric)?.unit?.prefix - ? metricConfig(cluster.name, item.metric)?.unit?.prefix - : "") + - (metricConfig(cluster.name, item.metric)?.unit?.base - ? metricConfig(cluster.name, item.metric)?.unit?.base - : "") - }`} + xlabel={`${item.metric} bin maximum [${metricUnits[item.metric]}]`} + xunit={`${metricUnits[item.metric]}`} ylabel="Normalized Hours" yunit="Hours" /> @@ -578,22 +589,8 @@ {width} height={250} color={"rgba(0, 102, 204, 0.33)"} - xLabel={`${item.m1} [${ - (metricConfig(cluster.name, item.m1)?.unit?.prefix - ? metricConfig(cluster.name, item.m1)?.unit?.prefix - : "") + - (metricConfig(cluster.name, item.m1)?.unit?.base - ? metricConfig(cluster.name, item.m1)?.unit?.base - : "") - }]`} - yLabel={`${item.m2} [${ - (metricConfig(cluster.name, item.m2)?.unit?.prefix - ? metricConfig(cluster.name, item.m2)?.unit?.prefix - : "") + - (metricConfig(cluster.name, item.m2)?.unit?.base - ? metricConfig(cluster.name, item.m2)?.unit?.base - : "") - }]`} + xLabel={`${item.m1} [${metricUnits[item.m1]}]`} + yLabel={`${item.m2} [${metricUnits[item.m2]}]`} X={item.f1} Y={item.f2} S={$footprintsQuery.data.footprints.timeWeights.nodeHours} diff --git a/web/frontend/src/Config.root.svelte b/web/frontend/src/Config.root.svelte index 61e99a8..6dd68f1 100644 --- a/web/frontend/src/Config.root.svelte +++ b/web/frontend/src/Config.root.svelte @@ -1,12 +1,17 @@ - - - - - - {#if view === "job"} - - - Core Metrics Footprint - - - {/if} - - {#each footprintData as fpd, index} -
-
 {fpd.name}
- -
-
- - {#if fpd.impact === 3 || fpd.impact === -1} - - {:else if fpd.impact === 2} - - {/if} - - {#if fpd.impact === 3} - - {:else if fpd.impact === 2} - - {:else if fpd.impact === 1} - - {:else if fpd.impact === 0} - - {:else if fpd.impact === -1} - - {/if} -
-
- - {fpd.avg} / {fpd.max} - {fpd.unit}   -
-
- {fpd.message} -
-
- -
- {/each} - {#if job?.metaData?.message} -
- {@html job.metaData.message} - {/if} -
-
- - diff --git a/web/frontend/src/Jobs.root.svelte b/web/frontend/src/Jobs.root.svelte index f7c99ff..e789821 100644 --- a/web/frontend/src/Jobs.root.svelte +++ b/web/frontend/src/Jobs.root.svelte @@ -1,6 +1,14 @@ - @@ -77,11 +86,11 @@ { + on:update-filters={({ detail }) => { selectedCluster = detail.filters[0]?.cluster ? detail.filters[0].cluster.eq : null; - jobList.update(detail.filters); + jobList.queryJobs(detail.filters); }} /> @@ -91,11 +100,14 @@ {presetProject} bind:authlevel bind:roles - on:update={({ detail }) => filterComponent.update(detail)} + on:set-filter={({ detail }) => filterComponent.updateFilters(detail)} /> - jobList.refresh()} /> + { + jobList.refreshJobs() + jobList.refreshAllMetrics() + }} />
@@ -119,5 +131,5 @@ bind:metrics bind:isOpen={isMetricsSelectionOpen} bind:showFootprint - view="list" + footprintSelect={true} /> diff --git a/web/frontend/src/List.root.svelte b/web/frontend/src/List.root.svelte index bc1ac6f..13f01f0 100644 --- a/web/frontend/src/List.root.svelte +++ b/web/frontend/src/List.root.svelte @@ -1,9 +1,13 @@ + @@ -113,7 +125,7 @@ {filterPresets} startTimeQuickSelect={true} menuText="Only {type.toLowerCase()}s with jobs that match the filters will show up" - on:update={({ detail }) => { + on:update-filters={({ detail }) => { jobFilters = detail.filters; }} /> diff --git a/web/frontend/src/Node.root.svelte b/web/frontend/src/Node.root.svelte index 0a5a75e..2d58540 100644 --- a/web/frontend/src/Node.root.svelte +++ b/web/frontend/src/Node.root.svelte @@ -1,5 +1,15 @@ + + @@ -157,7 +158,7 @@ { + on:refresh={() => { const diff = Date.now() - to; from = new Date(from.getTime() + diff); to = new Date(to.getTime() + diff); @@ -195,7 +196,7 @@ >

{item.name} - {metricUnits[item.name]} + {systemUnits[item.name] ? "(" + systemUnits[item.name] + ")" : ""}

{#if item.disabled === false && item.metric} + + + @@ -108,7 +119,7 @@ {:else} { + on:refresh={() => { const diff = Date.now() - to; from = new Date(from.getTime() + diff); to = new Date(to.getTime() + diff); @@ -123,9 +134,9 @@ Metric diff --git a/web/frontend/src/User.root.svelte b/web/frontend/src/User.root.svelte index 41969d9..61d5420 100644 --- a/web/frontend/src/User.root.svelte +++ b/web/frontend/src/User.root.svelte @@ -1,6 +1,13 @@ + + @@ -123,22 +140,25 @@ {filterPresets} startTimeQuickSelect={true} bind:this={filterComponent} - on:update={({ detail }) => { + on:update-filters={({ detail }) => { jobFilters = [...detail.filters, { user: { eq: user.username } }]; selectedCluster = jobFilters[0]?.cluster ? jobFilters[0].cluster.eq : null; - jobList.update(jobFilters); + jobList.queryJobs(jobFilters); }} /> filterComponent.update(detail)} + on:set-filter={({ detail }) => filterComponent.updateFilters(detail)} /> - jobList.refresh()} /> + { + jobList.refreshJobs() + jobList.refreshAllMetrics() + }} />
@@ -245,7 +265,7 @@ usesBins={true} {width} height={250} - title="Distribution of '{item.metric}' averages" + title="Distribution of '{item.metric} ({item.stat})' footprints" xlabel={`${item.metric} bin maximum ${item?.unit ? `[${item.unit}]` : ``}`} xunit={item.unit} ylabel="Number of Jobs" @@ -272,7 +292,7 @@ bind:metrics bind:isOpen={isMetricsSelectionOpen} bind:showFootprint - view="list" + footprintSelect={true} /> - import { Icon, InputGroup, InputGroupText } from "@sveltestrap/sveltestrap"; - - export let timeseriesPlots; - - let windowSize = 100; // Goes from 0 to 100 - let windowPosition = 50; // Goes from 0 to 100 - - function updatePlots() { - let ws = windowSize / (100 * 2), - wp = windowPosition / 100; - let from = wp - ws, - to = wp + ws; - Object.values(timeseriesPlots).forEach((plot) => - plot.setTimeRange(from, to), - ); - } - - // Rendering a big job can take a long time, so we - // throttle the rerenders to every 100ms here. - let timeoutId = null; - function requestUpdatePlots() { - if (timeoutId != null) window.cancelAnimationFrame(timeoutId); - - timeoutId = window.requestAnimationFrame(() => { - updatePlots(); - timeoutId = null; - }, 100); - } - - $: requestUpdatePlots(windowSize, windowPosition); - - -
- - - - - - Window Size: - - - ({windowSize}%) - - - - Window Position: - - - -
diff --git a/web/frontend/src/PlotSelection.svelte b/web/frontend/src/analysis/PlotSelection.svelte similarity index 91% rename from web/frontend/src/PlotSelection.svelte rename to web/frontend/src/analysis/PlotSelection.svelte index b4cf58b..6a5e089 100644 --- a/web/frontend/src/PlotSelection.svelte +++ b/web/frontend/src/analysis/PlotSelection.svelte @@ -1,3 +1,12 @@ + + - handleSettingSubmit(e)}/> - handleSettingSubmit(e)}/> - handleSettingSubmit(e)}/> + handleSettingSubmit(e)}/> + handleSettingSubmit(e)}/> + handleSettingSubmit(e)}/> diff --git a/web/frontend/src/config/admin/AddUser.svelte b/web/frontend/src/config/admin/AddUser.svelte index 84aacc3..6c20d7a 100644 --- a/web/frontend/src/config/admin/AddUser.svelte +++ b/web/frontend/src/config/admin/AddUser.svelte @@ -1,4 +1,14 @@ - - - (isOpen = !isOpen)}> - Filter based on statistics (of non-running jobs) - - {#each statistics as stat} -

{stat.text}

- ( - (stat.from = detail[0]), (stat.to = detail[1]), (stat.enabled = true) - )} - min={0} - max={stat.peak} - firstSlider={stat.from} - secondSlider={stat.to} - inputFieldFrom={stat.from} - inputFieldTo={stat.to} - /> - {/each} -
- - - - - -
diff --git a/web/frontend/src/filters/Filters.svelte b/web/frontend/src/generic/Filters.svelte similarity index 88% rename from web/frontend/src/filters/Filters.svelte rename to web/frontend/src/generic/Filters.svelte index 7253ff7..a1839c7 100644 --- a/web/frontend/src/filters/Filters.svelte +++ b/web/frontend/src/generic/Filters.svelte @@ -1,15 +1,21 @@ + + + (isOpen = !isOpen)}> + Filter based on statistics (of non-running jobs) + + {#each statistics as stat} +

{stat.text}

+ ( + (stat.from = detail[0]), (stat.to = detail[1]), (stat.enabled = true) + )} + min={0} + max={stat.peak} + firstSlider={stat.from} + secondSlider={stat.to} + inputFieldFrom={stat.from} + inputFieldTo={stat.to} + /> + {/each} +
+ + + + + +
diff --git a/web/frontend/src/filters/Tags.svelte b/web/frontend/src/generic/filters/Tags.svelte similarity index 80% rename from web/frontend/src/filters/Tags.svelte rename to web/frontend/src/generic/filters/Tags.svelte index 06153ed..e42d185 100644 --- a/web/frontend/src/filters/Tags.svelte +++ b/web/frontend/src/generic/filters/Tags.svelte @@ -1,3 +1,15 @@ + + + + + + + {#if displayTitle} + + + Core Metrics Footprint + + + {/if} + + {#each footprintData as fpd, index} + {#if fpd.impact !== 4} +
+
 {fpd.name}
+ +
+
+ + {#if fpd.impact === 3 || fpd.impact === -1} + + {:else if fpd.impact === 2} + + {/if} + + {#if fpd.impact === 3} + + {:else if fpd.impact === 2} + + {:else if fpd.impact === 1} + + {:else if fpd.impact === 0} + + {:else if fpd.impact === -1} + + {/if} +
+
+ + {fpd.avg} / {fpd.max} + {fpd.unit}   +
+
+ {fpd.message} +
+ + {#if fpd.dir} + + + + {/if} + + + + {#if !fpd.dir} + + + + {/if} + + {:else} +
+
+  {fpd.name} +
+
+
+ +
+
+ {fpd.avg}  +
+
+
+ {fpd.message} + {/if} + {/each} + {#if job?.metaData?.message} +
+ {@html job.metaData.message} + {/if} +
+
+ + diff --git a/web/frontend/src/joblist/Refresher.svelte b/web/frontend/src/generic/helper/Refresher.svelte similarity index 68% rename from web/frontend/src/joblist/Refresher.svelte rename to web/frontend/src/generic/helper/Refresher.svelte index 635ffbe..bf90f3c 100644 --- a/web/frontend/src/joblist/Refresher.svelte +++ b/web/frontend/src/generic/helper/Refresher.svelte @@ -1,8 +1,11 @@ @@ -166,9 +148,9 @@ {/if} diff --git a/web/frontend/src/joblist/Pagination.svelte b/web/frontend/src/generic/joblist/Pagination.svelte similarity index 97% rename from web/frontend/src/joblist/Pagination.svelte rename to web/frontend/src/generic/joblist/Pagination.svelte index f7b7453..77f6bc9 100644 --- a/web/frontend/src/joblist/Pagination.svelte +++ b/web/frontend/src/generic/joblist/Pagination.svelte @@ -1,12 +1,13 @@ @@ -60,7 +61,7 @@ itemsPerPage = Number(itemsPerPage); } - dispatch("update", { itemsPerPage, page }); + dispatch("update-paging", { itemsPerPage, page }); } $: backButtonDisabled = (page === 1); $: nextButtonDisabled = (page >= (totalItems / itemsPerPage)); diff --git a/web/frontend/src/plots/Histogram.svelte b/web/frontend/src/generic/plots/Histogram.svelte similarity index 89% rename from web/frontend/src/plots/Histogram.svelte rename to web/frontend/src/generic/plots/Histogram.svelte index 8300384..a1bb79b 100644 --- a/web/frontend/src/plots/Histogram.svelte +++ b/web/frontend/src/generic/plots/Histogram.svelte @@ -1,7 +1,16 @@ - {#if series[0].data.length > 0} diff --git a/web/frontend/src/plots/Pie.svelte b/web/frontend/src/generic/plots/Pie.svelte similarity index 79% rename from web/frontend/src/plots/Pie.svelte rename to web/frontend/src/generic/plots/Pie.svelte index 11dc2c9..89c333c 100644 --- a/web/frontend/src/plots/Pie.svelte +++ b/web/frontend/src/generic/plots/Pie.svelte @@ -1,3 +1,17 @@ + + + +
+ +
\ No newline at end of file diff --git a/web/frontend/src/plots/Scatter.svelte b/web/frontend/src/generic/plots/Scatter.svelte similarity index 91% rename from web/frontend/src/plots/Scatter.svelte rename to web/frontend/src/generic/plots/Scatter.svelte index 911d27d..1b260a6 100644 --- a/web/frontend/src/plots/Scatter.svelte +++ b/web/frontend/src/generic/plots/Scatter.svelte @@ -1,6 +1,16 @@ -
- -
+ + +
+ +
diff --git a/web/frontend/src/filters/DoubleRangeSlider.svelte b/web/frontend/src/generic/select/DoubleRangeSlider.svelte similarity index 98% rename from web/frontend/src/filters/DoubleRangeSlider.svelte rename to web/frontend/src/generic/select/DoubleRangeSlider.svelte index 2d4795f..57bcace 100644 --- a/web/frontend/src/filters/DoubleRangeSlider.svelte +++ b/web/frontend/src/generic/select/DoubleRangeSlider.svelte @@ -4,13 +4,14 @@ Originally created by Michael Keller (https://github.com/mhkeller/svelte-double- Changes: remove dependency, text inputs, configurable value ranges, on:change event --> diff --git a/web/frontend/src/HistogramSelection.svelte b/web/frontend/src/generic/select/HistogramSelection.svelte similarity index 72% rename from web/frontend/src/HistogramSelection.svelte rename to web/frontend/src/generic/select/HistogramSelection.svelte index 39b1872..4e38123 100644 --- a/web/frontend/src/HistogramSelection.svelte +++ b/web/frontend/src/generic/select/HistogramSelection.svelte @@ -1,4 +1,14 @@ + + (isOpen = !isOpen)}> diff --git a/web/frontend/src/MetricSelection.svelte b/web/frontend/src/generic/select/MetricSelection.svelte similarity index 65% rename from web/frontend/src/MetricSelection.svelte rename to web/frontend/src/generic/select/MetricSelection.svelte index 91fd8e6..2b1151e 100644 --- a/web/frontend/src/MetricSelection.svelte +++ b/web/frontend/src/generic/select/MetricSelection.svelte @@ -1,13 +1,18 @@ + {#each links as item} diff --git a/web/frontend/src/NavbarTools.svelte b/web/frontend/src/header/NavbarTools.svelte similarity index 89% rename from web/frontend/src/NavbarTools.svelte rename to web/frontend/src/header/NavbarTools.svelte index f44b4e9..fa9cac9 100644 --- a/web/frontend/src/NavbarTools.svelte +++ b/web/frontend/src/header/NavbarTools.svelte @@ -1,3 +1,13 @@ + +