diff --git a/CLAUDE.md b/CLAUDE.md index f30c3923..406f11ba 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -96,7 +96,7 @@ The backend follows a layered architecture with clear separation of concerns: - **internal/auth**: Authentication layer - Supports local accounts, LDAP, OIDC, and JWT tokens - Implements rate limiting for login attempts -- **internal/metricstore**: Metric store with data loading API +- **pkg/metricstore**: Metric store with data loading API - In-memory metric storage with checkpointing - Query API for loading job metric data - **internal/archiver**: Job archiving to file-based archive @@ -209,7 +209,7 @@ applied automatically on startup. Version tracking in `version` table. ### Adding a new metric data backend -1. Implement metric loading functions in `internal/metricstore/query.go` +1. Implement metric loading functions in `pkg/metricstore/query.go` 2. Add cluster configuration to metric store initialization 3. Update config.json schema documentation diff --git a/README.md b/README.md index 00bcb119..475401f4 100644 --- a/README.md +++ b/README.md @@ -163,8 +163,6 @@ ln -s ./var/job-archive GraphQL schema and resolvers - [`importer`](https://github.com/ClusterCockpit/cc-backend/tree/master/internal/importer) Job data import and database initialization - - [`metricstore`](https://github.com/ClusterCockpit/cc-backend/tree/master/internal/metricstore) - In-memory metric data store with checkpointing and metric loading - [`metricdispatch`](https://github.com/ClusterCockpit/cc-backend/tree/master/internal/metricdispatch) Dispatches metric data loading to appropriate backends - [`repository`](https://github.com/ClusterCockpit/cc-backend/tree/master/internal/repository) @@ -179,6 +177,8 @@ ln -s ./var/job-archive contains Go packages that can be used by other projects. - [`archive`](https://github.com/ClusterCockpit/cc-backend/tree/master/pkg/archive) Job archive backend implementations (filesystem, S3) + - [`metricstore`](https://github.com/ClusterCockpit/cc-backend/tree/master/pkg/metricstore) + In-memory metric data store with checkpointing and metric loading - [`nats`](https://github.com/ClusterCockpit/cc-backend/tree/master/pkg/nats) NATS client and message handling - [`tools/`](https://github.com/ClusterCockpit/cc-backend/tree/master/tools) diff --git a/cmd/cc-backend/init.go b/cmd/cc-backend/init.go index 19c8dd29..09ad2084 100644 --- a/cmd/cc-backend/init.go +++ b/cmd/cc-backend/init.go @@ -12,7 +12,6 @@ import ( "encoding/json" "os" - "github.com/ClusterCockpit/cc-backend/internal/config" "github.com/ClusterCockpit/cc-backend/internal/repository" "github.com/ClusterCockpit/cc-backend/pkg/archive" cclog "github.com/ClusterCockpit/cc-lib/v2/ccLogger" @@ -35,14 +34,14 @@ const configString = ` "addr": "127.0.0.1:8080", "short-running-jobs-duration": 300, "resampling": { - "minimumPoints": 600, + "minimum-points": 600, "trigger": 300, "resolutions": [ 240, 60 ] }, - "apiAllowedIPs": [ + "api-allowed-ips": [ "*" ], "emission-constant": 317 @@ -89,7 +88,7 @@ func initEnv() { cclog.Abortf("Could not create default ./var/job-archive folder with permissions '0o777'. Application initialization failed, exited.\nError: %s\n", err.Error()) } archiveCfg := "{\"kind\": \"file\",\"path\": \"./var/job-archive\"}" - if err := archive.Init(json.RawMessage(archiveCfg), config.Keys.DisableArchive); err != nil { + if err := archive.Init(json.RawMessage(archiveCfg)); err != nil { cclog.Abortf("Could not initialize job-archive, exited.\nError: %s\n", err.Error()) } } diff --git a/cmd/cc-backend/main.go b/cmd/cc-backend/main.go index 1cc4c8cd..e6753902 100644 --- a/cmd/cc-backend/main.go +++ b/cmd/cc-backend/main.go @@ -24,11 +24,11 @@ import ( "github.com/ClusterCockpit/cc-backend/internal/auth" "github.com/ClusterCockpit/cc-backend/internal/config" "github.com/ClusterCockpit/cc-backend/internal/importer" - "github.com/ClusterCockpit/cc-backend/internal/metricstore" "github.com/ClusterCockpit/cc-backend/internal/repository" "github.com/ClusterCockpit/cc-backend/internal/tagger" "github.com/ClusterCockpit/cc-backend/internal/taskmanager" "github.com/ClusterCockpit/cc-backend/pkg/archive" + "github.com/ClusterCockpit/cc-backend/pkg/metricstore" "github.com/ClusterCockpit/cc-backend/pkg/nats" "github.com/ClusterCockpit/cc-backend/web" ccconf "github.com/ClusterCockpit/cc-lib/v2/ccConfig" @@ -107,7 +107,7 @@ func initConfiguration() error { } func initDatabase() error { - repository.Connect(config.Keys.DBDriver, config.Keys.DB) + repository.Connect(config.Keys.DB) return nil } @@ -274,7 +274,7 @@ func initSubsystems() error { cclog.Debug("Archive configuration not found, using default archive configuration") archiveCfg = json.RawMessage(defaultArchiveConfig) } - if err := archive.Init(archiveCfg, config.Keys.DisableArchive); err != nil { + if err := archive.Init(archiveCfg); err != nil { return fmt.Errorf("initializing archive: %w", err) } diff --git a/cmd/cc-backend/server.go b/cmd/cc-backend/server.go index 8d700823..5473271c 100644 --- a/cmd/cc-backend/server.go +++ b/cmd/cc-backend/server.go @@ -29,8 +29,8 @@ import ( "github.com/ClusterCockpit/cc-backend/internal/config" "github.com/ClusterCockpit/cc-backend/internal/graph" "github.com/ClusterCockpit/cc-backend/internal/graph/generated" - "github.com/ClusterCockpit/cc-backend/internal/metricstore" "github.com/ClusterCockpit/cc-backend/internal/routerConfig" + "github.com/ClusterCockpit/cc-backend/pkg/metricstore" "github.com/ClusterCockpit/cc-backend/pkg/nats" "github.com/ClusterCockpit/cc-backend/web" cclog "github.com/ClusterCockpit/cc-lib/v2/ccLogger" diff --git a/configs/config-demo.json b/configs/config-demo.json index 79670f91..c3042993 100644 --- a/configs/config-demo.json +++ b/configs/config-demo.json @@ -1,7 +1,6 @@ { "main": { - "addr": "127.0.0.1:8080", - "apiAllowedIPs": ["*"] + "addr": "127.0.0.1:8080" }, "cron": { "commit-job-worker": "1m", @@ -15,8 +14,9 @@ }, "metric-store": { "checkpoints": { - "interval": "1h" + "interval": "12h" }, - "retention-in-memory": "12h" + "retention-in-memory": "48h", + "memory-cap": 100 } -} +} \ No newline at end of file diff --git a/configs/config.json b/configs/config.json index f91d9661..00cca0af 100644 --- a/configs/config.json +++ b/configs/config.json @@ -5,17 +5,22 @@ "https-key-file": "/etc/letsencrypt/live/url/privkey.pem", "user": "clustercockpit", "group": "clustercockpit", - "apiAllowedIPs": ["*"], + "api-allowed-ips": [ + "*" + ], "short-running-jobs-duration": 300, "enable-job-taggers": true, "resampling": { - "minimumPoints": 600, + "minimum-points": 600, "trigger": 180, - "resolutions": [240, 60] + "resolutions": [ + 240, + 60 + ] }, - "apiSubjects": { - "subjectJobEvent": "cc.job.event", - "subjectNodeState": "cc.node.state" + "api-subjects": { + "subject-job-event": "cc.job.event", + "subject-node-state": "cc.node.state" } }, "nats": { @@ -37,8 +42,8 @@ "kind": "s3", "endpoint": "http://x.x.x.x", "bucket": "jobarchive", - "accessKey": "xx", - "secretKey": "xx", + "access-key": "xx", + "secret-key": "xx", "retention": { "policy": "move", "age": 365, @@ -47,10 +52,17 @@ }, "metric-store": { "checkpoints": { - "interval": "1h" + "interval": "12h", + "directory": "./var/checkpoints" }, - "retention-in-memory": "12h", - "subscriptions": [ + "memory-cap": 100, + "retention-in-memory": "48h", + "cleanup": { + "mode": "archive", + "interval": "48h", + "directory": "./var/archive" + }, + "nats-subscriptions": [ { "subscribe-to": "hpc-nats", "cluster-tag": "fritz" @@ -62,4 +74,4 @@ ] }, "ui-file": "ui-config.json" -} +} \ No newline at end of file diff --git a/configs/uiConfig.json b/configs/uiConfig.json index 2a3ed307..f2b89232 100644 --- a/configs/uiConfig.json +++ b/configs/uiConfig.json @@ -1,38 +1,38 @@ { - "jobList": { - "usePaging": false, - "showFootprint":false + "job-list": { + "use-paging": false, + "show-footprint":false }, - "jobView": { - "showPolarPlot": true, - "showFootprint": true, - "showRoofline": true, - "showStatTable": true + "job-view": { + "show-polar-plot": true, + "show-footprint": true, + "show-roofline": true, + "show-stat-table": true }, - "metricConfig": { - "jobListMetrics": ["mem_bw", "flops_dp"], - "jobViewPlotMetrics": ["mem_bw", "flops_dp"], - "jobViewTableMetrics": ["mem_bw", "flops_dp"], + "metric-config": { + "job-list-metrics": ["mem_bw", "flops_dp"], + "job-view-plot-metrics": ["mem_bw", "flops_dp"], + "job-view-table-metrics": ["mem_bw", "flops_dp"], "clusters": [ { "name": "test", - "subClusters": [ + "sub-clusters": [ { "name": "one", - "jobListMetrics": ["mem_used", "flops_sp"] + "job-list-metrics": ["mem_used", "flops_sp"] } ] } ] }, - "nodeList": { - "usePaging": true + "node-list": { + "use-paging": true }, - "plotConfiguration": { - "plotsPerRow": 3, - "colorBackground": true, - "lineWidth": 3, - "colorScheme": [ + "plot-configuration": { + "plots-per-row": 3, + "color-background": true, + "line-width": 3, + "color-scheme": [ "#00bfff", "#0000ff", "#ff00ff", diff --git a/internal/api/api_test.go b/internal/api/api_test.go index 025983c1..7153cd1d 100644 --- a/internal/api/api_test.go +++ b/internal/api/api_test.go @@ -24,9 +24,9 @@ import ( "github.com/ClusterCockpit/cc-backend/internal/config" "github.com/ClusterCockpit/cc-backend/internal/graph" "github.com/ClusterCockpit/cc-backend/internal/metricdispatch" - "github.com/ClusterCockpit/cc-backend/internal/metricstore" "github.com/ClusterCockpit/cc-backend/internal/repository" "github.com/ClusterCockpit/cc-backend/pkg/archive" + "github.com/ClusterCockpit/cc-backend/pkg/metricstore" ccconf "github.com/ClusterCockpit/cc-lib/v2/ccConfig" cclog "github.com/ClusterCockpit/cc-lib/v2/ccLogger" "github.com/ClusterCockpit/cc-lib/v2/schema" @@ -42,7 +42,7 @@ func setup(t *testing.T) *api.RestAPI { "main": { "addr": "0.0.0.0:8080", "validate": false, - "apiAllowedIPs": [ + "api-allowed-ips": [ "*" ] }, @@ -152,9 +152,9 @@ func setup(t *testing.T) *api.RestAPI { } archiveCfg := fmt.Sprintf("{\"kind\": \"file\",\"path\": \"%s\"}", jobarchive) - repository.Connect("sqlite3", dbfilepath) + repository.Connect(dbfilepath) - if err := archive.Init(json.RawMessage(archiveCfg), config.Keys.DisableArchive); err != nil { + if err := archive.Init(json.RawMessage(archiveCfg)); err != nil { t.Fatal(err) } diff --git a/internal/api/metricstore.go b/internal/api/metricstore.go index d4ab1dfe..d36df4bf 100644 --- a/internal/api/metricstore.go +++ b/internal/api/metricstore.go @@ -15,7 +15,7 @@ import ( "strconv" "strings" - "github.com/ClusterCockpit/cc-backend/internal/metricstore" + "github.com/ClusterCockpit/cc-backend/pkg/metricstore" cclog "github.com/ClusterCockpit/cc-lib/v2/ccLogger" "github.com/influxdata/line-protocol/v2/lineprotocol" diff --git a/internal/api/nats_test.go b/internal/api/nats_test.go index c6a9bcd9..b1d2a624 100644 --- a/internal/api/nats_test.go +++ b/internal/api/nats_test.go @@ -18,9 +18,9 @@ import ( "github.com/ClusterCockpit/cc-backend/internal/auth" "github.com/ClusterCockpit/cc-backend/internal/config" "github.com/ClusterCockpit/cc-backend/internal/graph" - "github.com/ClusterCockpit/cc-backend/internal/metricstore" "github.com/ClusterCockpit/cc-backend/internal/repository" "github.com/ClusterCockpit/cc-backend/pkg/archive" + "github.com/ClusterCockpit/cc-backend/pkg/metricstore" ccconf "github.com/ClusterCockpit/cc-lib/v2/ccConfig" cclog "github.com/ClusterCockpit/cc-lib/v2/ccLogger" lp "github.com/ClusterCockpit/cc-lib/v2/ccMessage" @@ -36,7 +36,7 @@ func setupNatsTest(t *testing.T) *NatsAPI { "main": { "addr": "0.0.0.0:8080", "validate": false, - "apiAllowedIPs": [ + "api-allowed-ips": [ "*" ] }, @@ -146,9 +146,9 @@ func setupNatsTest(t *testing.T) *NatsAPI { } archiveCfg := fmt.Sprintf("{\"kind\": \"file\",\"path\": \"%s\"}", jobarchive) - repository.Connect("sqlite3", dbfilepath) + repository.Connect(dbfilepath) - if err := archive.Init(json.RawMessage(archiveCfg), config.Keys.DisableArchive); err != nil { + if err := archive.Init(json.RawMessage(archiveCfg)); err != nil { t.Fatal(err) } diff --git a/internal/archiver/archiver.go b/internal/archiver/archiver.go index 4e0b6473..454a2358 100644 --- a/internal/archiver/archiver.go +++ b/internal/archiver/archiver.go @@ -9,7 +9,6 @@ import ( "context" "math" - "github.com/ClusterCockpit/cc-backend/internal/config" "github.com/ClusterCockpit/cc-backend/internal/metricdispatch" "github.com/ClusterCockpit/cc-backend/pkg/archive" cclog "github.com/ClusterCockpit/cc-lib/v2/ccLogger" @@ -94,12 +93,5 @@ func ArchiveJob(job *schema.Job, ctx context.Context) (*schema.Job, error) { } } - // If the file based archive is disabled, - // only return the JobMeta structure as the - // statistics in there are needed. - if config.Keys.DisableArchive { - return job, nil - } - return job, archive.GetHandle().ImportJob(job, &jobData) } diff --git a/internal/auth/auth.go b/internal/auth/auth.go index 41691d00..df618a3f 100644 --- a/internal/auth/auth.go +++ b/internal/auth/auth.go @@ -305,8 +305,13 @@ func (auth *Authentication) SaveSession(rw http.ResponseWriter, r *http.Request, if auth.SessionMaxAge != 0 { session.Options.MaxAge = int(auth.SessionMaxAge.Seconds()) } - if config.Keys.HTTPSCertFile == "" && config.Keys.HTTPSKeyFile == "" { - cclog.Warn("HTTPS not configured - session cookies will not have Secure flag set (insecure for production)") + if r.TLS == nil && r.Header.Get("X-Forwarded-Proto") != "https" { + // If neither TLS or an encrypted reverse proxy are used, do not mark cookies as secure. + cclog.Warn("Authenticating with unencrypted request. Session cookies will not have Secure flag set (insecure for production)") + if r.Header.Get("X-Forwarded-Proto") == "" { + // This warning will not be printed if e.g. X-Forwarded-Proto == http + cclog.Warn("If you are using a reverse proxy, make sure X-Forwarded-Proto is set") + } session.Options.Secure = false } session.Options.SameSite = http.SameSiteStrictMode @@ -616,9 +621,9 @@ func securedCheck(user *schema.User, r *http.Request) error { } // If SplitHostPort fails, IPAddress is already just a host (no port) - // If nothing declared in config: deny all request to this api endpoint + // If nothing declared in config: Continue if len(config.Keys.APIAllowedIPs) == 0 { - return fmt.Errorf("missing configuration key ApiAllowedIPs") + return nil } // If wildcard declared in config: Continue if config.Keys.APIAllowedIPs[0] == "*" { diff --git a/internal/auth/jwt.go b/internal/auth/jwt.go index c0f641b9..abdce313 100644 --- a/internal/auth/jwt.go +++ b/internal/auth/jwt.go @@ -25,20 +25,20 @@ type JWTAuthConfig struct { MaxAge string `json:"max-age"` // Specifies which cookie should be checked for a JWT token (if no authorization header is present) - CookieName string `json:"cookieName"` + CookieName string `json:"cookie-name"` // Deny login for users not in database (but defined in JWT). // Ignore user roles defined in JWTs ('roles' claim), get them from db. - ValidateUser bool `json:"validateUser"` + ValidateUser bool `json:"validate-user"` // Specifies which issuer should be accepted when validating external JWTs ('iss' claim) - TrustedIssuer string `json:"trustedIssuer"` + TrustedIssuer string `json:"trusted-issuer"` // Should an non-existent user be added to the DB based on the information in the token - SyncUserOnLogin bool `json:"syncUserOnLogin"` + SyncUserOnLogin bool `json:"sync-user-on-login"` // Should an existent user be updated in the DB based on the information in the token - UpdateUserOnLogin bool `json:"updateUserOnLogin"` + UpdateUserOnLogin bool `json:"update-user-on-login"` } type JWTAuthenticator struct { diff --git a/internal/auth/ldap.go b/internal/auth/ldap.go index 4cbb80c5..5e12f07b 100644 --- a/internal/auth/ldap.go +++ b/internal/auth/ldap.go @@ -20,16 +20,16 @@ import ( type LdapConfig struct { URL string `json:"url"` - UserBase string `json:"user_base"` - SearchDN string `json:"search_dn"` - UserBind string `json:"user_bind"` - UserFilter string `json:"user_filter"` - UserAttr string `json:"username_attr"` - SyncInterval string `json:"sync_interval"` // Parsed using time.ParseDuration. - SyncDelOldUsers bool `json:"sync_del_old_users"` + UserBase string `json:"user-base"` + SearchDN string `json:"search-dn"` + UserBind string `json:"user-bind"` + UserFilter string `json:"user-filter"` + UserAttr string `json:"username-attr"` + SyncInterval string `json:"sync-interval"` // Parsed using time.ParseDuration. + SyncDelOldUsers bool `json:"sync-del-old-users"` // Should an non-existent user be added to the DB if user exists in ldap directory - SyncUserOnLogin bool `json:"syncUserOnLogin"` + SyncUserOnLogin bool `json:"sync-user-on-login"` } type LdapAuthenticator struct { diff --git a/internal/auth/oidc.go b/internal/auth/oidc.go index b90aca4f..a3ff0c2c 100644 --- a/internal/auth/oidc.go +++ b/internal/auth/oidc.go @@ -24,8 +24,8 @@ import ( type OpenIDConfig struct { Provider string `json:"provider"` - SyncUserOnLogin bool `json:"syncUserOnLogin"` - UpdateUserOnLogin bool `json:"updateUserOnLogin"` + SyncUserOnLogin bool `json:"sync-user-on-login"` + UpdateUserOnLogin bool `json:"update-user-on-login"` } type OIDC struct { diff --git a/internal/auth/schema.go b/internal/auth/schema.go index 060ff2df..496e899b 100644 --- a/internal/auth/schema.go +++ b/internal/auth/schema.go @@ -15,37 +15,44 @@ var configSchema = ` "description": "Configure how long a token is valid. As string parsable by time.ParseDuration()", "type": "string" }, - "cookieName": { + "cookie-name": { "description": "Cookie that should be checked for a JWT token.", "type": "string" }, - "validateUser": { + "validate-user": { "description": "Deny login for users not in database (but defined in JWT). Overwrite roles in JWT with database roles.", "type": "boolean" }, - "trustedIssuer": { + "trusted-issuer": { "description": "Issuer that should be accepted when validating external JWTs ", "type": "string" }, - "syncUserOnLogin": { + "sync-user-on-login": { "description": "Add non-existent user to DB at login attempt with values provided in JWT.", "type": "boolean" + }, + "update-user-on-login": { + "description": "Should an existent user attributes in the DB be updated at login attempt with values provided in JWT.", + "type": "boolean" } }, "required": ["max-age"] }, "oidc": { - "provider": { - "description": "", - "type": "string" - }, - "syncUserOnLogin": { - "description": "", - "type": "boolean" - }, - "updateUserOnLogin": { - "description": "", - "type": "boolean" + "type": "object", + "properties": { + "provider": { + "description": "OpenID Connect provider URL.", + "type": "string" + }, + "sync-user-on-login": { + "description": "Add non-existent user to DB at login attempt with values provided.", + "type": "boolean" + }, + "update-user-on-login": { + "description": "Should an existent user attributes in the DB be updated at login attempt with values provided.", + "type": "boolean" + } }, "required": ["provider"] }, @@ -57,40 +64,40 @@ var configSchema = ` "description": "URL of LDAP directory server.", "type": "string" }, - "user_base": { + "user-base": { "description": "Base DN of user tree root.", "type": "string" }, - "search_dn": { + "search-dn": { "description": "DN for authenticating LDAP admin account with general read rights.", "type": "string" }, - "user_bind": { + "user-bind": { "description": "Expression used to authenticate users via LDAP bind. Must contain uid={username}.", "type": "string" }, - "user_filter": { + "user-filter": { "description": "Filter to extract users for syncing.", "type": "string" }, - "username_attr": { + "username-attr": { "description": "Attribute with full username. Default: gecos", "type": "string" }, - "sync_interval": { + "sync-interval": { "description": "Interval used for syncing local user table with LDAP directory. Parsed using time.ParseDuration.", "type": "string" }, - "sync_del_old_users": { + "sync-del-old-users": { "description": "Delete obsolete users in database.", "type": "boolean" }, - "syncUserOnLogin": { + "sync-user-on-login": { "description": "Add non-existent user to DB at login attempt if user exists in Ldap directory", "type": "boolean" } }, - "required": ["url", "user_base", "search_dn", "user_bind", "user_filter"] + "required": ["url", "user-base", "search-dn", "user-bind", "user-filter"] }, "required": ["jwts"] }` diff --git a/internal/config/config.go b/internal/config/config.go index b8eea2ca..4e6fe975 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -20,9 +20,9 @@ type ProgramConfig struct { Addr string `json:"addr"` // Addresses from which secured admin API endpoints can be reached, can be wildcard "*" - APIAllowedIPs []string `json:"apiAllowedIPs"` + APIAllowedIPs []string `json:"api-allowed-ips"` - APISubjects *NATSConfig `json:"apiSubjects"` + APISubjects *NATSConfig `json:"api-subjects"` // Drop root permissions once .env was read and the port was taken. User string `json:"user"` @@ -37,16 +37,9 @@ type ProgramConfig struct { EmbedStaticFiles bool `json:"embed-static-files"` StaticFiles string `json:"static-files"` - // Database driver - only 'sqlite3' is supported - DBDriver string `json:"db-driver"` - // Path to SQLite database file DB string `json:"db"` - // Keep all metric data in the metric data repositories, - // do not write to the job-archive. - DisableArchive bool `json:"disable-archive"` - EnableJobTaggers bool `json:"enable-job-taggers"` // Validate json input against schema @@ -82,7 +75,7 @@ type ProgramConfig struct { type ResampleConfig struct { // Minimum number of points to trigger resampling of data - MinimumPoints int `json:"minimumPoints"` + MinimumPoints int `json:"minimum-points"` // Array of resampling target resolutions, in seconds; Example: [600,300,60] Resolutions []int `json:"resolutions"` // Trigger next zoom level at less than this many visible datapoints @@ -90,8 +83,8 @@ type ResampleConfig struct { } type NATSConfig struct { - SubjectJobEvent string `json:"subjectJobEvent"` - SubjectNodeState string `json:"subjectNodeState"` + SubjectJobEvent string `json:"subject-job-event"` + SubjectNodeState string `json:"subject-node-state"` } type IntRange struct { @@ -107,18 +100,14 @@ type TimeRange struct { type FilterRanges struct { Duration *IntRange `json:"duration"` - NumNodes *IntRange `json:"numNodes"` - StartTime *TimeRange `json:"startTime"` + NumNodes *IntRange `json:"num-nodes"` + StartTime *TimeRange `json:"start-time"` } var Keys ProgramConfig = ProgramConfig{ Addr: "localhost:8080", - DisableAuthentication: false, EmbedStaticFiles: true, - DBDriver: "sqlite3", DB: "./var/job.db", - DisableArchive: false, - Validate: false, SessionMaxAge: "168h", StopJobsExceedingWalltime: 0, ShortRunningJobsDuration: 5 * 60, diff --git a/internal/config/default_metrics.go b/internal/config/default_metrics.go index d710aa06..d31b1f15 100644 --- a/internal/config/default_metrics.go +++ b/internal/config/default_metrics.go @@ -15,7 +15,7 @@ import ( type DefaultMetricsCluster struct { Name string `json:"name"` - DefaultMetrics string `json:"default_metrics"` + DefaultMetrics string `json:"default-metrics"` } type DefaultMetricsConfig struct { diff --git a/internal/config/schema.go b/internal/config/schema.go index 2d068140..0d575b3c 100644 --- a/internal/config/schema.go +++ b/internal/config/schema.go @@ -6,14 +6,14 @@ package config var configSchema = ` - { +{ "type": "object", "properties": { "addr": { "description": "Address where the http (or https) server will listen on (for example: 'localhost:80').", "type": "string" }, - "apiAllowedIPs": { + "api-allowed-ips": { "description": "Addresses from which secured API endpoints can be reached", "type": "array", "items": { @@ -44,10 +44,6 @@ var configSchema = ` "description": "Path to SQLite database file (e.g., './var/job.db')", "type": "string" }, - "disable-archive": { - "description": "Keep all metric data in the metric data repositories, do not write to the job-archive.", - "type": "boolean" - }, "enable-job-taggers": { "description": "Turn on automatic application and jobclass taggers", "type": "boolean" @@ -102,7 +98,7 @@ var configSchema = ` "description": "Enable dynamic zoom in frontend metric plots.", "type": "object", "properties": { - "minimumPoints": { + "minimum-points": { "description": "Minimum points to trigger resampling of time-series data.", "type": "integer" }, @@ -120,21 +116,20 @@ var configSchema = ` }, "required": ["trigger", "resolutions"] }, - "apiSubjects": { + "api-subjects": { "description": "NATS subjects configuration for subscribing to job and node events.", "type": "object", "properties": { - "subjectJobEvent": { + "subject-job-event": { "description": "NATS subject for job events (start_job, stop_job)", "type": "string" }, - "subjectNodeState": { + "subject-node-state": { "description": "NATS subject for node state updates", "type": "string" } }, - "required": ["subjectJobEvent", "subjectNodeState"] + "required": ["subject-job-event", "subject-node-state"] } - }, - "required": ["apiAllowedIPs"] - }` + } +}` diff --git a/internal/importer/importer_test.go b/internal/importer/importer_test.go index ebc500b7..f53e3a9d 100644 --- a/internal/importer/importer_test.go +++ b/internal/importer/importer_test.go @@ -50,7 +50,7 @@ func setup(t *testing.T) *repository.JobRepository { "main": { "addr": "0.0.0.0:8080", "validate": false, - "apiAllowedIPs": [ + "api-allowed-ips": [ "*" ]}, "archive": { @@ -100,11 +100,11 @@ func setup(t *testing.T) *repository.JobRepository { archiveCfg := fmt.Sprintf("{\"kind\": \"file\",\"path\": \"%s\"}", jobarchive) - if err := archive.Init(json.RawMessage(archiveCfg), config.Keys.DisableArchive); err != nil { + if err := archive.Init(json.RawMessage(archiveCfg)); err != nil { t.Fatal(err) } - repository.Connect("sqlite3", dbfilepath) + repository.Connect(dbfilepath) return repository.GetJobRepository() } diff --git a/internal/metricdispatch/dataLoader.go b/internal/metricdispatch/dataLoader.go index 8bfebbd6..91f5ecde 100644 --- a/internal/metricdispatch/dataLoader.go +++ b/internal/metricdispatch/dataLoader.go @@ -43,9 +43,8 @@ import ( "math" "time" - "github.com/ClusterCockpit/cc-backend/internal/config" - "github.com/ClusterCockpit/cc-backend/internal/metricstore" "github.com/ClusterCockpit/cc-backend/pkg/archive" + "github.com/ClusterCockpit/cc-backend/pkg/metricstore" cclog "github.com/ClusterCockpit/cc-lib/v2/ccLogger" "github.com/ClusterCockpit/cc-lib/v2/lrucache" "github.com/ClusterCockpit/cc-lib/v2/resampler" @@ -95,8 +94,7 @@ func LoadData(job *schema.Job, var err error if job.State == schema.JobStateRunning || - job.MonitoringStatus == schema.MonitoringStatusRunningOrArchiving || - config.Keys.DisableArchive { + job.MonitoringStatus == schema.MonitoringStatusRunningOrArchiving { if scopes == nil { scopes = append(scopes, schema.MetricScopeNode) @@ -234,7 +232,7 @@ func LoadAverages( data [][]schema.Float, ctx context.Context, ) error { - if job.State != schema.JobStateRunning && !config.Keys.DisableArchive { + if job.State != schema.JobStateRunning { return archive.LoadAveragesFromArchive(job, metrics, data) // #166 change also here? } @@ -271,7 +269,7 @@ func LoadScopedJobStats( scopes []schema.MetricScope, ctx context.Context, ) (schema.ScopedJobStats, error) { - if job.State != schema.JobStateRunning && !config.Keys.DisableArchive { + if job.State != schema.JobStateRunning { return archive.LoadScopedStatsFromArchive(job, metrics, scopes) } @@ -293,7 +291,7 @@ func LoadJobStats( metrics []string, ctx context.Context, ) (map[string]schema.MetricStatistics, error) { - if job.State != schema.JobStateRunning && !config.Keys.DisableArchive { + if job.State != schema.JobStateRunning { return archive.LoadStatsFromArchive(job, metrics) } diff --git a/internal/metricstore/buffer.go b/internal/metricstore/buffer.go deleted file mode 100644 index 94d3ce76..00000000 --- a/internal/metricstore/buffer.go +++ /dev/null @@ -1,190 +0,0 @@ -// Copyright (C) NHR@FAU, University Erlangen-Nuremberg. -// All rights reserved. This file is part of cc-backend. -// Use of this source code is governed by a MIT-style -// license that can be found in the LICENSE file. - -package metricstore - -import ( - "errors" - "sync" - - "github.com/ClusterCockpit/cc-lib/v2/schema" -) - -// BufferCap is the default buffer capacity. -// buffer.data will only ever grow up to its capacity and a new link -// in the buffer chain will be created if needed so that no copying -// of data or reallocation needs to happen on writes. -const BufferCap int = DefaultBufferCapacity - -var bufferPool sync.Pool = sync.Pool{ - New: func() any { - return &buffer{ - data: make([]schema.Float, 0, BufferCap), - } - }, -} - -var ( - ErrNoData error = errors.New("[METRICSTORE]> no data for this metric/level") - ErrDataDoesNotAlign error = errors.New("[METRICSTORE]> data from lower granularities does not align") -) - -// Each metric on each level has it's own buffer. -// This is where the actual values go. -// If `cap(data)` is reached, a new buffer is created and -// becomes the new head of a buffer list. -type buffer struct { - prev *buffer - next *buffer - data []schema.Float - frequency int64 - start int64 - archived bool - closed bool -} - -func newBuffer(ts, freq int64) *buffer { - b := bufferPool.Get().(*buffer) - b.frequency = freq - b.start = ts - (freq / 2) - b.prev = nil - b.next = nil - b.archived = false - b.closed = false - b.data = b.data[:0] - return b -} - -// If a new buffer was created, the new head is returnd. -// Otherwise, the existing buffer is returnd. -// Normaly, only "newer" data should be written, but if the value would -// end up in the same buffer anyways it is allowed. -func (b *buffer) write(ts int64, value schema.Float) (*buffer, error) { - if ts < b.start { - return nil, errors.New("[METRICSTORE]> cannot write value to buffer from past") - } - - // idx := int((ts - b.start + (b.frequency / 3)) / b.frequency) - idx := int((ts - b.start) / b.frequency) - if idx >= cap(b.data) { - newbuf := newBuffer(ts, b.frequency) - newbuf.prev = b - b.next = newbuf - b = newbuf - idx = 0 - } - - // Overwriting value or writing value from past - if idx < len(b.data) { - b.data[idx] = value - return b, nil - } - - // Fill up unwritten slots with NaN - for i := len(b.data); i < idx; i++ { - b.data = append(b.data, schema.NaN) - } - - b.data = append(b.data, value) - return b, nil -} - -func (b *buffer) end() int64 { - return b.firstWrite() + int64(len(b.data))*b.frequency -} - -func (b *buffer) firstWrite() int64 { - return b.start + (b.frequency / 2) -} - -// Return all known values from `from` to `to`. Gaps of information are represented as NaN. -// Simple linear interpolation is done between the two neighboring cells if possible. -// If values at the start or end are missing, instead of NaN values, the second and thrid -// return values contain the actual `from`/`to`. -// This function goes back the buffer chain if `from` is older than the currents buffer start. -// The loaded values are added to `data` and `data` is returned, possibly with a shorter length. -// If `data` is not long enough to hold all values, this function will panic! -func (b *buffer) read(from, to int64, data []schema.Float) ([]schema.Float, int64, int64, error) { - if from < b.firstWrite() { - if b.prev != nil { - return b.prev.read(from, to, data) - } - from = b.firstWrite() - } - - i := 0 - t := from - for ; t < to; t += b.frequency { - idx := int((t - b.start) / b.frequency) - if idx >= cap(b.data) { - if b.next == nil { - break - } - b = b.next - idx = 0 - } - - if idx >= len(b.data) { - if b.next == nil || to <= b.next.start { - break - } - data[i] += schema.NaN - } else if t < b.start { - data[i] += schema.NaN - } else { - data[i] += b.data[idx] - } - i++ - } - - return data[:i], from, t, nil -} - -// Returns true if this buffer needs to be freed. -func (b *buffer) free(t int64) (delme bool, n int) { - if b.prev != nil { - delme, m := b.prev.free(t) - n += m - if delme { - b.prev.next = nil - if cap(b.prev.data) == BufferCap { - bufferPool.Put(b.prev) - } - b.prev = nil - } - } - - end := b.end() - if end < t { - return true, n + 1 - } - - return false, n -} - -// Call `callback` on every buffer that contains data in the range from `from` to `to`. -func (b *buffer) iterFromTo(from, to int64, callback func(b *buffer) error) error { - if b == nil { - return nil - } - - if err := b.prev.iterFromTo(from, to, callback); err != nil { - return err - } - - if from <= b.end() && b.start <= to { - return callback(b) - } - - return nil -} - -func (b *buffer) count() int64 { - res := int64(len(b.data)) - if b.prev != nil { - res += b.prev.count() - } - return res -} diff --git a/internal/metricstore/config.go b/internal/metricstore/config.go deleted file mode 100644 index 9657453d..00000000 --- a/internal/metricstore/config.go +++ /dev/null @@ -1,128 +0,0 @@ -// Copyright (C) NHR@FAU, University Erlangen-Nuremberg. -// All rights reserved. This file is part of cc-backend. -// Use of this source code is governed by a MIT-style -// license that can be found in the LICENSE file. - -package metricstore - -import ( - "fmt" - "time" -) - -const ( - DefaultMaxWorkers = 10 - DefaultBufferCapacity = 512 - DefaultGCTriggerInterval = 100 - DefaultAvroWorkers = 4 - DefaultCheckpointBufferMin = 3 - DefaultAvroCheckpointInterval = time.Minute -) - -type Checkpoints struct { - FileFormat string `json:"file-format"` - Interval string `json:"interval"` - RootDir string `json:"directory"` -} - -type Debug struct { - DumpToFile string `json:"dump-to-file"` - EnableGops bool `json:"gops"` -} - -type Archive struct { - ArchiveInterval string `json:"interval"` - RootDir string `json:"directory"` - DeleteInstead bool `json:"delete-instead"` -} - -type Subscriptions []struct { - // Channel name - SubscribeTo string `json:"subscribe-to"` - - // Allow lines without a cluster tag, use this as default, optional - ClusterTag string `json:"cluster-tag"` -} - -type MetricStoreConfig struct { - // Number of concurrent workers for checkpoint and archive operations. - // If not set or 0, defaults to min(runtime.NumCPU()/2+1, 10) - NumWorkers int `json:"num-workers"` - RetentionInMemory string `json:"retention-in-memory"` - MemoryCap int `json:"memory-cap"` - Checkpoints Checkpoints `json:"checkpoints"` - Debug *Debug `json:"debug"` - Archive *Archive `json:"archive"` - Subscriptions *Subscriptions `json:"nats-subscriptions"` -} - -var Keys MetricStoreConfig = MetricStoreConfig{ - Checkpoints: Checkpoints{ - FileFormat: "avro", - RootDir: "./var/checkpoints", - }, -} - -// AggregationStrategy for aggregation over multiple values at different cpus/sockets/..., not time! -type AggregationStrategy int - -const ( - NoAggregation AggregationStrategy = iota - SumAggregation - AvgAggregation -) - -func AssignAggregationStrategy(str string) (AggregationStrategy, error) { - switch str { - case "": - return NoAggregation, nil - case "sum": - return SumAggregation, nil - case "avg": - return AvgAggregation, nil - default: - return NoAggregation, fmt.Errorf("[METRICSTORE]> unknown aggregation strategy: %s", str) - } -} - -type MetricConfig struct { - // Interval in seconds at which measurements are stored - Frequency int64 - - // Can be 'sum', 'avg' or null. Describes how to aggregate metrics from the same timestep over the hierarchy. - Aggregation AggregationStrategy - - // Private, used internally... - offset int -} - -var Metrics map[string]MetricConfig - -func GetMetricFrequency(metricName string) (int64, error) { - if metric, ok := Metrics[metricName]; ok { - return metric.Frequency, nil - } - return 0, fmt.Errorf("[METRICSTORE]> metric %s not found", metricName) -} - -// AddMetric adds logic to add metrics. Redundant metrics should be updated with max frequency. -// use metric.Name to check if the metric already exists. -// if not, add it to the Metrics map. -func AddMetric(name string, metric MetricConfig) error { - if Metrics == nil { - Metrics = make(map[string]MetricConfig, 0) - } - - if existingMetric, ok := Metrics[name]; ok { - if existingMetric.Frequency != metric.Frequency { - if existingMetric.Frequency < metric.Frequency { - existingMetric.Frequency = metric.Frequency - Metrics[name] = existingMetric - } - } - } else { - Metrics[name] = metric - } - - return nil -} diff --git a/internal/metricstore/level.go b/internal/metricstore/level.go deleted file mode 100644 index 87aeefc9..00000000 --- a/internal/metricstore/level.go +++ /dev/null @@ -1,215 +0,0 @@ -// Copyright (C) NHR@FAU, University Erlangen-Nuremberg. -// All rights reserved. This file is part of cc-backend. -// Use of this source code is governed by a MIT-style -// license that can be found in the LICENSE file. - -package metricstore - -import ( - "sync" - "unsafe" - - "github.com/ClusterCockpit/cc-lib/v2/util" -) - -// Could also be called "node" as this forms a node in a tree structure. -// Called Level because "node" might be confusing here. -// Can be both a leaf or a inner node. In this tree structue, inner nodes can -// also hold data (in `metrics`). -type Level struct { - children map[string]*Level - metrics []*buffer - lock sync.RWMutex -} - -// Find the correct level for the given selector, creating it if -// it does not exist. Example selector in the context of the -// ClusterCockpit could be: []string{ "emmy", "host123", "cpu0" }. -// This function would probably benefit a lot from `level.children` beeing a `sync.Map`? -func (l *Level) findLevelOrCreate(selector []string, nMetrics int) *Level { - if len(selector) == 0 { - return l - } - - // Allow concurrent reads: - l.lock.RLock() - var child *Level - var ok bool - if l.children == nil { - // Children map needs to be created... - l.lock.RUnlock() - } else { - child, ok = l.children[selector[0]] - l.lock.RUnlock() - if ok { - return child.findLevelOrCreate(selector[1:], nMetrics) - } - } - - // The level does not exist, take write lock for unique access: - l.lock.Lock() - // While this thread waited for the write lock, another thread - // could have created the child node. - if l.children != nil { - child, ok = l.children[selector[0]] - if ok { - l.lock.Unlock() - return child.findLevelOrCreate(selector[1:], nMetrics) - } - } - - child = &Level{ - metrics: make([]*buffer, nMetrics), - children: nil, - } - - if l.children != nil { - l.children[selector[0]] = child - } else { - l.children = map[string]*Level{selector[0]: child} - } - l.lock.Unlock() - return child.findLevelOrCreate(selector[1:], nMetrics) -} - -func (l *Level) collectPaths(currentDepth, targetDepth int, currentPath []string, results *[][]string) { - l.lock.RLock() - defer l.lock.RUnlock() - - for key, child := range l.children { - if child == nil { - continue - } - - // We explicitly make a new slice and copy data to avoid sharing underlying arrays between siblings - newPath := make([]string, len(currentPath)) - copy(newPath, currentPath) - newPath = append(newPath, key) - - // Check depth, and just return if depth reached - if currentDepth+1 == targetDepth { - *results = append(*results, newPath) - } else { - child.collectPaths(currentDepth+1, targetDepth, newPath, results) - } - } -} - -func (l *Level) free(t int64) (int, error) { - l.lock.Lock() - defer l.lock.Unlock() - - n := 0 - for i, b := range l.metrics { - if b != nil { - delme, m := b.free(t) - n += m - if delme { - if cap(b.data) == BufferCap { - bufferPool.Put(b) - } - l.metrics[i] = nil - } - } - } - - for _, l := range l.children { - m, err := l.free(t) - n += m - if err != nil { - return n, err - } - } - - return n, nil -} - -func (l *Level) sizeInBytes() int64 { - l.lock.RLock() - defer l.lock.RUnlock() - size := int64(0) - - for _, b := range l.metrics { - if b != nil { - size += b.count() * int64(unsafe.Sizeof(util.Float(0))) - } - } - - for _, child := range l.children { - size += child.sizeInBytes() - } - - return size -} - -func (l *Level) findLevel(selector []string) *Level { - if len(selector) == 0 { - return l - } - - l.lock.RLock() - defer l.lock.RUnlock() - - lvl := l.children[selector[0]] - if lvl == nil { - return nil - } - - return lvl.findLevel(selector[1:]) -} - -func (l *Level) findBuffers(selector util.Selector, offset int, f func(b *buffer) error) error { - l.lock.RLock() - defer l.lock.RUnlock() - - if len(selector) == 0 { - b := l.metrics[offset] - if b != nil { - return f(b) - } - - for _, lvl := range l.children { - err := lvl.findBuffers(nil, offset, f) - if err != nil { - return err - } - } - return nil - } - - sel := selector[0] - if len(sel.String) != 0 && l.children != nil { - lvl, ok := l.children[sel.String] - if ok { - err := lvl.findBuffers(selector[1:], offset, f) - if err != nil { - return err - } - } - return nil - } - - if sel.Group != nil && l.children != nil { - for _, key := range sel.Group { - lvl, ok := l.children[key] - if ok { - err := lvl.findBuffers(selector[1:], offset, f) - if err != nil { - return err - } - } - } - return nil - } - - if sel.Any && l.children != nil { - for _, lvl := range l.children { - if err := lvl.findBuffers(selector[1:], offset, f); err != nil { - return err - } - } - return nil - } - - return nil -} diff --git a/internal/repository/dbConnection.go b/internal/repository/dbConnection.go index 3141cf86..d67d3227 100644 --- a/internal/repository/dbConnection.go +++ b/internal/repository/dbConnection.go @@ -51,14 +51,10 @@ func setupSqlite(db *sql.DB) error { return nil } -func Connect(driver string, db string) { +func Connect(db string) { var err error var dbHandle *sqlx.DB - if driver != "sqlite3" { - cclog.Abortf("Unsupported database driver '%s'. Only 'sqlite3' is supported.\n", driver) - } - dbConnOnce.Do(func() { opts := DatabaseOptions{ URL: db, @@ -100,7 +96,7 @@ func Connect(driver string, db string) { dbHandle.SetConnMaxLifetime(opts.ConnectionMaxLifetime) dbHandle.SetConnMaxIdleTime(opts.ConnectionMaxIdleTime) - dbConnInstance = &DBConnection{DB: dbHandle, Driver: driver} + dbConnInstance = &DBConnection{DB: dbHandle} err = checkDBVersion(dbHandle.DB) if err != nil { cclog.Abortf("DB Connection: Failed DB version check.\nError: %s\n", err.Error()) diff --git a/internal/repository/node_test.go b/internal/repository/node_test.go index 990de924..b863dc69 100644 --- a/internal/repository/node_test.go +++ b/internal/repository/node_test.go @@ -26,7 +26,7 @@ func nodeTestSetup(t *testing.T) { "main": { "addr": "0.0.0.0:8080", "validate": false, - "apiAllowedIPs": [ + "api-allowed-ips": [ "*" ] }, @@ -139,9 +139,9 @@ func nodeTestSetup(t *testing.T) { } archiveCfg := fmt.Sprintf("{\"kind\": \"file\",\"path\": \"%s\"}", jobarchive) - Connect("sqlite3", dbfilepath) + Connect(dbfilepath) - if err := archive.Init(json.RawMessage(archiveCfg), config.Keys.DisableArchive); err != nil { + if err := archive.Init(json.RawMessage(archiveCfg)); err != nil { t.Fatal(err) } } diff --git a/internal/repository/repository_test.go b/internal/repository/repository_test.go index 475e7bca..34852830 100644 --- a/internal/repository/repository_test.go +++ b/internal/repository/repository_test.go @@ -151,7 +151,7 @@ func setup(tb testing.TB) *JobRepository { dbfile := "testdata/job.db" err := MigrateDB(dbfile) noErr(tb, err) - Connect("sqlite3", dbfile) + Connect(dbfile) return GetJobRepository() } diff --git a/internal/repository/userConfig_test.go b/internal/repository/userConfig_test.go index f66fccfb..cee59304 100644 --- a/internal/repository/userConfig_test.go +++ b/internal/repository/userConfig_test.go @@ -20,7 +20,7 @@ func setupUserTest(t *testing.T) *UserCfgRepo { const testconfig = `{ "main": { "addr": "0.0.0.0:8080", - "apiAllowedIPs": [ + "api-allowed-ips": [ "*" ] }, @@ -36,7 +36,7 @@ func setupUserTest(t *testing.T) *UserCfgRepo { if err != nil { t.Fatal(err) } - Connect("sqlite3", dbfilepath) + Connect(dbfilepath) tmpdir := t.TempDir() cfgFilePath := filepath.Join(tmpdir, "config.json") diff --git a/internal/tagger/detectApp_test.go b/internal/tagger/detectApp_test.go index fe5e7a21..93cbbdb7 100644 --- a/internal/tagger/detectApp_test.go +++ b/internal/tagger/detectApp_test.go @@ -19,7 +19,7 @@ func setup(tb testing.TB) *repository.JobRepository { dbfile := "../repository/testdata/job.db" err := repository.MigrateDB(dbfile) noErr(tb, err) - repository.Connect("sqlite3", dbfile) + repository.Connect(dbfile) return repository.GetJobRepository() } diff --git a/internal/taskmanager/updateFootprintService.go b/internal/taskmanager/updateFootprintService.go index c8f81e37..fad278e2 100644 --- a/internal/taskmanager/updateFootprintService.go +++ b/internal/taskmanager/updateFootprintService.go @@ -10,8 +10,8 @@ import ( "math" "time" - "github.com/ClusterCockpit/cc-backend/internal/metricstore" "github.com/ClusterCockpit/cc-backend/pkg/archive" + "github.com/ClusterCockpit/cc-backend/pkg/metricstore" cclog "github.com/ClusterCockpit/cc-lib/v2/ccLogger" "github.com/ClusterCockpit/cc-lib/v2/schema" sq "github.com/Masterminds/squirrel" diff --git a/pkg/archive/ConfigSchema.go b/pkg/archive/ConfigSchema.go index dfa7e1f6..aebcf37b 100644 --- a/pkg/archive/ConfigSchema.go +++ b/pkg/archive/ConfigSchema.go @@ -18,7 +18,7 @@ var configSchema = ` "description": "Path to job archive for file backend", "type": "string" }, - "dbPath": { + "db-path": { "description": "Path to SQLite database file for sqlite backend", "type": "string" }, @@ -26,11 +26,11 @@ var configSchema = ` "description": "S3 endpoint URL (for S3-compatible services like MinIO)", "type": "string" }, - "accessKey": { + "access-key": { "description": "S3 access key ID", "type": "string" }, - "secretKey": { + "secret-key": { "description": "S3 secret access key", "type": "string" }, @@ -42,7 +42,7 @@ var configSchema = ` "description": "AWS region for S3 bucket", "type": "string" }, - "usePathStyle": { + "use-path-style": { "description": "Use path-style S3 URLs (required for MinIO and some S3-compatible services)", "type": "boolean" }, @@ -59,7 +59,7 @@ var configSchema = ` "type": "string", "enum": ["none", "delete", "move"] }, - "includeDB": { + "include-db": { "description": "Also remove jobs from database", "type": "boolean" }, diff --git a/pkg/archive/archive.go b/pkg/archive/archive.go index c3032faa..f993f025 100644 --- a/pkg/archive/archive.go +++ b/pkg/archive/archive.go @@ -181,11 +181,10 @@ type JobContainer struct { } var ( - initOnce sync.Once - cache *lrucache.Cache = lrucache.New(128 * 1024 * 1024) - ar ArchiveBackend - useArchive bool - mutex sync.Mutex + initOnce sync.Once + cache *lrucache.Cache = lrucache.New(128 * 1024 * 1024) + ar ArchiveBackend + mutex sync.Mutex ) // Init initializes the archive backend with the provided configuration. @@ -197,12 +196,10 @@ var ( // // The configuration determines which backend is used (file, s3, or sqlite). // Returns an error if initialization fails or version is incompatible. -func Init(rawConfig json.RawMessage, disableArchive bool) error { +func Init(rawConfig json.RawMessage) error { var err error initOnce.Do(func() { - useArchive = !disableArchive - var cfg struct { Kind string `json:"kind"` } @@ -378,7 +375,7 @@ func UpdateMetadata(job *schema.Job, metadata map[string]string) error { mutex.Lock() defer mutex.Unlock() - if job.State == schema.JobStateRunning || !useArchive { + if job.State == schema.JobStateRunning { return nil } @@ -401,7 +398,7 @@ func UpdateTags(job *schema.Job, tags []*schema.Tag) error { mutex.Lock() defer mutex.Unlock() - if job.State == schema.JobStateRunning || !useArchive { + if job.State == schema.JobStateRunning { return nil } diff --git a/pkg/archive/archive_test.go b/pkg/archive/archive_test.go index 595315c3..277f8ac5 100644 --- a/pkg/archive/archive_test.go +++ b/pkg/archive/archive_test.go @@ -23,7 +23,7 @@ func setup(t *testing.T) archive.ArchiveBackend { util.CopyDir("./testdata/archive/", jobarchive) archiveCfg := fmt.Sprintf("{\"kind\": \"file\",\"path\": \"%s\"}", jobarchive) - if err := archive.Init(json.RawMessage(archiveCfg), false); err != nil { + if err := archive.Init(json.RawMessage(archiveCfg)); err != nil { t.Fatal(err) } diff --git a/pkg/archive/clusterConfig_test.go b/pkg/archive/clusterConfig_test.go index 36130175..510c1747 100644 --- a/pkg/archive/clusterConfig_test.go +++ b/pkg/archive/clusterConfig_test.go @@ -12,7 +12,7 @@ import ( ) func TestClusterConfig(t *testing.T) { - if err := archive.Init(json.RawMessage("{\"kind\": \"file\",\"path\": \"testdata/archive\"}"), false); err != nil { + if err := archive.Init(json.RawMessage("{\"kind\": \"file\",\"path\": \"testdata/archive\"}")); err != nil { t.Fatal(err) } diff --git a/pkg/archive/s3Backend.go b/pkg/archive/s3Backend.go index 85df5c27..84abd713 100644 --- a/pkg/archive/s3Backend.go +++ b/pkg/archive/s3Backend.go @@ -35,12 +35,12 @@ import ( // S3ArchiveConfig holds the configuration for the S3 archive backend. type S3ArchiveConfig struct { - Endpoint string `json:"endpoint"` // S3 endpoint URL (optional, for MinIO/localstack) - AccessKey string `json:"accessKey"` // AWS access key ID - SecretKey string `json:"secretKey"` // AWS secret access key - Bucket string `json:"bucket"` // S3 bucket name - Region string `json:"region"` // AWS region - UsePathStyle bool `json:"usePathStyle"` // Use path-style URLs (required for MinIO) + Endpoint string `json:"endpoint"` // S3 endpoint URL (optional, for MinIO/localstack) + AccessKey string `json:"access-key"` // AWS access key ID + SecretKey string `json:"secret-key"` // AWS secret access key + Bucket string `json:"bucket"` // S3 bucket name + Region string `json:"region"` // AWS region + UsePathStyle bool `json:"use-path-style"` // Use path-style URLs (required for MinIO) } // S3Archive implements ArchiveBackend using AWS S3 or S3-compatible object storage. diff --git a/pkg/archive/s3Backend_test.go b/pkg/archive/s3Backend_test.go index 0b4e17a2..ca56f3ed 100644 --- a/pkg/archive/s3Backend_test.go +++ b/pkg/archive/s3Backend_test.go @@ -241,11 +241,11 @@ func TestGetS3Directory(t *testing.T) { func TestS3ArchiveConfigParsing(t *testing.T) { rawConfig := json.RawMessage(`{ "endpoint": "http://localhost:9000", - "accessKey": "minioadmin", - "secretKey": "minioadmin", + "access-key": "minioadmin", + "secret-key": "minioadmin", "bucket": "test-bucket", "region": "us-east-1", - "usePathStyle": true + "use-path-style": true }`) var cfg S3ArchiveConfig diff --git a/pkg/archive/sqliteBackend.go b/pkg/archive/sqliteBackend.go index 5bce9cea..50821367 100644 --- a/pkg/archive/sqliteBackend.go +++ b/pkg/archive/sqliteBackend.go @@ -29,7 +29,7 @@ import ( // SqliteArchiveConfig holds the configuration for the SQLite archive backend. type SqliteArchiveConfig struct { - DBPath string `json:"dbPath"` // Path to SQLite database file + DBPath string `json:"db-path"` // Path to SQLite database file } // SqliteArchive implements ArchiveBackend using a SQLite database with BLOB storage. diff --git a/pkg/archive/sqliteBackend_test.go b/pkg/archive/sqliteBackend_test.go index 5d05e14e..6a35f6ca 100644 --- a/pkg/archive/sqliteBackend_test.go +++ b/pkg/archive/sqliteBackend_test.go @@ -22,7 +22,7 @@ func TestSqliteInitEmptyPath(t *testing.T) { func TestSqliteInitInvalidConfig(t *testing.T) { var sa SqliteArchive - _, err := sa.Init(json.RawMessage(`"dbPath":"/tmp/test.db"`)) + _, err := sa.Init(json.RawMessage(`"db-path":"/tmp/test.db"`)) if err == nil { t.Fatal("expected error for invalid config") } @@ -33,7 +33,7 @@ func TestSqliteInit(t *testing.T) { defer os.Remove(tmpfile) var sa SqliteArchive - version, err := sa.Init(json.RawMessage(`{"dbPath":"` + tmpfile + `"}`)) + version, err := sa.Init(json.RawMessage(`{"db-path":"` + tmpfile + `"}`)) if err != nil { t.Fatalf("init failed: %v", err) } @@ -51,7 +51,7 @@ func TestSqliteStoreAndLoadJobMeta(t *testing.T) { defer os.Remove(tmpfile) var sa SqliteArchive - _, err := sa.Init(json.RawMessage(`{"dbPath":"` + tmpfile + `"}`)) + _, err := sa.Init(json.RawMessage(`{"db-path":"` + tmpfile + `"}`)) if err != nil { t.Fatalf("init failed: %v", err) } @@ -97,7 +97,7 @@ func TestSqliteImportJob(t *testing.T) { defer os.Remove(tmpfile) var sa SqliteArchive - _, err := sa.Init(json.RawMessage(`{"dbPath":"` + tmpfile + `"}`)) + _, err := sa.Init(json.RawMessage(`{"db-path":"` + tmpfile + `"}`)) if err != nil { t.Fatalf("init failed: %v", err) } @@ -114,7 +114,7 @@ func TestSqliteGetClusters(t *testing.T) { defer os.Remove(tmpfile) var sa SqliteArchive - _, err := sa.Init(json.RawMessage(`{"dbPath":"` + tmpfile + `"}`)) + _, err := sa.Init(json.RawMessage(`{"db-path":"` + tmpfile + `"}`)) if err != nil { t.Fatalf("init failed: %v", err) } @@ -141,7 +141,7 @@ func TestSqliteGetClusters(t *testing.T) { // Reinitialize to refresh cluster list sa.db.Close() - _, err = sa.Init(json.RawMessage(`{"dbPath":"` + tmpfile + `"}`)) + _, err = sa.Init(json.RawMessage(`{"db-path":"` + tmpfile + `"}`)) if err != nil { t.Fatalf("reinit failed: %v", err) } @@ -158,7 +158,7 @@ func TestSqliteCleanUp(t *testing.T) { defer os.Remove(tmpfile) var sa SqliteArchive - _, err := sa.Init(json.RawMessage(`{"dbPath":"` + tmpfile + `"}`)) + _, err := sa.Init(json.RawMessage(`{"db-path":"` + tmpfile + `"}`)) if err != nil { t.Fatalf("init failed: %v", err) } @@ -193,7 +193,7 @@ func TestSqliteClean(t *testing.T) { defer os.Remove(tmpfile) var sa SqliteArchive - _, err := sa.Init(json.RawMessage(`{"dbPath":"` + tmpfile + `"}`)) + _, err := sa.Init(json.RawMessage(`{"db-path":"` + tmpfile + `"}`)) if err != nil { t.Fatalf("init failed: %v", err) } @@ -237,7 +237,7 @@ func TestSqliteIter(t *testing.T) { defer os.Remove(tmpfile) var sa SqliteArchive - _, err := sa.Init(json.RawMessage(`{"dbPath":"` + tmpfile + `"}`)) + _, err := sa.Init(json.RawMessage(`{"db-path":"` + tmpfile + `"}`)) if err != nil { t.Fatalf("init failed: %v", err) } @@ -276,7 +276,7 @@ func TestSqliteCompress(t *testing.T) { defer os.Remove(tmpfile) var sa SqliteArchive - _, err := sa.Init(json.RawMessage(`{"dbPath":"` + tmpfile + `"}`)) + _, err := sa.Init(json.RawMessage(`{"db-path":"` + tmpfile + `"}`)) if err != nil { t.Fatalf("init failed: %v", err) } @@ -299,7 +299,7 @@ func TestSqliteCompress(t *testing.T) { } func TestSqliteConfigParsing(t *testing.T) { - rawConfig := json.RawMessage(`{"dbPath": "/tmp/test.db"}`) + rawConfig := json.RawMessage(`{"db-path": "/tmp/test.db"}`) var cfg SqliteArchiveConfig err := json.Unmarshal(rawConfig, &cfg) @@ -317,7 +317,7 @@ func TestSqliteIterChunking(t *testing.T) { defer os.Remove(tmpfile) var sa SqliteArchive - _, err := sa.Init(json.RawMessage(`{"dbPath":"` + tmpfile + `"}`)) + _, err := sa.Init(json.RawMessage(`{"db-path":"` + tmpfile + `"}`)) if err != nil { t.Fatalf("init failed: %v", err) } diff --git a/internal/metricstore/api.go b/pkg/metricstore/api.go similarity index 58% rename from internal/metricstore/api.go rename to pkg/metricstore/api.go index d8a2ea82..a72c2ac7 100644 --- a/internal/metricstore/api.go +++ b/pkg/metricstore/api.go @@ -3,6 +3,9 @@ // Use of this source code is governed by a MIT-style // license that can be found in the LICENSE file. +// This file contains the API types and data fetching logic for querying metric data +// from the in-memory metric store. It provides structures for building complex queries +// with support for aggregation, scaling, padding, and statistics computation. package metricstore import ( @@ -15,10 +18,17 @@ import ( ) var ( + // ErrInvalidTimeRange is returned when a query has 'from' >= 'to' ErrInvalidTimeRange = errors.New("[METRICSTORE]> invalid time range: 'from' must be before 'to'") - ErrEmptyCluster = errors.New("[METRICSTORE]> cluster name cannot be empty") + // ErrEmptyCluster is returned when a query with ForAllNodes has no cluster specified + ErrEmptyCluster = errors.New("[METRICSTORE]> cluster name cannot be empty") ) +// APIMetricData represents the response data for a single metric query. +// +// It contains both the time-series data points and computed statistics (avg, min, max). +// If an error occurred during data retrieval, the Error field will be set and other +// fields may be incomplete. type APIMetricData struct { Error *string `json:"error,omitempty"` Data schema.FloatArray `json:"data,omitempty"` @@ -30,6 +40,13 @@ type APIMetricData struct { Max schema.Float `json:"max"` } +// APIQueryRequest represents a batch query request for metric data. +// +// It supports two modes of operation: +// 1. Explicit queries via the Queries field +// 2. Automatic query generation via ForAllNodes (queries all specified metrics for all nodes in the cluster) +// +// The request can be customized with flags to include/exclude statistics, raw data, and padding. type APIQueryRequest struct { Cluster string `json:"cluster"` Queries []APIQuery `json:"queries"` @@ -41,11 +58,25 @@ type APIQueryRequest struct { WithPadding bool `json:"with-padding"` } +// APIQueryResponse represents the response to an APIQueryRequest. +// +// Results is a 2D array where each outer element corresponds to a query, +// and each inner element corresponds to a selector within that query +// (e.g., multiple CPUs or cores). type APIQueryResponse struct { Queries []APIQuery `json:"queries,omitempty"` Results [][]APIMetricData `json:"results"` } +// APIQuery represents a single metric query with optional hierarchical selectors. +// +// The hierarchical selection works as follows: +// - Hostname: The node to query +// - Type + TypeIds: First level of hierarchy (e.g., "cpu" + ["0", "1", "2"]) +// - SubType + SubTypeIds: Second level of hierarchy (e.g., "core" + ["0", "1"]) +// +// If Aggregate is true, data from multiple type/subtype IDs will be aggregated according +// to the metric's aggregation strategy. Otherwise, separate results are returned for each combination. type APIQuery struct { Type *string `json:"type,omitempty"` SubType *string `json:"subtype,omitempty"` @@ -58,6 +89,11 @@ type APIQuery struct { Aggregate bool `json:"aggreg"` } +// AddStats computes and populates the Avg, Min, and Max fields from the Data array. +// +// NaN values in the data are ignored during computation. If all values are NaN, +// the statistics fields will be set to NaN. +// // TODO: Optimize this, just like the stats endpoint! func (data *APIMetricData) AddStats() { n := 0 @@ -83,6 +119,10 @@ func (data *APIMetricData) AddStats() { } } +// ScaleBy multiplies all data points and statistics by the given factor. +// +// This is commonly used for unit conversion (e.g., bytes to gigabytes). +// Scaling by 0 or 1 is a no-op for performance reasons. func (data *APIMetricData) ScaleBy(f schema.Float) { if f == 0 || f == 1 { return @@ -96,6 +136,17 @@ func (data *APIMetricData) ScaleBy(f schema.Float) { } } +// PadDataWithNull pads the beginning of the data array with NaN values if needed. +// +// This ensures that the data aligns with the requested 'from' timestamp, even if +// the metric store doesn't have data for the earliest time points. This is useful +// for maintaining consistent array indexing across multiple queries. +// +// Parameters: +// - ms: MemoryStore instance to lookup metric configuration +// - from: The requested start timestamp +// - to: The requested end timestamp (unused but kept for API consistency) +// - metric: The metric name to lookup frequency information func (data *APIMetricData) PadDataWithNull(ms *MemoryStore, from, to int64, metric string) { minfo, ok := ms.Metrics[metric] if !ok { @@ -115,6 +166,31 @@ func (data *APIMetricData) PadDataWithNull(ms *MemoryStore, from, to int64, metr } } +// FetchData executes a batch metric query request and returns the results. +// +// This is the primary API for retrieving metric data from the memory store. It supports: +// - Individual queries via req.Queries +// - Batch queries for all nodes via req.ForAllNodes +// - Hierarchical selector construction (cluster → host → type → subtype) +// - Optional statistics computation (avg, min, max) +// - Optional data scaling +// - Optional data padding with NaN values +// +// The function constructs selectors based on the query parameters and calls MemoryStore.Read() +// for each selector. If a query specifies Aggregate=false with multiple type/subtype IDs, +// separate results are returned for each combination. +// +// Parameters: +// - req: The query request containing queries, time range, and options +// +// Returns: +// - APIQueryResponse containing results for each query, or error if validation fails +// +// Errors: +// - ErrInvalidTimeRange if req.From > req.To +// - ErrEmptyCluster if req.ForAllNodes is used without specifying a cluster +// - Error if MemoryStore is not initialized +// - Individual query errors are stored in APIMetricData.Error field func FetchData(req APIQueryRequest) (*APIQueryResponse, error) { if req.From > req.To { return nil, ErrInvalidTimeRange @@ -126,7 +202,7 @@ func FetchData(req APIQueryRequest) (*APIQueryResponse, error) { req.WithData = true ms := GetMemoryStore() if ms == nil { - return nil, fmt.Errorf("memorystore not initialized") + return nil, fmt.Errorf("[METRICSTORE]> memorystore not initialized") } response := APIQueryResponse{ @@ -195,8 +271,6 @@ func FetchData(req APIQueryRequest) (*APIQueryResponse, error) { } } - // log.Printf("query: %#v\n", query) - // log.Printf("sels: %#v\n", sels) var err error res := make([]APIMetricData, 0, len(sels)) for _, sel := range sels { diff --git a/internal/metricstore/archive.go b/pkg/metricstore/archive.go similarity index 65% rename from internal/metricstore/archive.go rename to pkg/metricstore/archive.go index 78efadfe..6abcb183 100644 --- a/internal/metricstore/archive.go +++ b/pkg/metricstore/archive.go @@ -21,12 +21,40 @@ import ( cclog "github.com/ClusterCockpit/cc-lib/v2/ccLogger" ) -func Archiving(wg *sync.WaitGroup, ctx context.Context) { +// Worker for either Archiving or Deleting files + +func CleanUp(wg *sync.WaitGroup, ctx context.Context) { + if Keys.Cleanup.Mode == "archive" { + // Run as Archiver + cleanUpWorker(wg, ctx, + Keys.Cleanup.Interval, + "archiving", + Keys.Cleanup.RootDir, + false, + ) + } else { + if Keys.Cleanup.Interval == "" { + Keys.Cleanup.Interval = Keys.RetentionInMemory + } + + // Run as Deleter + cleanUpWorker(wg, ctx, + Keys.Cleanup.Interval, + "deleting", + "", + true, + ) + } +} + +// runWorker takes simple values to configure what it does +func cleanUpWorker(wg *sync.WaitGroup, ctx context.Context, interval string, mode string, cleanupDir string, delete bool) { go func() { defer wg.Done() - d, err := time.ParseDuration(Keys.Archive.ArchiveInterval) + + d, err := time.ParseDuration(interval) if err != nil { - cclog.Fatalf("[METRICSTORE]> error parsing archive interval duration: %v\n", err) + cclog.Fatalf("[METRICSTORE]> error parsing %s interval duration: %v\n", mode, err) } if d <= 0 { return @@ -41,14 +69,18 @@ func Archiving(wg *sync.WaitGroup, ctx context.Context) { return case <-ticker.C: t := time.Now().Add(-d) - cclog.Infof("[METRICSTORE]> start archiving checkpoints (older than %s)...", t.Format(time.RFC3339)) - n, err := ArchiveCheckpoints(Keys.Checkpoints.RootDir, - Keys.Archive.RootDir, t.Unix(), Keys.Archive.DeleteInstead) + cclog.Infof("[METRICSTORE]> start %s checkpoints (older than %s)...", mode, t.Format(time.RFC3339)) + + n, err := CleanupCheckpoints(Keys.Checkpoints.RootDir, cleanupDir, t.Unix(), delete) if err != nil { - cclog.Errorf("[METRICSTORE]> archiving failed: %s", err.Error()) + cclog.Errorf("[METRICSTORE]> %s failed: %s", mode, err.Error()) } else { - cclog.Infof("[METRICSTORE]> done: %d files zipped and moved to archive", n) + if delete && cleanupDir == "" { + cclog.Infof("[METRICSTORE]> done: %d checkpoints deleted", n) + } else { + cclog.Infof("[METRICSTORE]> done: %d files zipped and moved to archive", n) + } } } } @@ -57,9 +89,9 @@ func Archiving(wg *sync.WaitGroup, ctx context.Context) { var ErrNoNewArchiveData error = errors.New("all data already archived") -// ZIP all checkpoint files older than `from` together and write them to the `archiveDir`, -// deleting them from the `checkpointsDir`. -func ArchiveCheckpoints(checkpointsDir, archiveDir string, from int64, deleteInstead bool) (int, error) { +// Delete or ZIP all checkpoint files older than `from` together and write them to the `cleanupDir`, +// deleting/moving them from the `checkpointsDir`. +func CleanupCheckpoints(checkpointsDir, cleanupDir string, from int64, deleteInstead bool) (int, error) { entries1, err := os.ReadDir(checkpointsDir) if err != nil { return 0, err @@ -79,7 +111,7 @@ func ArchiveCheckpoints(checkpointsDir, archiveDir string, from int64, deleteIns go func() { defer wg.Done() for workItem := range work { - m, err := archiveCheckpoints(workItem.cdir, workItem.adir, from, deleteInstead) + m, err := cleanupCheckpoints(workItem.cdir, workItem.adir, from, deleteInstead) if err != nil { cclog.Errorf("error while archiving %s/%s: %s", workItem.cluster, workItem.host, err.Error()) atomic.AddInt32(&errs, 1) @@ -97,7 +129,7 @@ func ArchiveCheckpoints(checkpointsDir, archiveDir string, from int64, deleteIns for _, de2 := range entries2 { cdir := filepath.Join(checkpointsDir, de1.Name(), de2.Name()) - adir := filepath.Join(archiveDir, de1.Name(), de2.Name()) + adir := filepath.Join(cleanupDir, de1.Name(), de2.Name()) work <- workItem{ adir: adir, cdir: cdir, cluster: de1.Name(), host: de2.Name(), @@ -118,8 +150,8 @@ func ArchiveCheckpoints(checkpointsDir, archiveDir string, from int64, deleteIns return int(n), nil } -// Helper function for `ArchiveCheckpoints`. -func archiveCheckpoints(dir string, archiveDir string, from int64, deleteInstead bool) (int, error) { +// Helper function for `CleanupCheckpoints`. +func cleanupCheckpoints(dir string, cleanupDir string, from int64, deleteInstead bool) (int, error) { entries, err := os.ReadDir(dir) if err != nil { return 0, err @@ -143,10 +175,10 @@ func archiveCheckpoints(dir string, archiveDir string, from int64, deleteInstead return n, nil } - filename := filepath.Join(archiveDir, fmt.Sprintf("%d.zip", from)) + filename := filepath.Join(cleanupDir, fmt.Sprintf("%d.zip", from)) f, err := os.OpenFile(filename, os.O_CREATE|os.O_WRONLY, CheckpointFilePerms) if err != nil && os.IsNotExist(err) { - err = os.MkdirAll(archiveDir, CheckpointDirPerms) + err = os.MkdirAll(cleanupDir, CheckpointDirPerms) if err == nil { f, err = os.OpenFile(filename, os.O_CREATE|os.O_WRONLY, CheckpointFilePerms) } diff --git a/internal/metricstore/avroCheckpoint.go b/pkg/metricstore/avroCheckpoint.go similarity index 100% rename from internal/metricstore/avroCheckpoint.go rename to pkg/metricstore/avroCheckpoint.go diff --git a/internal/metricstore/avroHelper.go b/pkg/metricstore/avroHelper.go similarity index 100% rename from internal/metricstore/avroHelper.go rename to pkg/metricstore/avroHelper.go diff --git a/internal/metricstore/avroStruct.go b/pkg/metricstore/avroStruct.go similarity index 100% rename from internal/metricstore/avroStruct.go rename to pkg/metricstore/avroStruct.go diff --git a/pkg/metricstore/buffer.go b/pkg/metricstore/buffer.go new file mode 100644 index 00000000..46eb5149 --- /dev/null +++ b/pkg/metricstore/buffer.go @@ -0,0 +1,318 @@ +// Copyright (C) NHR@FAU, University Erlangen-Nuremberg. +// All rights reserved. This file is part of cc-backend. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +// Package metricstore provides buffer.go: Time-series data buffer implementation. +// +// # Buffer Architecture +// +// Each metric at each hierarchical level (cluster/host/cpu/etc.) uses a linked-list +// chain of fixed-size buffers to store time-series data. This design: +// +// - Avoids reallocation/copying when growing (new links added instead) +// - Enables efficient pooling (buffers returned to sync.Pool) +// - Supports traversal back in time (via prev pointers) +// - Maintains temporal ordering (newer data in later buffers) +// +// # Buffer Chain Example +// +// [oldest buffer] <- prev -- [older] <- prev -- [newest buffer (head)] +// start=1000 start=1512 start=2024 +// data=[v0...v511] data=[v0...v511] data=[v0...v42] +// +// When the head buffer reaches capacity (BufferCap = 512), a new buffer becomes +// the new head and the old head is linked via prev. +// +// # Pooling Strategy +// +// sync.Pool reduces GC pressure for the common case (BufferCap-sized allocations). +// Non-standard capacity buffers are not pooled (e.g., from checkpoint deserialization). +// +// # Time Alignment +// +// Timestamps are aligned to measurement frequency intervals: +// +// index = (timestamp - buffer.start) / buffer.frequency +// actualTime = buffer.start + (frequency / 2) + (index * frequency) +// +// Missing data points are represented as NaN values. The read() function performs +// linear interpolation where possible. +package metricstore + +import ( + "errors" + "sync" + + "github.com/ClusterCockpit/cc-lib/v2/schema" +) + +// BufferCap is the default buffer capacity. +// buffer.data will only ever grow up to its capacity and a new link +// in the buffer chain will be created if needed so that no copying +// of data or reallocation needs to happen on writes. +const BufferCap int = DefaultBufferCapacity + +var bufferPool sync.Pool = sync.Pool{ + New: func() any { + return &buffer{ + data: make([]schema.Float, 0, BufferCap), + } + }, +} + +var ( + // ErrNoData indicates no time-series data exists for the requested metric/level. + ErrNoData error = errors.New("[METRICSTORE]> no data for this metric/level") + + // ErrDataDoesNotAlign indicates that aggregated data from child scopes + // does not align with the parent scope's expected timestamps/intervals. + ErrDataDoesNotAlign error = errors.New("[METRICSTORE]> data from lower granularities does not align") +) + +// buffer stores time-series data for a single metric at a specific hierarchical level. +// +// Buffers form doubly-linked chains ordered by time. When capacity is reached, +// a new buffer becomes the head and the old head is linked via prev/next. +// +// Fields: +// - prev: Link to older buffer in the chain (nil if this is oldest) +// - next: Link to newer buffer in the chain (nil if this is newest/head) +// - data: Time-series values (schema.Float supports NaN for missing data) +// - frequency: Measurement interval in seconds +// - start: Start timestamp (adjusted by -frequency/2 for alignment) +// - archived: True if data has been persisted to disk archive +// - closed: True if buffer is no longer accepting writes +// +// Index calculation: index = (timestamp - start) / frequency +// Actual data timestamp: start + (frequency / 2) + (index * frequency) +type buffer struct { + prev *buffer + next *buffer + data []schema.Float + frequency int64 + start int64 + archived bool + closed bool +} + +func newBuffer(ts, freq int64) *buffer { + b := bufferPool.Get().(*buffer) + b.frequency = freq + b.start = ts - (freq / 2) + b.prev = nil + b.next = nil + b.archived = false + b.closed = false + b.data = b.data[:0] + return b +} + +// write appends a timestamped value to the buffer chain. +// +// Returns the head buffer (which may be newly created if capacity was reached). +// Timestamps older than the buffer's start are rejected. If the calculated index +// exceeds capacity, a new buffer is allocated and linked as the new head. +// +// Missing timestamps are automatically filled with NaN values to maintain alignment. +// Overwrites are allowed if the index is already within the existing data slice. +// +// Parameters: +// - ts: Unix timestamp in seconds +// - value: Metric value (can be schema.NaN for missing data) +// +// Returns: +// - *buffer: The new head buffer (same as b if no new buffer created) +// - error: Non-nil if timestamp is before buffer start +func (b *buffer) write(ts int64, value schema.Float) (*buffer, error) { + if ts < b.start { + return nil, errors.New("[METRICSTORE]> cannot write value to buffer from past") + } + + // idx := int((ts - b.start + (b.frequency / 3)) / b.frequency) + idx := int((ts - b.start) / b.frequency) + if idx >= cap(b.data) { + newbuf := newBuffer(ts, b.frequency) + newbuf.prev = b + b.next = newbuf + b = newbuf + idx = 0 + } + + // Overwriting value or writing value from past + if idx < len(b.data) { + b.data[idx] = value + return b, nil + } + + // Fill up unwritten slots with NaN + for i := len(b.data); i < idx; i++ { + b.data = append(b.data, schema.NaN) + } + + b.data = append(b.data, value) + return b, nil +} + +func (b *buffer) end() int64 { + return b.firstWrite() + int64(len(b.data))*b.frequency +} + +func (b *buffer) firstWrite() int64 { + return b.start + (b.frequency / 2) +} + +// read retrieves time-series data from the buffer chain for the specified time range. +// +// Traverses the buffer chain backwards (via prev links) if 'from' precedes the current +// buffer's start. Missing data points are represented as NaN. Values are accumulated +// into the provided 'data' slice (using +=, so caller must zero-initialize if needed). +// +// The function adjusts the actual time range returned if data is unavailable at the +// boundaries (returned via adjusted from/to timestamps). +// +// Parameters: +// - from: Start timestamp (Unix seconds) +// - to: End timestamp (Unix seconds, exclusive) +// - data: Pre-allocated slice to accumulate results (must be large enough) +// +// Returns: +// - []schema.Float: Slice of data (may be shorter than input 'data' slice) +// - int64: Actual start timestamp with available data +// - int64: Actual end timestamp (exclusive) +// - error: Non-nil on failure +// +// Panics if 'data' slice is too small to hold all values in [from, to). +func (b *buffer) read(from, to int64, data []schema.Float) ([]schema.Float, int64, int64, error) { + if from < b.firstWrite() { + if b.prev != nil { + return b.prev.read(from, to, data) + } + from = b.firstWrite() + } + + i := 0 + t := from + for ; t < to; t += b.frequency { + idx := int((t - b.start) / b.frequency) + if idx >= cap(b.data) { + if b.next == nil { + break + } + b = b.next + idx = 0 + } + + if idx >= len(b.data) { + if b.next == nil || to <= b.next.start { + break + } + data[i] += schema.NaN + } else if t < b.start { + data[i] += schema.NaN + } else { + data[i] += b.data[idx] + } + i++ + } + + return data[:i], from, t, nil +} + +// free removes buffers older than the specified timestamp from the chain. +// +// Recursively traverses backwards (via prev) and unlinks buffers whose end time +// is before the retention threshold. Freed buffers are returned to the pool if +// they have the standard capacity (BufferCap). +// +// Parameters: +// - t: Retention threshold timestamp (Unix seconds) +// +// Returns: +// - delme: True if the current buffer itself should be deleted by caller +// - n: Number of buffers freed in this subtree +func (b *buffer) free(t int64) (delme bool, n int) { + if b.prev != nil { + delme, m := b.prev.free(t) + n += m + if delme { + b.prev.next = nil + if cap(b.prev.data) == BufferCap { + bufferPool.Put(b.prev) + } + b.prev = nil + } + } + + end := b.end() + if end < t { + return true, n + 1 + } + + return false, n +} + +// forceFreeOldest recursively finds the end of the linked list (the oldest buffer) +// and removes it. +// Returns: +// +// delme: true if 'b' itself is the oldest and should be removed by the caller +// n: the number of buffers freed (will be 1 or 0) +func (b *buffer) forceFreeOldest() (delme bool, n int) { + // If there is a previous buffer, recurse down to find the oldest + if b.prev != nil { + delPrev, freed := b.prev.forceFreeOldest() + + // If the previous buffer signals it should be deleted: + if delPrev { + // Clear links on the dying buffer to prevent leaks + b.prev.next = nil + b.prev.data = nil // Release the underlying float slice immediately + + // Remove the link from the current buffer + b.prev = nil + } + return false, freed + } + + // If b.prev is nil, THIS buffer is the oldest. + // We return true so the parent (or the Level loop) knows to delete reference to 'b'. + return true, 1 +} + +// iterFromTo invokes callback on every buffer in the chain that overlaps [from, to]. +// +// Traverses backwards (via prev) first, then processes current buffer if it overlaps +// the time range. Used for checkpoint/archive operations that need to serialize buffers +// within a specific time window. +// +// Parameters: +// - from: Start timestamp (Unix seconds, inclusive) +// - to: End timestamp (Unix seconds, inclusive) +// - callback: Function to invoke on each overlapping buffer +// +// Returns: +// - error: First error returned by callback, or nil if all succeeded +func (b *buffer) iterFromTo(from, to int64, callback func(b *buffer) error) error { + if b == nil { + return nil + } + + if err := b.prev.iterFromTo(from, to, callback); err != nil { + return err + } + + if from <= b.end() && b.start <= to { + return callback(b) + } + + return nil +} + +func (b *buffer) count() int64 { + res := int64(len(b.data)) + if b.prev != nil { + res += b.prev.count() + } + return res +} diff --git a/internal/metricstore/checkpoint.go b/pkg/metricstore/checkpoint.go similarity index 73% rename from internal/metricstore/checkpoint.go rename to pkg/metricstore/checkpoint.go index b5511221..b90b1c22 100644 --- a/internal/metricstore/checkpoint.go +++ b/pkg/metricstore/checkpoint.go @@ -3,6 +3,34 @@ // Use of this source code is governed by a MIT-style // license that can be found in the LICENSE file. +// This file implements checkpoint persistence for the in-memory metric store. +// +// Checkpoints enable graceful restarts by periodically saving in-memory metric +// data to disk in either JSON or Avro format. The checkpoint system: +// +// Key Features: +// - Periodic background checkpointing via the Checkpointing() worker +// - Two formats: JSON (human-readable) and Avro (compact, efficient) +// - Parallel checkpoint creation and loading using worker pools +// - Hierarchical file organization: checkpoint_dir/cluster/host/timestamp.{json|avro} +// - Only saves unarchived data (archived data is already persisted elsewhere) +// - Automatic format detection and fallback during loading +// - GC optimization during loading to prevent excessive heap growth +// +// Checkpoint Workflow: +// 1. Init() loads checkpoints within retention window at startup +// 2. Checkpointing() worker periodically saves new data +// 3. Shutdown() writes final checkpoint before exit +// +// File Organization: +// +// checkpoints/ +// cluster1/ +// host001/ +// 1234567890.json (timestamp = checkpoint start time) +// 1234567950.json +// host002/ +// ... package metricstore import ( @@ -29,18 +57,21 @@ import ( ) const ( - CheckpointFilePerms = 0o644 - CheckpointDirPerms = 0o755 - GCTriggerInterval = DefaultGCTriggerInterval + CheckpointFilePerms = 0o644 // File permissions for checkpoint files + CheckpointDirPerms = 0o755 // Directory permissions for checkpoint directories + GCTriggerInterval = DefaultGCTriggerInterval // Interval for triggering GC during checkpoint loading ) -// Whenever changed, update MarshalJSON as well! +// CheckpointMetrics represents metric data in a checkpoint file. +// Whenever the structure changes, update MarshalJSON as well! type CheckpointMetrics struct { Data []schema.Float `json:"data"` Frequency int64 `json:"frequency"` Start int64 `json:"start"` } +// CheckpointFile represents the hierarchical structure of a checkpoint file. +// It mirrors the Level tree structure from the MemoryStore. type CheckpointFile struct { Metrics map[string]*CheckpointMetrics `json:"metrics"` Children map[string]*CheckpointFile `json:"children"` @@ -48,10 +79,23 @@ type CheckpointFile struct { To int64 `json:"to"` } -var lastCheckpoint time.Time +// lastCheckpoint tracks the timestamp of the last checkpoint creation. +var ( + lastCheckpoint time.Time + lastCheckpointMu sync.Mutex +) +// Checkpointing starts a background worker that periodically saves metric data to disk. +// +// The behavior depends on the configured file format: +// - JSON: Periodic checkpointing based on Keys.Checkpoints.Interval +// - Avro: Initial delay + periodic checkpointing at DefaultAvroCheckpointInterval +// +// The worker respects context cancellation and signals completion via the WaitGroup. func Checkpointing(wg *sync.WaitGroup, ctx context.Context) { + lastCheckpointMu.Lock() lastCheckpoint = time.Now() + lastCheckpointMu.Unlock() if Keys.Checkpoints.FileFormat == "json" { ms := GetMemoryStore() @@ -60,9 +104,10 @@ func Checkpointing(wg *sync.WaitGroup, ctx context.Context) { defer wg.Done() d, err := time.ParseDuration(Keys.Checkpoints.Interval) if err != nil { - cclog.Fatal(err) + cclog.Fatalf("[METRICSTORE]> invalid checkpoint interval '%s': %s", Keys.Checkpoints.Interval, err.Error()) } if d <= 0 { + cclog.Warnf("[METRICSTORE]> checkpoint interval is zero or negative (%s), checkpointing disabled", d) return } @@ -74,15 +119,21 @@ func Checkpointing(wg *sync.WaitGroup, ctx context.Context) { case <-ctx.Done(): return case <-ticker.C: - cclog.Infof("[METRICSTORE]> start checkpointing (starting at %s)...", lastCheckpoint.Format(time.RFC3339)) + lastCheckpointMu.Lock() + from := lastCheckpoint + lastCheckpointMu.Unlock() + + cclog.Infof("[METRICSTORE]> start checkpointing (starting at %s)...", from.Format(time.RFC3339)) now := time.Now() n, err := ms.ToCheckpoint(Keys.Checkpoints.RootDir, - lastCheckpoint.Unix(), now.Unix()) + from.Unix(), now.Unix()) if err != nil { cclog.Errorf("[METRICSTORE]> checkpointing failed: %s", err.Error()) } else { cclog.Infof("[METRICSTORE]> done: %d checkpoint files created", n) + lastCheckpointMu.Lock() lastCheckpoint = now + lastCheckpointMu.Unlock() } } } @@ -113,9 +164,10 @@ func Checkpointing(wg *sync.WaitGroup, ctx context.Context) { } } -// As `Float` implements a custom MarshalJSON() function, -// serializing an array of such types has more overhead -// than one would assume (because of extra allocations, interfaces and so on). +// MarshalJSON provides optimized JSON encoding for CheckpointMetrics. +// +// Since schema.Float has custom MarshalJSON, serializing []Float has significant overhead. +// This method manually constructs JSON to avoid allocations and interface conversions. func (cm *CheckpointMetrics) MarshalJSON() ([]byte, error) { buf := make([]byte, 0, 128+len(cm.Data)*8) buf = append(buf, `{"frequency":`...) @@ -137,13 +189,27 @@ func (cm *CheckpointMetrics) MarshalJSON() ([]byte, error) { return buf, nil } -// Metrics stored at the lowest 2 levels are not stored away (root and cluster)! -// On a per-host basis a new JSON file is created. I have no idea if this will scale. -// The good thing: Only a host at a time is locked, so this function can run -// in parallel to writes/reads. +// ToCheckpoint writes metric data to checkpoint files in parallel. +// +// Metrics at root and cluster levels are skipped. One file per host is created. +// Uses worker pool (Keys.NumWorkers) for parallel processing. Only locks one host +// at a time, allowing concurrent writes/reads to other hosts. +// +// Returns the number of checkpoint files created and any errors encountered. func (m *MemoryStore) ToCheckpoint(dir string, from, to int64) (int, error) { - levels := make([]*Level, 0) - selectors := make([][]string, 0) + // Pre-calculate capacity by counting cluster/host pairs + m.root.lock.RLock() + totalHosts := 0 + for _, l1 := range m.root.children { + l1.lock.RLock() + totalHosts += len(l1.children) + l1.lock.RUnlock() + } + m.root.lock.RUnlock() + + levels := make([]*Level, 0, totalHosts) + selectors := make([][]string, 0, totalHosts) + m.root.lock.RLock() for sel1, l1 := range m.root.children { l1.lock.RLock() @@ -203,6 +269,8 @@ func (m *MemoryStore) ToCheckpoint(dir string, from, to int64) (int, error) { return int(n), nil } +// toCheckpointFile recursively converts a Level tree to CheckpointFile structure. +// Skips metrics that are already archived. Returns nil if no unarchived data exists. func (l *Level) toCheckpointFile(from, to int64, m *MemoryStore) (*CheckpointFile, error) { l.lock.RLock() defer l.lock.RUnlock() @@ -224,6 +292,7 @@ func (l *Level) toCheckpointFile(from, to int64, m *MemoryStore) (*CheckpointFil b.iterFromTo(from, to, func(b *buffer) error { if !b.archived { allArchived = false + return fmt.Errorf("stop") // Early termination signal } return nil }) @@ -267,6 +336,8 @@ func (l *Level) toCheckpointFile(from, to int64, m *MemoryStore) (*CheckpointFil return retval, nil } +// toCheckpoint writes a Level's data to a JSON checkpoint file. +// Creates directory if needed. Returns ErrNoNewArchiveData if nothing to save. func (l *Level) toCheckpoint(dir string, from, to int64, m *MemoryStore) error { cf, err := l.toCheckpointFile(from, to, m) if err != nil { @@ -278,11 +349,11 @@ func (l *Level) toCheckpoint(dir string, from, to int64, m *MemoryStore) error { } filepath := path.Join(dir, fmt.Sprintf("%d.json", from)) - f, err := os.OpenFile(filepath, os.O_CREATE|os.O_WRONLY, CheckpointFilePerms) + f, err := os.OpenFile(filepath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, CheckpointFilePerms) if err != nil && os.IsNotExist(err) { err = os.MkdirAll(dir, CheckpointDirPerms) if err == nil { - f, err = os.OpenFile(filepath, os.O_CREATE|os.O_WRONLY, CheckpointFilePerms) + f, err = os.OpenFile(filepath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, CheckpointFilePerms) } } if err != nil { @@ -298,9 +369,54 @@ func (l *Level) toCheckpoint(dir string, from, to int64, m *MemoryStore) error { return bw.Flush() } +// enqueueCheckpointHosts traverses checkpoint directory and enqueues cluster/host pairs. +// Returns error if directory structure is invalid. +func enqueueCheckpointHosts(dir string, work chan<- [2]string) error { + clustersDir, err := os.ReadDir(dir) + if err != nil { + return err + } + + gcCounter := 0 + for _, clusterDir := range clustersDir { + if !clusterDir.IsDir() { + return errors.New("[METRICSTORE]> expected only directories at first level of checkpoints/ directory") + } + + hostsDir, err := os.ReadDir(filepath.Join(dir, clusterDir.Name())) + if err != nil { + return err + } + + for _, hostDir := range hostsDir { + if !hostDir.IsDir() { + return errors.New("[METRICSTORE]> expected only directories at second level of checkpoints/ directory") + } + + gcCounter++ + if gcCounter%GCTriggerInterval == 0 { + // Forcing garbage collection runs here regulary during the loading of checkpoints + // will decrease the total heap size after loading everything back to memory is done. + // While loading data, the heap will grow fast, so the GC target size will double + // almost always. By forcing GCs here, we can keep it growing more slowly so that + // at the end, less memory is wasted. + runtime.GC() + } + + work <- [2]string{clusterDir.Name(), hostDir.Name()} + } + } + + return nil +} + +// FromCheckpoint loads checkpoint files from disk into memory in parallel. +// +// Uses worker pool to load cluster/host combinations. Periodically triggers GC +// to prevent excessive heap growth. Returns number of files loaded and any errors. func (m *MemoryStore) FromCheckpoint(dir string, from int64, extension string) (int, error) { var wg sync.WaitGroup - work := make(chan [2]string, Keys.NumWorkers) + work := make(chan [2]string, Keys.NumWorkers*4) n, errs := int32(0), int32(0) wg.Add(Keys.NumWorkers) @@ -319,40 +435,7 @@ func (m *MemoryStore) FromCheckpoint(dir string, from int64, extension string) ( }() } - i := 0 - clustersDir, err := os.ReadDir(dir) - for _, clusterDir := range clustersDir { - if !clusterDir.IsDir() { - err = errors.New("[METRICSTORE]> expected only directories at first level of checkpoints/ directory") - goto done - } - - hostsDir, e := os.ReadDir(filepath.Join(dir, clusterDir.Name())) - if e != nil { - err = e - goto done - } - - for _, hostDir := range hostsDir { - if !hostDir.IsDir() { - err = errors.New("[METRICSTORE]> expected only directories at second level of checkpoints/ directory") - goto done - } - - i++ - if i%Keys.NumWorkers == 0 && i > GCTriggerInterval { - // Forcing garbage collection runs here regulary during the loading of checkpoints - // will decrease the total heap size after loading everything back to memory is done. - // While loading data, the heap will grow fast, so the GC target size will double - // almost always. By forcing GCs here, we can keep it growing more slowly so that - // at the end, less memory is wasted. - runtime.GC() - } - - work <- [2]string{clusterDir.Name(), hostDir.Name()} - } - } -done: + err := enqueueCheckpointHosts(dir, work) close(work) wg.Wait() @@ -366,9 +449,11 @@ done: return int(n), nil } -// Metrics stored at the lowest 2 levels are not loaded (root and cluster)! -// This function can only be called once and before the very first write or read. -// Different host's data is loaded to memory in parallel. +// FromCheckpointFiles is the main entry point for loading checkpoints at startup. +// +// Automatically detects checkpoint format (JSON vs Avro) and falls back if needed. +// Creates checkpoint directory if it doesn't exist. This function must be called +// before any writes or reads, and can only be called once. func (m *MemoryStore) FromCheckpointFiles(dir string, from int64) (int, error) { if _, err := os.Stat(dir); os.IsNotExist(err) { // The directory does not exist, so create it using os.MkdirAll() @@ -411,6 +496,7 @@ func (m *MemoryStore) FromCheckpointFiles(dir string, from int64) (int, error) { return 0, nil } +// checkFilesWithExtension walks a directory tree to check if files with the given extension exist. func checkFilesWithExtension(dir string, extension string) (bool, error) { found := false @@ -730,13 +816,24 @@ func findFiles(direntries []fs.DirEntry, t int64, extension string, findMoreRece return nums[a.Name()] < nums[b.Name()] }) + if len(nums) == 0 { + return nil, nil + } + filenames := make([]string, 0) - for i := range direntries { - e := direntries[i] + + for i, e := range direntries { ts1 := nums[e.Name()] + // Logic to look for files in forward or direction + // If logic: All files greater than or after + // the given timestamp will be selected + // Else If logic: All files less than or before + // the given timestamp will be selected if findMoreRecentFiles && t <= ts1 { filenames = append(filenames, e.Name()) + } else if !findMoreRecentFiles && ts1 <= t && ts1 != 0 { + filenames = append(filenames, e.Name()) } if i == len(direntries)-1 { continue @@ -749,10 +846,6 @@ func findFiles(direntries []fs.DirEntry, t int64, extension string, findMoreRece if ts1 < t && t < ts2 { filenames = append(filenames, e.Name()) } - } else { - if ts2 < t { - filenames = append(filenames, e.Name()) - } } } diff --git a/pkg/metricstore/config.go b/pkg/metricstore/config.go new file mode 100644 index 00000000..44a24f7d --- /dev/null +++ b/pkg/metricstore/config.go @@ -0,0 +1,261 @@ +// Copyright (C) NHR@FAU, University Erlangen-Nuremberg. +// All rights reserved. This file is part of cc-backend. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +// Package metricstore provides config.go: Configuration structures and metric management. +// +// # Configuration Hierarchy +// +// The metricstore package uses nested configuration structures: +// +// MetricStoreConfig (Keys) +// ├─ NumWorkers: Parallel checkpoint/archive workers +// ├─ RetentionInMemory: How long to keep data in RAM +// ├─ MemoryCap: Memory limit in bytes (triggers forceFree) +// ├─ Checkpoints: Persistence configuration +// │ ├─ FileFormat: "avro" or "json" +// │ ├─ Interval: How often to save (e.g., "1h") +// │ └─ RootDir: Checkpoint storage path +// ├─ Cleanup: Long-term storage configuration +// │ ├─ Interval: How often to delete/archive +// │ ├─ RootDir: Archive storage path +// │ └─ Mode: "delete" or "archive" +// ├─ Debug: Development/debugging options +// └─ Subscriptions: NATS topic subscriptions for metric ingestion +// +// # Metric Configuration +// +// Each metric (e.g., "cpu_load", "mem_used") has a MetricConfig entry in the global +// Metrics map, defining: +// +// - Frequency: Measurement interval in seconds +// - Aggregation: How to combine values (sum/avg/none) when transforming scopes +// - offset: Internal index into Level.metrics slice (assigned during Init) +// +// # AggregationStrategy +// +// Determines how to combine metric values when aggregating from finer to coarser scopes: +// +// - NoAggregation: Do not combine (incompatible scopes) +// - SumAggregation: Add values (e.g., power consumption: core→socket) +// - AvgAggregation: Average values (e.g., temperature: core→socket) +package metricstore + +import ( + "fmt" + "time" +) + +const ( + DefaultMaxWorkers = 10 + DefaultBufferCapacity = 512 + DefaultGCTriggerInterval = 100 + DefaultAvroWorkers = 4 + DefaultCheckpointBufferMin = 3 + DefaultAvroCheckpointInterval = time.Minute + DefaultMemoryUsageTrackerInterval = 1 * time.Hour +) + +// Checkpoints configures periodic persistence of in-memory metric data. +// +// Fields: +// - FileFormat: "avro" (default, binary, compact) or "json" (human-readable, slower) +// - Interval: Duration string (e.g., "1h", "30m") between checkpoint saves +// - RootDir: Filesystem path for checkpoint files (created if missing) +type Checkpoints struct { + FileFormat string `json:"file-format"` + Interval string `json:"interval"` + RootDir string `json:"directory"` +} + +// Debug provides development and profiling options. +// +// Fields: +// - DumpToFile: Path to dump checkpoint data for inspection (empty = disabled) +// - EnableGops: Enable gops agent for live runtime debugging (https://github.com/google/gops) +type Debug struct { + DumpToFile string `json:"dump-to-file"` + EnableGops bool `json:"gops"` +} + +// Archive configures long-term storage of old metric data. +// +// Data older than RetentionInMemory is archived to disk or deleted. +// +// Fields: +// - ArchiveInterval: Duration string (e.g., "24h") between archive operations +// - RootDir: Filesystem path for archived data (created if missing) +// - DeleteInstead: If true, delete old data instead of archiving (saves disk space) +type Cleanup struct { + Interval string `json:"interval"` + RootDir string `json:"directory"` + Mode string `json:"mode"` +} + +// Subscriptions defines NATS topics to subscribe to for metric ingestion. +// +// Each subscription receives metrics via NATS messaging, enabling real-time +// data collection from compute nodes. +// +// Fields: +// - SubscribeTo: NATS subject/channel name (e.g., "metrics.compute.*") +// - ClusterTag: Default cluster name for metrics without cluster tag (optional) +type Subscriptions []struct { + // Channel name + SubscribeTo string `json:"subscribe-to"` + + // Allow lines without a cluster tag, use this as default, optional + ClusterTag string `json:"cluster-tag"` +} + +// MetricStoreConfig defines the main configuration for the metricstore. +// +// Loaded from cc-backend's config.json "metricstore" section. Controls memory usage, +// persistence, archiving, and metric ingestion. +// +// Fields: +// - NumWorkers: Parallel workers for checkpoint/archive (0 = auto: min(NumCPU/2+1, 10)) +// - RetentionInMemory: Duration string (e.g., "48h") for in-memory data retention +// - MemoryCap: Max bytes for buffer data (0 = unlimited); triggers forceFree when exceeded +// - Checkpoints: Periodic persistence configuration +// - Debug: Development/profiling options (nil = disabled) +// - Archive: Long-term storage configuration (nil = disabled) +// - Subscriptions: NATS topics for metric ingestion (nil = polling only) +type MetricStoreConfig struct { + // Number of concurrent workers for checkpoint and archive operations. + // If not set or 0, defaults to min(runtime.NumCPU()/2+1, 10) + NumWorkers int `json:"num-workers"` + RetentionInMemory string `json:"retention-in-memory"` + MemoryCap int `json:"memory-cap"` + Checkpoints Checkpoints `json:"checkpoints"` + Debug *Debug `json:"debug"` + Cleanup *Cleanup `json:"cleanup"` + Subscriptions *Subscriptions `json:"nats-subscriptions"` +} + +// Keys is the global metricstore configuration instance. +// +// Initialized with defaults, then overwritten by cc-backend's config.json. +// Accessed by Init(), Checkpointing(), and other lifecycle functions. +var Keys MetricStoreConfig = MetricStoreConfig{ + Checkpoints: Checkpoints{ + FileFormat: "avro", + RootDir: "./var/checkpoints", + }, + Cleanup: &Cleanup{ + Mode: "delete", + }, +} + +// AggregationStrategy defines how to combine metric values across hierarchy levels. +// +// Used when transforming data from finer-grained scopes (e.g., core) to coarser scopes +// (e.g., socket). This is SPATIAL aggregation, not TEMPORAL (time-based) aggregation. +// +// Values: +// - NoAggregation: Do not aggregate (incompatible scopes or non-aggregatable metrics) +// - SumAggregation: Add values (e.g., power: sum core power → socket power) +// - AvgAggregation: Average values (e.g., temperature: average core temps → socket temp) +type AggregationStrategy int + +const ( + NoAggregation AggregationStrategy = iota // Do not aggregate + SumAggregation // Sum values (e.g., power, energy) + AvgAggregation // Average values (e.g., temperature, utilization) +) + +// AssignAggregationStrategy parses a string into an AggregationStrategy value. +// +// Used when loading metric configurations from JSON/YAML files. +// +// Parameters: +// - str: "sum", "avg", or "" (empty string for NoAggregation) +// +// Returns: +// - AggregationStrategy: Parsed value +// - error: Non-nil if str is unrecognized +func AssignAggregationStrategy(str string) (AggregationStrategy, error) { + switch str { + case "": + return NoAggregation, nil + case "sum": + return SumAggregation, nil + case "avg": + return AvgAggregation, nil + default: + return NoAggregation, fmt.Errorf("[METRICSTORE]> unknown aggregation strategy: %s", str) + } +} + +// MetricConfig defines configuration for a single metric type. +// +// Stored in the global Metrics map, keyed by metric name (e.g., "cpu_load"). +// +// Fields: +// - Frequency: Measurement interval in seconds (e.g., 60 for 1-minute granularity) +// - Aggregation: How to combine values across hierarchy levels (sum/avg/none) +// - offset: Internal index into Level.metrics slice (assigned during Init) +type MetricConfig struct { + // Interval in seconds at which measurements are stored + Frequency int64 + + // Can be 'sum', 'avg' or null. Describes how to aggregate metrics from the same timestep over the hierarchy. + Aggregation AggregationStrategy + + // Private, used internally... + offset int +} + +// Metrics is the global map of metric configurations. +// +// Keyed by metric name (e.g., "cpu_load", "mem_used"). Populated during Init() +// from cluster configuration and checkpoint restoration. Each MetricConfig.offset +// corresponds to the buffer slice index in Level.metrics. +var Metrics map[string]MetricConfig + +// GetMetricFrequency retrieves the measurement interval for a metric. +// +// Parameters: +// - metricName: Metric name (e.g., "cpu_load") +// +// Returns: +// - int64: Frequency in seconds +// - error: Non-nil if metric not found in Metrics map +func GetMetricFrequency(metricName string) (int64, error) { + if metric, ok := Metrics[metricName]; ok { + return metric.Frequency, nil + } + return 0, fmt.Errorf("[METRICSTORE]> metric %s not found", metricName) +} + +// AddMetric registers a new metric or updates an existing one. +// +// If the metric already exists with a different frequency, uses the higher frequency +// (finer granularity). This handles cases where different clusters report the same +// metric at different intervals. +// +// Parameters: +// - name: Metric name (e.g., "cpu_load") +// - metric: Configuration (frequency, aggregation strategy) +// +// Returns: +// - error: Always nil (signature for future error handling) +func AddMetric(name string, metric MetricConfig) error { + if Metrics == nil { + Metrics = make(map[string]MetricConfig, 0) + } + + if existingMetric, ok := Metrics[name]; ok { + if existingMetric.Frequency != metric.Frequency { + if existingMetric.Frequency < metric.Frequency { + existingMetric.Frequency = metric.Frequency + Metrics[name] = existingMetric + } + } + } else { + Metrics[name] = metric + } + + return nil +} diff --git a/internal/metricstore/configSchema.go b/pkg/metricstore/configSchema.go similarity index 65% rename from internal/metricstore/configSchema.go rename to pkg/metricstore/configSchema.go index c06bc6d9..70fcc74a 100644 --- a/internal/metricstore/configSchema.go +++ b/pkg/metricstore/configSchema.go @@ -14,11 +14,11 @@ const configSchema = `{ "type": "integer" }, "checkpoints": { - "description": "Configuration for checkpointing the metrics within metric-store", + "description": "Configuration for checkpointing the metrics buffers", "type": "object", "properties": { "file-format": { - "description": "Specify the type of checkpoint file. There are 2 variants: 'avro' and 'json'. If nothing is specified, 'avro' is default.", + "description": "Specify the format for checkpoint files. There are 2 variants: 'avro' and 'json'. If nothing is specified, 'avro' is default.", "type": "string" }, "interval": { @@ -26,26 +26,38 @@ const configSchema = `{ "type": "string" }, "directory": { - "description": "Specify the parent directy in which the checkpointed files should be placed.", + "description": "Path in which the checkpointed files should be placed.", "type": "string" } }, "required": ["interval"] }, - "archive": { - "description": "Configuration for archiving the already checkpointed files.", + "cleanup": { + "description": "Configuration for the cleanup process.", "type": "object", "properties": { + "mode": { + "description": "The operation mode (e.g., 'archive' or 'delete').", + "type": "string", + "enum": ["archive", "delete"] + }, "interval": { - "description": "Interval at which the checkpointed files should be archived.", + "description": "Interval at which the cleanup runs.", "type": "string" }, "directory": { - "description": "Specify the directy in which the archived files should be placed.", + "description": "Target directory for operations.", "type": "string" } }, - "required": ["interval", "directory"] + "if": { + "properties": { + "mode": { "const": "archive" } + } + }, + "then": { + "required": ["interval", "directory"] + } }, "retention-in-memory": { "description": "Keep the metrics within memory for given time interval. Retention for X hours, then the metrics would be freed.", @@ -56,13 +68,13 @@ const configSchema = `{ "type": "integer" }, "nats-subscriptions": { - "description": "Array of various subscriptions. Allows to subscibe to different subjects and publishers.", + "description": "Array of various subscriptions. Allows to subscribe to different subjects and publishers.", "type": "array", "items": { "type": "object", "properties": { "subscribe-to": { - "description": "Channel name", + "description": "Subject name", "type": "string" }, "cluster-tag": { @@ -70,8 +82,9 @@ const configSchema = `{ "type": "string" } } + "required": ["subscribe-to"] } } }, - "required": ["checkpoints", "retention-in-memory"] + "required": ["checkpoints", "retention-in-memory", "memory-cap"] }` diff --git a/internal/metricstore/debug.go b/pkg/metricstore/debug.go similarity index 100% rename from internal/metricstore/debug.go rename to pkg/metricstore/debug.go diff --git a/internal/metricstore/healthcheck.go b/pkg/metricstore/healthcheck.go similarity index 100% rename from internal/metricstore/healthcheck.go rename to pkg/metricstore/healthcheck.go diff --git a/pkg/metricstore/level.go b/pkg/metricstore/level.go new file mode 100644 index 00000000..b35137ef --- /dev/null +++ b/pkg/metricstore/level.go @@ -0,0 +1,388 @@ +// Copyright (C) NHR@FAU, University Erlangen-Nuremberg. +// All rights reserved. This file is part of cc-backend. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +// Package metricstore provides level.go: Hierarchical tree structure for metric storage. +// +// # Level Architecture +// +// The Level type forms a tree structure where each node represents a level in the +// ClusterCockpit hierarchy: cluster → host → socket → core → hwthread, with special +// nodes for memory domains and accelerators. +// +// Structure: +// +// Root Level (cluster="emmy") +// ├─ Level (host="node001") +// │ ├─ Level (socket="0") +// │ │ ├─ Level (core="0") [stores cpu0 metrics] +// │ │ └─ Level (core="1") [stores cpu1 metrics] +// │ └─ Level (socket="1") +// │ └─ ... +// └─ Level (host="node002") +// └─ ... +// +// Each Level can: +// - Hold data (metrics slice of buffer pointers) +// - Have child nodes (children map[string]*Level) +// - Both simultaneously (inner nodes can store aggregated metrics) +// +// # Selector Paths +// +// Selectors are hierarchical paths: []string{"cluster", "host", "component"}. +// Example: []string{"emmy", "node001", "cpu0"} navigates to the cpu0 core level. +// +// # Concurrency +// +// RWMutex protects children map and metrics slice. Read-heavy workload (metric reads) +// uses RLock. Writes (new levels, buffer updates) use Lock. Double-checked locking +// prevents races during level creation. +package metricstore + +import ( + "sync" + "unsafe" + + "github.com/ClusterCockpit/cc-lib/v2/util" +) + +// Level represents a node in the hierarchical metric storage tree. +// +// Can be both a leaf or inner node. Inner nodes hold data in 'metrics' for aggregated +// values (e.g., socket-level metrics derived from core-level data). Named "Level" +// instead of "node" to avoid confusion with cluster nodes (hosts). +// +// Fields: +// - children: Map of child level names to Level pointers (e.g., "cpu0" → Level) +// - metrics: Slice of buffer pointers (one per metric, indexed by MetricConfig.offset) +// - lock: RWMutex for concurrent access (read-heavy, write-rare) +type Level struct { + children map[string]*Level + metrics []*buffer + lock sync.RWMutex +} + +// findLevelOrCreate navigates to or creates the level specified by selector. +// +// Recursively descends the tree, creating missing levels as needed. Uses double-checked +// locking: RLock first (fast path), then Lock if creation needed (slow path), then +// re-check after acquiring Lock to handle races. +// +// Example selector: []string{"emmy", "node001", "cpu0"} +// Navigates: root → emmy → node001 → cpu0, creating levels as needed. +// +// Parameters: +// - selector: Hierarchical path (consumed recursively, decreasing depth) +// - nMetrics: Number of metric slots to allocate in new levels +// +// Returns: +// - *Level: The target level (existing or newly created) +// +// Note: sync.Map may improve performance for high-concurrency writes, but current +// approach suffices for read-heavy workload. +func (l *Level) findLevelOrCreate(selector []string, nMetrics int) *Level { + if len(selector) == 0 { + return l + } + + // Allow concurrent reads: + l.lock.RLock() + var child *Level + var ok bool + if l.children == nil { + // Children map needs to be created... + l.lock.RUnlock() + } else { + child, ok = l.children[selector[0]] + l.lock.RUnlock() + if ok { + return child.findLevelOrCreate(selector[1:], nMetrics) + } + } + + // The level does not exist, take write lock for unique access: + l.lock.Lock() + // While this thread waited for the write lock, another thread + // could have created the child node. + if l.children != nil { + child, ok = l.children[selector[0]] + if ok { + l.lock.Unlock() + return child.findLevelOrCreate(selector[1:], nMetrics) + } + } + + child = &Level{ + metrics: make([]*buffer, nMetrics), + children: nil, + } + + if l.children != nil { + l.children[selector[0]] = child + } else { + l.children = map[string]*Level{selector[0]: child} + } + l.lock.Unlock() + return child.findLevelOrCreate(selector[1:], nMetrics) +} + +// collectPaths gathers all selector paths at the specified depth in the tree. +// +// Recursively traverses children, collecting paths when currentDepth+1 == targetDepth. +// Each path is a selector that can be used with findLevel() or findBuffers(). +// +// Explicitly copies slices to avoid shared underlying arrays between siblings, preventing +// unintended mutations. +// +// Parameters: +// - currentDepth: Depth of current level (0 = root) +// - targetDepth: Depth to collect paths from +// - currentPath: Path accumulated so far +// - results: Output slice (appended to) +// +// Example: collectPaths(0, 2, []string{}, &results) collects all 2-level paths +// like []string{"emmy", "node001"}, []string{"emmy", "node002"}, etc. +func (l *Level) collectPaths(currentDepth, targetDepth int, currentPath []string, results *[][]string) { + l.lock.RLock() + defer l.lock.RUnlock() + + for key, child := range l.children { + if child == nil { + continue + } + + // We explicitly make a new slice and copy data to avoid sharing underlying arrays between siblings + newPath := make([]string, len(currentPath)) + copy(newPath, currentPath) + newPath = append(newPath, key) + + // Check depth, and just return if depth reached + if currentDepth+1 == targetDepth { + *results = append(*results, newPath) + } else { + child.collectPaths(currentDepth+1, targetDepth, newPath, results) + } + } +} + +// free removes buffers older than the retention threshold from the entire subtree. +// +// Recursively frees buffers in this level's metrics and all child levels. Buffers +// with standard capacity (BufferCap) are returned to the pool. Called by the +// retention worker to enforce retention policies. +// +// Parameters: +// - t: Retention threshold timestamp (Unix seconds) +// +// Returns: +// - int: Total number of buffers freed in this subtree +// - error: Non-nil on failure (propagated from children) +func (l *Level) free(t int64) (int, error) { + l.lock.Lock() + defer l.lock.Unlock() + + n := 0 + for i, b := range l.metrics { + if b != nil { + delme, m := b.free(t) + n += m + if delme { + if cap(b.data) == BufferCap { + bufferPool.Put(b) + } + l.metrics[i] = nil + } + } + } + + for _, l := range l.children { + m, err := l.free(t) + n += m + if err != nil { + return n, err + } + } + + return n, nil +} + +// forceFree removes the oldest buffer from each metric chain in the subtree. +// +// Unlike free(), which removes based on time threshold, this unconditionally removes +// the oldest buffer in each chain. Used by MemoryUsageTracker when memory cap is +// exceeded and time-based retention is insufficient. +// +// Recursively processes current level's metrics and all child levels. +// +// Returns: +// - int: Total number of buffers freed in this subtree +// - error: Non-nil on failure (propagated from children) +func (l *Level) forceFree() (int, error) { + l.lock.Lock() + defer l.lock.Unlock() + + n := 0 + + // Iterate over metrics in the current level + for i, b := range l.metrics { + if b != nil { + // Attempt to free the oldest buffer in this chain + delme, freedCount := b.forceFreeOldest() + n += freedCount + + // If delme is true, it means 'b' itself (the head) was the oldest + // and needs to be removed from the slice. + if delme { + // Nil out fields to ensure no hanging references + + b.next = nil + b.prev = nil + b.data = nil + + l.metrics[i] = nil + } + } + } + + // Recursively traverse children + for _, child := range l.children { + m, err := child.forceFree() + n += m + if err != nil { + return n, err + } + } + + return n, nil +} + +// sizeInBytes calculates the total memory usage of all buffers in the subtree. +// +// Recursively sums buffer data sizes (count of Float values × sizeof(Float)) across +// this level's metrics and all child levels. Used by MemoryUsageTracker to enforce +// memory cap limits. +// +// Returns: +// - int64: Total bytes used by buffer data in this subtree +func (l *Level) sizeInBytes() int64 { + l.lock.RLock() + defer l.lock.RUnlock() + size := int64(0) + + for _, b := range l.metrics { + if b != nil { + size += b.count() * int64(unsafe.Sizeof(util.Float(0))) + } + } + + for _, child := range l.children { + size += child.sizeInBytes() + } + + return size +} + +// findLevel navigates to the level specified by selector, returning nil if not found. +// +// Read-only variant of findLevelOrCreate. Does not create missing levels. +// Recursively descends the tree following the selector path. +// +// Parameters: +// - selector: Hierarchical path (e.g., []string{"emmy", "node001", "cpu0"}) +// +// Returns: +// - *Level: The target level, or nil if any component in the path does not exist +func (l *Level) findLevel(selector []string) *Level { + if len(selector) == 0 { + return l + } + + l.lock.RLock() + defer l.lock.RUnlock() + + lvl := l.children[selector[0]] + if lvl == nil { + return nil + } + + return lvl.findLevel(selector[1:]) +} + +// findBuffers invokes callback on all buffers matching the selector pattern. +// +// Supports flexible selector patterns (from cc-lib/util.Selector): +// - Exact match: Selector element with String set (e.g., "node001") +// - Group match: Selector element with Group set (e.g., ["cpu0", "cpu2", "cpu4"]) +// - Wildcard: Selector element with Any=true (matches all children) +// +// Empty selector (len==0) matches current level's buffer at 'offset' and recursively +// all descendant buffers at the same offset (used for aggregation queries). +// +// Parameters: +// - selector: Pattern to match (consumed recursively) +// - offset: Metric index in metrics slice (from MetricConfig.offset) +// - f: Callback invoked on each matching buffer +// +// Returns: +// - error: First error returned by callback, or nil if all succeeded +// +// Example: +// +// // Find all cpu0 buffers across all hosts: +// findBuffers([]Selector{{Any: true}, {String: "cpu0"}}, metricOffset, callback) +func (l *Level) findBuffers(selector util.Selector, offset int, f func(b *buffer) error) error { + l.lock.RLock() + defer l.lock.RUnlock() + + if len(selector) == 0 { + b := l.metrics[offset] + if b != nil { + return f(b) + } + + for _, lvl := range l.children { + err := lvl.findBuffers(nil, offset, f) + if err != nil { + return err + } + } + return nil + } + + sel := selector[0] + if len(sel.String) != 0 && l.children != nil { + lvl, ok := l.children[sel.String] + if ok { + err := lvl.findBuffers(selector[1:], offset, f) + if err != nil { + return err + } + } + return nil + } + + if sel.Group != nil && l.children != nil { + for _, key := range sel.Group { + lvl, ok := l.children[key] + if ok { + err := lvl.findBuffers(selector[1:], offset, f) + if err != nil { + return err + } + } + } + return nil + } + + if sel.Any && l.children != nil { + for _, lvl := range l.children { + if err := lvl.findBuffers(selector[1:], offset, f); err != nil { + return err + } + } + return nil + } + + return nil +} diff --git a/internal/metricstore/lineprotocol.go b/pkg/metricstore/lineprotocol.go similarity index 100% rename from internal/metricstore/lineprotocol.go rename to pkg/metricstore/lineprotocol.go diff --git a/internal/metricstore/metricstore.go b/pkg/metricstore/metricstore.go similarity index 57% rename from internal/metricstore/metricstore.go rename to pkg/metricstore/metricstore.go index 37028654..a50f4ab5 100644 --- a/internal/metricstore/metricstore.go +++ b/pkg/metricstore/metricstore.go @@ -37,35 +37,89 @@ import ( "github.com/ClusterCockpit/cc-lib/v2/util" ) +// Define a struct to hold your globals and the mutex +type GlobalState struct { + mu sync.RWMutex + lastRetentionTime int64 + selectorsExcluded bool +} + var ( singleton sync.Once msInstance *MemoryStore // shutdownFunc stores the context cancellation function created in Init // and is called during Shutdown to cancel all background goroutines - shutdownFunc context.CancelFunc + shutdownFunc context.CancelFunc + shutdownFuncMu sync.Mutex // Protects shutdownFunc from concurrent access + // Create a global instance + state = &GlobalState{} ) // NodeProvider provides information about nodes currently in use by running jobs. +// // This interface allows metricstore to query job information without directly // depending on the repository package, breaking the import cycle. +// +// Implementations should return nodes that are actively processing jobs started +// before the given timestamp. These nodes will be excluded from retention-based +// garbage collection to prevent data loss for jobs that are still running or +// recently completed. type NodeProvider interface { // GetUsedNodes returns a map of cluster names to sorted lists of unique hostnames // that are currently in use by jobs that started before the given timestamp. + // + // Parameters: + // - ts: Unix timestamp threshold - returns nodes with jobs started before this time + // + // Returns: + // - Map of cluster names to lists of node hostnames that should be excluded from garbage collection + // - Error if the query fails GetUsedNodes(ts int64) (map[string][]string, error) } +// Metric represents a single metric data point to be written to the store. type Metric struct { - Name string - Value schema.Float + Name string + Value schema.Float + // MetricConfig contains frequency and aggregation settings for this metric. + // If Frequency is 0, configuration will be looked up from MemoryStore.Metrics during Write(). MetricConfig MetricConfig } +// MemoryStore is the main in-memory time-series metric storage implementation. +// +// It organizes metrics in a hierarchical tree structure where each level represents +// a component of the system hierarchy (e.g., cluster → host → CPU). Each level can +// store multiple metrics as time-series buffers. +// +// The store is initialized as a singleton via InitMetrics() and accessed via GetMemoryStore(). +// All public methods are safe for concurrent use. type MemoryStore struct { Metrics map[string]MetricConfig root Level - nodeProvider NodeProvider // Injected dependency for querying running jobs + nodeProvider NodeProvider } +// Init initializes the metric store from configuration and starts background workers. +// +// This function must be called exactly once before any other metricstore operations. +// It performs the following initialization steps: +// 1. Validates and decodes the metric store configuration +// 2. Configures worker pool size (defaults to NumCPU/2+1, max 10) +// 3. Loads metric configurations from all registered clusters +// 4. Restores checkpoints within the retention window +// 5. Starts background workers for retention, checkpointing, archiving, and monitoring +// 6. Optionally subscribes to NATS for real-time metric ingestion +// +// Parameters: +// - rawConfig: JSON configuration for the metric store (see MetricStoreConfig) +// - wg: WaitGroup that will be incremented for each background goroutine started +// +// The function will call cclog.Fatal on critical errors during initialization. +// Use Shutdown() to cleanly stop all background workers started by Init(). +// +// Note: Signal handling must be implemented by the caller. Call Shutdown() when +// receiving termination signals to ensure checkpoint data is persisted. func Init(rawConfig json.RawMessage, wg *sync.WaitGroup) { startupTime := time.Now() @@ -142,25 +196,30 @@ func Init(rawConfig json.RawMessage, wg *sync.WaitGroup) { retentionGoroutines := 1 checkpointingGoroutines := 1 dataStagingGoroutines := 1 - archivingGoroutines := 0 - if Keys.Archive != nil { - archivingGoroutines = 1 - } - totalGoroutines := retentionGoroutines + checkpointingGoroutines + dataStagingGoroutines + archivingGoroutines + archivingGoroutines := 1 + memoryUsageTracker := 1 + + totalGoroutines := retentionGoroutines + + checkpointingGoroutines + + dataStagingGoroutines + + archivingGoroutines + + memoryUsageTracker + wg.Add(totalGoroutines) Retention(wg, ctx) Checkpointing(wg, ctx) - if Keys.Archive != nil { - Archiving(wg, ctx) - } + CleanUp(wg, ctx) DataStaging(wg, ctx) + MemoryUsageTracker(wg, ctx) // Note: Signal handling has been removed from this function. // The caller is responsible for handling shutdown signals and calling // the shutdown() function when appropriate. // Store the shutdown function for later use by Shutdown() + shutdownFuncMu.Lock() shutdownFunc = shutdown + shutdownFuncMu.Unlock() if Keys.Subscriptions != nil { err = ReceiveNats(ms, 1, ctx) @@ -170,8 +229,17 @@ func Init(rawConfig json.RawMessage, wg *sync.WaitGroup) { } } -// InitMetrics creates a new, initialized instance of a MemoryStore. -// Will panic if values in the metric configurations are invalid. +// InitMetrics initializes the singleton MemoryStore instance with the given metric configurations. +// +// This function must be called before GetMemoryStore() and can only be called once due to +// the singleton pattern. It assigns each metric an internal offset for efficient buffer indexing. +// +// Parameters: +// - metrics: Map of metric names to their configurations (frequency and aggregation strategy) +// +// Panics if any metric has Frequency == 0, which indicates an invalid configuration. +// +// After this call, the global msInstance is ready for use via GetMemoryStore(). func InitMetrics(metrics map[string]MetricConfig) { singleton.Do(func() { offset := 0 @@ -198,6 +266,11 @@ func InitMetrics(metrics map[string]MetricConfig) { }) } +// GetMemoryStore returns the singleton MemoryStore instance. +// +// Returns the initialized MemoryStore singleton. Calls cclog.Fatal if InitMetrics() was not called first. +// +// This function is safe for concurrent use after initialization. func GetMemoryStore() *MemoryStore { if msInstance == nil { cclog.Fatalf("[METRICSTORE]> MemoryStore not initialized!") @@ -214,7 +287,22 @@ func (ms *MemoryStore) SetNodeProvider(provider NodeProvider) { ms.nodeProvider = provider } +// Shutdown performs a graceful shutdown of the metric store. +// +// This function cancels all background goroutines started by Init() and writes +// a final checkpoint to disk before returning. It should be called when the +// application receives a termination signal. +// +// The function will: +// 1. Cancel the context to stop all background workers +// 2. Close NATS message channels if using Avro format +// 3. Write a final checkpoint to preserve in-memory data +// 4. Log any errors encountered during shutdown +// +// Note: This function blocks until the final checkpoint is written. func Shutdown() { + shutdownFuncMu.Lock() + defer shutdownFuncMu.Unlock() if shutdownFunc != nil { shutdownFunc() } @@ -241,6 +329,17 @@ func Shutdown() { cclog.Infof("[METRICSTORE]> Done! (%d files written)\n", files) } +// Retention starts a background goroutine that periodically frees old metric data. +// +// This worker runs at half the retention interval and calls Free() to remove buffers +// older than the configured retention time. It respects the NodeProvider to preserve +// data for nodes with active jobs. +// +// Parameters: +// - wg: WaitGroup to signal completion when context is cancelled +// - ctx: Context for cancellation signal +// +// The goroutine exits when ctx is cancelled. func Retention(wg *sync.WaitGroup, ctx context.Context) { ms := GetMemoryStore() @@ -266,7 +365,12 @@ func Retention(wg *sync.WaitGroup, ctx context.Context) { case <-ctx.Done(): return case <-ticker.C: + state.mu.Lock() + t := time.Now().Add(-d) + + state.lastRetentionTime = t.Unix() + cclog.Infof("[METRICSTORE]> start freeing buffers (older than %s)...\n", t.Format(time.RFC3339)) freed, err := Free(ms, t) @@ -275,11 +379,125 @@ func Retention(wg *sync.WaitGroup, ctx context.Context) { } else { cclog.Infof("[METRICSTORE]> done: %d buffers freed\n", freed) } + + state.mu.Unlock() } } }() } +// MemoryUsageTracker starts a background goroutine that monitors memory usage. +// +// This worker checks memory usage every minute and force-frees buffers if memory +// exceeds the configured cap. It protects against infinite loops by limiting +// iterations and forcing garbage collection between attempts. +// +// Parameters: +// - wg: WaitGroup to signal completion when context is cancelled +// - ctx: Context for cancellation signal +// +// The goroutine exits when ctx is cancelled. +func MemoryUsageTracker(wg *sync.WaitGroup, ctx context.Context) { + ms := GetMemoryStore() + + go func() { + defer wg.Done() + d := DefaultMemoryUsageTrackerInterval + + if d <= 0 { + return + } + + ticker := time.NewTicker(d) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + state.mu.RLock() + + memoryUsageGB := ms.SizeInGB() + cclog.Infof("[METRICSTORE]> current memory usage: %.2f GB\n", memoryUsageGB) + + freedTotal := 0 + var err error + + // First force-free all the checkpoints that were + if state.lastRetentionTime != 0 && state.selectorsExcluded { + freedTotal, err = ms.Free(nil, state.lastRetentionTime) + if err != nil { + cclog.Errorf("[METRICSTORE]> error while force-freeing the excluded buffers: %s", err) + } + + // Calling runtime.GC() twice in succession tp completely empty a bufferPool (sync.Pool) + runtime.GC() + runtime.GC() + + cclog.Infof("[METRICSTORE]> done: %d excluded buffers force-freed\n", freedTotal) + } + + state.mu.RUnlock() + + memoryUsageGB = ms.SizeInGB() + + if memoryUsageGB > float64(Keys.MemoryCap) { + cclog.Warnf("[METRICSTORE]> memory usage is still greater than the Memory Cap: %d GB\n", Keys.MemoryCap) + cclog.Warnf("[METRICSTORE]> starting to force-free the buffers from the Metric Store\n") + + const maxIterations = 100 + + for range maxIterations { + memoryUsageGB = ms.SizeInGB() + if memoryUsageGB < float64(Keys.MemoryCap) { + break + } + + freed, err := ms.ForceFree() + if err != nil { + cclog.Errorf("[METRICSTORE]> error while force-freeing the buffers: %s", err) + } + if freed == 0 { + cclog.Errorf("[METRICSTORE]> 0 buffers force-freed in last try, %d total buffers force-freed, memory usage of %.2f GB remains higher than the memory cap of %d GB and there are no buffers left to force-free\n", freedTotal, memoryUsageGB, Keys.MemoryCap) + break + } + freedTotal += freed + + runtime.GC() + } + + if memoryUsageGB >= float64(Keys.MemoryCap) { + cclog.Errorf("[METRICSTORE]> reached maximum iterations (%d) or no more buffers to free, current memory usage: %.2f GB\n", maxIterations, memoryUsageGB) + } else { + cclog.Infof("[METRICSTORE]> done: %d buffers force-freed\n", freedTotal) + cclog.Infof("[METRICSTORE]> current memory usage after force-freeing the buffers: %.2f GB\n", memoryUsageGB) + } + } + + } + } + }() +} + +// Free removes metric data older than the given time while preserving data for active nodes. +// +// This function implements intelligent retention by consulting the NodeProvider (if configured) +// to determine which nodes are currently in use by running jobs. Data for these nodes is +// preserved even if older than the retention time. +// +// Parameters: +// - ms: The MemoryStore instance +// - t: Time threshold - buffers with data older than this will be freed +// +// Returns: +// - Number of buffers freed +// - Error if NodeProvider query fails +// +// Behavior: +// - If no NodeProvider is set: frees all buffers older than t +// - If NodeProvider returns empty map: frees all buffers older than t +// - Otherwise: preserves buffers for nodes returned by GetUsedNodes(), frees others func Free(ms *MemoryStore, t time.Time) (int, error) { // If no NodeProvider is configured, free all buffers older than t if ms.nodeProvider == nil { @@ -291,30 +509,34 @@ func Free(ms *MemoryStore, t time.Time) (int, error) { return 0, err } - // excludeSelectors := make(map[string][]string, 0) - - // excludeSelectors := map[string][]string{ - // "alex": {"a0122", "a0123", "a0225"}, - // "fritz": {"f0201", "f0202"}, - // } - switch lenMap := len(excludeSelectors); lenMap { // If the length of the map returned by GetUsedNodes() is 0, // then use default Free method with nil selector case 0: + state.selectorsExcluded = false return ms.Free(nil, t.Unix()) // Else formulate selectors, exclude those from the map // and free the rest of the selectors default: + state.selectorsExcluded = true selectors := GetSelectors(ms, excludeSelectors) return FreeSelected(ms, selectors, t) } } -// A function to free specific selectors. Used when we want to retain some specific nodes -// beyond the retention time. +// FreeSelected frees buffers for specific selectors while preserving others. +// +// This function is used when we want to retain some specific nodes beyond the retention time. +// It iterates through the provided selectors and frees their associated buffers. +// +// Parameters: +// - ms: The MemoryStore instance +// - selectors: List of selector paths to free (e.g., [["cluster1", "node1"], ["cluster2", "node2"]]) +// - t: Time threshold for freeing buffers +// +// Returns the total number of buffers freed and any error encountered. func FreeSelected(ms *MemoryStore, selectors [][]string, t time.Time) (int, error) { freed := 0 @@ -331,8 +553,22 @@ func FreeSelected(ms *MemoryStore, selectors [][]string, t time.Time) (int, erro return freed, nil } -// This function will populate all the second last levels - meaning nodes -// From that we can exclude the specific selectosr/node we want to retain. +// GetSelectors returns all selectors at depth 2 (cluster/node level) that are NOT in the exclusion map. +// +// This function generates a list of selectors whose buffers should be freed by excluding +// selectors that correspond to nodes currently in use by running jobs. +// +// Parameters: +// - ms: The MemoryStore instance +// - excludeSelectors: Map of cluster names to node hostnames that should NOT be freed +// +// Returns a list of selectors ([]string paths) that can be safely freed. +// +// Example: +// +// If the tree has paths ["emmy", "node001"] and ["emmy", "node002"], +// and excludeSelectors contains {"emmy": ["node001"]}, +// then only [["emmy", "node002"]] is returned. func GetSelectors(ms *MemoryStore, excludeSelectors map[string][]string) [][]string { allSelectors := ms.GetPaths(2) @@ -361,9 +597,6 @@ func GetSelectors(ms *MemoryStore, excludeSelectors map[string][]string) [][]str } } - // fmt.Printf("All selectors: %#v\n\n", allSelectors) - // fmt.Printf("filteredSelectors: %#v\n\n", filteredSelectors) - return filteredSelectors } @@ -386,6 +619,7 @@ func (m *MemoryStore) Write(selector []string, ts int64, metrics []Metric) error if metric.MetricConfig.Frequency == 0 { metric.MetricConfig, ok = m.Metrics[metric.Name] if !ok { + cclog.Debugf("[METRICSTORE]> Unknown metric '%s' in Write() - skipping", metric.Name) metric.MetricConfig.Frequency = 0 } metrics[i] = metric @@ -506,6 +740,12 @@ func (m *MemoryStore) Free(selector []string, t int64) (int, error) { return m.GetLevel(selector).free(t) } +// Free releases all buffers for the selected level and all its children that +// contain only values older than `t`. +func (m *MemoryStore) ForceFree() (int, error) { + return m.GetLevel(nil).forceFree() +} + func (m *MemoryStore) FreeAll() error { for k := range m.root.children { delete(m.root.children, k) @@ -518,6 +758,10 @@ func (m *MemoryStore) SizeInBytes() int64 { return m.root.sizeInBytes() } +func (m *MemoryStore) SizeInGB() float64 { + return float64(m.root.sizeInBytes()) / 1e9 +} + // ListChildren , given a selector, returns a list of all children of the level // selected. func (m *MemoryStore) ListChildren(selector []string) []string { diff --git a/internal/metricstore/metricstore_test.go b/pkg/metricstore/metricstore_test.go similarity index 100% rename from internal/metricstore/metricstore_test.go rename to pkg/metricstore/metricstore_test.go diff --git a/internal/metricstore/query.go b/pkg/metricstore/query.go similarity index 76% rename from internal/metricstore/query.go rename to pkg/metricstore/query.go index 78c78dd5..a1656192 100644 --- a/internal/metricstore/query.go +++ b/pkg/metricstore/query.go @@ -3,6 +3,27 @@ // Use of this source code is governed by a MIT-style // license that can be found in the LICENSE file. +// This file implements high-level query functions for loading job metric data +// with automatic scope transformation and aggregation. +// +// Key Concepts: +// +// Metric Scopes: Metrics are collected at different granularities (native scope): +// - HWThread: Per hardware thread +// - Core: Per CPU core +// - Socket: Per CPU socket +// - MemoryDomain: Per memory domain (NUMA) +// - Accelerator: Per GPU/accelerator +// - Node: Per compute node +// +// Scope Transformation: The buildQueries functions transform between native scope +// and requested scope by: +// - Aggregating finer-grained data (e.g., HWThread → Core → Socket → Node) +// - Rejecting requests for finer granularity than available +// - Handling special cases (e.g., Accelerator metrics) +// +// Query Building: Constructs APIQuery structures with proper selectors (Type, TypeIds) +// based on cluster topology and job resources. package metricstore import ( @@ -17,9 +38,33 @@ import ( "github.com/ClusterCockpit/cc-lib/v2/schema" ) -// TestLoadDataCallback allows tests to override LoadData behavior +// TestLoadDataCallback allows tests to override LoadData behavior for testing purposes. +// When set to a non-nil function, LoadData will call this function instead of the default implementation. var TestLoadDataCallback func(job *schema.Job, metrics []string, scopes []schema.MetricScope, ctx context.Context, resolution int) (schema.JobData, error) +// LoadData loads metric data for a specific job with automatic scope transformation. +// +// This is the primary function for retrieving job metric data. It handles: +// - Building queries with scope transformation via buildQueries +// - Fetching data from the metric store +// - Organizing results by metric and scope +// - Converting NaN statistics to 0 for JSON compatibility +// - Partial error handling (returns data for successful queries even if some fail) +// +// Parameters: +// - job: Job metadata including cluster, resources, and time range +// - metrics: List of metric names to load +// - scopes: Requested metric scopes (will be transformed to match native scopes) +// - ctx: Context for cancellation (currently unused but reserved for future use) +// - resolution: Data resolution in seconds (0 for native resolution) +// +// Returns: +// - JobData: Map of metric → scope → JobMetric with time-series data and statistics +// - Error: Returns error if query building or fetching fails, or partial error listing failed hosts +// +// Example: +// +// jobData, err := LoadData(job, []string{"cpu_load", "mem_used"}, []schema.MetricScope{schema.MetricScopeNode}, ctx, 60) func LoadData( job *schema.Job, metrics []string, @@ -91,12 +136,7 @@ func LoadData( *id = query.TypeIds[ndx] } - if res.Avg.IsNaN() || res.Min.IsNaN() || res.Max.IsNaN() { - // "schema.Float()" because regular float64 can not be JSONed when NaN. - res.Avg = schema.Float(0) - res.Min = schema.Float(0) - res.Max = schema.Float(0) - } + sanitizeStats(&res) jobMetric.Series = append(jobMetric.Series, schema.Series{ Hostname: query.Hostname, @@ -126,6 +166,10 @@ func LoadData( return jobData, nil } +// Pre-converted scope strings avoid repeated string(MetricScope) allocations during +// query construction. These are used in APIQuery.Type field throughout buildQueries +// and buildNodeQueries functions. Converting once at package initialization improves +// performance for high-volume query building. var ( hwthreadString = string(schema.MetricScopeHWThread) coreString = string(schema.MetricScopeCore) @@ -134,12 +178,41 @@ var ( acceleratorString = string(schema.MetricScopeAccelerator) ) +// buildQueries constructs APIQuery structures with automatic scope transformation for a job. +// +// This function implements the core scope transformation logic, handling all combinations of +// native metric scopes and requested scopes. It uses the cluster topology to determine which +// hardware IDs to include in each query. +// +// Scope Transformation Rules: +// - If native scope >= requested scope: Aggregates data (Aggregate=true in APIQuery) +// - If native scope < requested scope: Returns error (cannot increase granularity) +// - Special handling for Accelerator scope (independent of CPU hierarchy) +// +// The function generates one or more APIQuery per (metric, scope, host) combination: +// - For non-aggregated queries: One query with all relevant IDs +// - For aggregated queries: May generate multiple queries (e.g., one per socket/core) +// +// Parameters: +// - job: Job metadata including cluster, subcluster, and resource allocation +// - metrics: List of metrics to query +// - scopes: Requested scopes for each metric +// - resolution: Data resolution in seconds +// +// Returns: +// - []APIQuery: List of queries to execute +// - []schema.MetricScope: Assigned scope for each query (after transformation) +// - error: Returns error if topology lookup fails or unhandled scope combination encountered func buildQueries( job *schema.Job, metrics []string, scopes []schema.MetricScope, resolution int64, ) ([]APIQuery, []schema.MetricScope, error) { + if len(job.Resources) == 0 { + return nil, nil, fmt.Errorf("METRICDATA/CCMS > no resources allocated for job %d", job.JobID) + } + queries := make([]APIQuery, 0, len(metrics)*len(scopes)*len(job.Resources)) assignedScope := []schema.MetricScope{} @@ -152,7 +225,6 @@ func buildQueries( for _, metric := range metrics { mc := archive.GetMetricConfig(job.Cluster, metric) if mc == nil { - // return nil, fmt.Errorf("METRICDATA/CCMS > metric '%s' is not specified for cluster '%s'", metric, job.Cluster) cclog.Infof("metric '%s' is not specified for cluster '%s'", metric, job.Cluster) continue } @@ -171,10 +243,9 @@ func buildQueries( } } - // Avoid duplicates... - handledScopes := make([]schema.MetricScope, 0, 3) + // Avoid duplicates using map for O(1) lookup + handledScopes := make(map[schema.MetricScope]bool, 3) - scopesLoop: for _, requestedScope := range scopes { nativeScope := mc.Scope if nativeScope == schema.MetricScopeAccelerator && job.NumAcc == 0 { @@ -182,12 +253,10 @@ func buildQueries( } scope := nativeScope.Max(requestedScope) - for _, s := range handledScopes { - if scope == s { - continue scopesLoop - } + if handledScopes[scope] { + continue } - handledScopes = append(handledScopes, scope) + handledScopes[scope] = true for _, host := range job.Resources { hwthreads := host.HWThreads @@ -232,7 +301,7 @@ func buildQueries( continue } - // HWThread -> HWThead + // HWThread -> HWThread if nativeScope == schema.MetricScopeHWThread && scope == schema.MetricScopeHWThread { queries = append(queries, APIQuery{ Metric: metric, @@ -356,7 +425,7 @@ func buildQueries( continue } - // MemoryDoman -> Node + // MemoryDomain -> Node if nativeScope == schema.MetricScopeMemoryDomain && scope == schema.MetricScopeNode { sockets, _ := topology.GetMemoryDomainsFromHWThreads(hwthreads) queries = append(queries, APIQuery{ @@ -420,12 +489,26 @@ func buildQueries( return queries, assignedScope, nil } +// LoadStats loads only metric statistics (avg/min/max) for a job at node scope. +// +// This is an optimized version of LoadData that fetches only statistics without +// time-series data, reducing bandwidth and memory usage. Always queries at node scope. +// +// Parameters: +// - job: Job metadata +// - metrics: List of metric names +// - ctx: Context (currently unused) +// +// Returns: +// - Map of metric → hostname → statistics +// - Error on query building or fetching failure func LoadStats( job *schema.Job, metrics []string, ctx context.Context, ) (map[string]map[string]schema.MetricStatistics, error) { - queries, _, err := buildQueries(job, metrics, []schema.MetricScope{schema.MetricScopeNode}, 0) // #166 Add scope shere for analysis view accelerator normalization? + // TODO(#166): Add scope parameter for analysis view accelerator normalization + queries, _, err := buildQueries(job, metrics, []schema.MetricScope{schema.MetricScopeNode}, 0) if err != nil { cclog.Errorf("Error while building queries for jobId %d, Metrics %v: %s", job.JobID, metrics, err.Error()) return nil, err @@ -477,6 +560,20 @@ func LoadStats( return stats, nil } +// LoadScopedStats loads metric statistics for a job with scope-aware grouping. +// +// Similar to LoadStats but supports multiple scopes and returns statistics grouped +// by scope with hardware IDs (e.g., per-core, per-socket statistics). +// +// Parameters: +// - job: Job metadata +// - metrics: List of metric names +// - scopes: Requested metric scopes +// - ctx: Context (currently unused) +// +// Returns: +// - ScopedJobStats: Map of metric → scope → []ScopedStats (with hostname and ID) +// - Error or partial error listing failed queries func LoadScopedStats( job *schema.Job, metrics []string, @@ -533,12 +630,7 @@ func LoadScopedStats( *id = query.TypeIds[ndx] } - if res.Avg.IsNaN() || res.Min.IsNaN() || res.Max.IsNaN() { - // "schema.Float()" because regular float64 can not be JSONed when NaN. - res.Avg = schema.Float(0) - res.Min = schema.Float(0) - res.Max = schema.Float(0) - } + sanitizeStats(&res) scopedJobStats[metric][scope] = append(scopedJobStats[metric][scope], &schema.ScopedStats{ Hostname: query.Hostname, @@ -567,6 +659,22 @@ func LoadScopedStats( return scopedJobStats, nil } +// LoadNodeData loads metric data for specific nodes in a cluster over a time range. +// +// Unlike LoadData which operates on job resources, this function queries arbitrary nodes +// directly. Useful for system monitoring and node status views. +// +// Parameters: +// - cluster: Cluster name +// - metrics: List of metric names +// - nodes: List of node hostnames (nil = all nodes in cluster via ForAllNodes) +// - scopes: Requested metric scopes (currently unused - always node scope) +// - from, to: Time range +// - ctx: Context (currently unused) +// +// Returns: +// - Map of hostname → metric → []JobMetric +// - Error or partial error listing failed queries func LoadNodeData( cluster string, metrics, nodes []string, @@ -615,14 +723,10 @@ func LoadNodeData( metric := query.Metric qdata := res[0] if qdata.Error != nil { - /* Build list for "partial errors", if any */ errors = append(errors, fmt.Sprintf("fetching %s for node %s failed: %s", metric, query.Hostname, *qdata.Error)) } - if qdata.Avg.IsNaN() || qdata.Min.IsNaN() || qdata.Max.IsNaN() { - // return nil, fmt.Errorf("METRICDATA/CCMS > fetching %s for node %s failed: %s", metric, query.Hostname, "avg/min/max is NaN") - qdata.Avg, qdata.Min, qdata.Max = 0., 0., 0. - } + sanitizeStats(&qdata) hostdata, ok := data[query.Hostname] if !ok { @@ -656,6 +760,24 @@ func LoadNodeData( return data, nil } +// LoadNodeListData loads metric data for a list of nodes with full scope transformation support. +// +// This is the most flexible node data loading function, supporting arbitrary scopes and +// resolution. Uses buildNodeQueries for proper scope transformation based on topology. +// +// Parameters: +// - cluster: Cluster name +// - subCluster: SubCluster name (empty string to infer from node names) +// - nodes: List of node hostnames +// - metrics: List of metric names +// - scopes: Requested metric scopes +// - resolution: Data resolution in seconds +// - from, to: Time range +// - ctx: Context (currently unused) +// +// Returns: +// - Map of hostname → JobData (metric → scope → JobMetric) +// - Error or partial error listing failed queries func LoadNodeListData( cluster, subCluster string, nodes []string, @@ -696,7 +818,7 @@ func LoadNodeListData( } else { query = req.Queries[i] } - // qdata := res[0] + metric := query.Metric scope := assignedScope[i] mc := archive.GetMetricConfig(cluster, metric) @@ -742,12 +864,7 @@ func LoadNodeListData( *id = query.TypeIds[ndx] } - if res.Avg.IsNaN() || res.Min.IsNaN() || res.Max.IsNaN() { - // "schema.Float()" because regular float64 can not be JSONed when NaN. - res.Avg = schema.Float(0) - res.Min = schema.Float(0) - res.Max = schema.Float(0) - } + sanitizeStats(&res) scopeData.Series = append(scopeData.Series, schema.Series{ Hostname: query.Hostname, @@ -770,6 +887,23 @@ func LoadNodeListData( return data, nil } +// buildNodeQueries constructs APIQuery structures for node-based queries with scope transformation. +// +// Similar to buildQueries but operates on node lists rather than job resources. +// Supports dynamic subcluster lookup when subCluster parameter is empty. +// +// Parameters: +// - cluster: Cluster name +// - subCluster: SubCluster name (empty = infer from node hostnames) +// - nodes: List of node hostnames +// - metrics: List of metric names +// - scopes: Requested metric scopes +// - resolution: Data resolution in seconds +// +// Returns: +// - []APIQuery: List of queries to execute +// - []schema.MetricScope: Assigned scope for each query +// - error: Returns error if topology lookup fails or unhandled scope combination func buildNodeQueries( cluster string, subCluster string, @@ -778,6 +912,10 @@ func buildNodeQueries( scopes []schema.MetricScope, resolution int64, ) ([]APIQuery, []schema.MetricScope, error) { + if len(nodes) == 0 { + return nil, nil, fmt.Errorf("METRICDATA/CCMS > no nodes specified for query") + } + queries := make([]APIQuery, 0, len(metrics)*len(scopes)*len(nodes)) assignedScope := []schema.MetricScope{} @@ -795,7 +933,6 @@ func buildNodeQueries( for _, metric := range metrics { mc := archive.GetMetricConfig(cluster, metric) if mc == nil { - // return nil, fmt.Errorf("METRICDATA/CCMS > metric '%s' is not specified for cluster '%s'", metric, cluster) cclog.Warnf("metric '%s' is not specified for cluster '%s'", metric, cluster) continue } @@ -814,20 +951,17 @@ func buildNodeQueries( } } - // Avoid duplicates... - handledScopes := make([]schema.MetricScope, 0, 3) + // Avoid duplicates using map for O(1) lookup + handledScopes := make(map[schema.MetricScope]bool, 3) - scopesLoop: for _, requestedScope := range scopes { nativeScope := mc.Scope scope := nativeScope.Max(requestedScope) - for _, s := range handledScopes { - if scope == s { - continue scopesLoop - } + if handledScopes[scope] { + continue } - handledScopes = append(handledScopes, scope) + handledScopes[scope] = true for _, hostname := range nodes { @@ -850,7 +984,7 @@ func buildNodeQueries( // Moved check here if metric matches hardware specs if nativeScope == schema.MetricScopeAccelerator && len(acceleratorIds) == 0 { - continue scopesLoop + continue } // Accelerator -> Accelerator (Use "accelerator" scope if requested scope is lower than node) @@ -890,7 +1024,7 @@ func buildNodeQueries( continue } - // HWThread -> HWThead + // HWThread -> HWThread if nativeScope == schema.MetricScopeHWThread && scope == schema.MetricScopeHWThread { queries = append(queries, APIQuery{ Metric: metric, @@ -1014,7 +1148,7 @@ func buildNodeQueries( continue } - // MemoryDoman -> Node + // MemoryDomain -> Node if nativeScope == schema.MetricScopeMemoryDomain && scope == schema.MetricScopeNode { sockets, _ := topology.GetMemoryDomainsFromHWThreads(topology.Node) queries = append(queries, APIQuery{ @@ -1078,10 +1212,37 @@ func buildNodeQueries( return queries, assignedScope, nil } +// sanitizeStats converts NaN statistics to zero for JSON compatibility. +// +// schema.Float with NaN values cannot be properly JSON-encoded, so we convert +// NaN to 0. This loses the distinction between "no data" and "zero value", +// but maintains API compatibility. +func sanitizeStats(data *APIMetricData) { + if data.Avg.IsNaN() { + data.Avg = schema.Float(0) + } + if data.Min.IsNaN() { + data.Min = schema.Float(0) + } + if data.Max.IsNaN() { + data.Max = schema.Float(0) + } +} + +// intToStringSlice converts a slice of integers to a slice of strings. +// Used to convert hardware thread/core/socket IDs from topology (int) to APIQuery TypeIds (string). +// +// Optimized to reuse a byte buffer for string conversion, reducing allocations. func intToStringSlice(is []int) []string { + if len(is) == 0 { + return nil + } + ss := make([]string, len(is)) + buf := make([]byte, 0, 16) // Reusable buffer for integer conversion for i, x := range is { - ss[i] = strconv.Itoa(x) + buf = strconv.AppendInt(buf[:0], int64(x), 10) + ss[i] = string(buf) } return ss } diff --git a/internal/metricstore/stats.go b/pkg/metricstore/stats.go similarity index 100% rename from internal/metricstore/stats.go rename to pkg/metricstore/stats.go diff --git a/tools/archive-manager/import_test.go b/tools/archive-manager/import_test.go index 57294d50..db8940c2 100644 --- a/tools/archive-manager/import_test.go +++ b/tools/archive-manager/import_test.go @@ -41,7 +41,7 @@ func TestImportFileToSqlite(t *testing.T) { } // Initialize destination backend (sqlite) - dstConfig := fmt.Sprintf(`{"kind":"sqlite","dbPath":"%s"}`, dstDb) + dstConfig := fmt.Sprintf(`{"kind":"sqlite","db-path":"%s"}`, dstDb) dstBackend, err := archive.InitBackend(json.RawMessage(dstConfig)) if err != nil { t.Fatalf("Failed to initialize destination backend: %s", err.Error()) @@ -176,7 +176,7 @@ func TestImportDataIntegrity(t *testing.T) { t.Fatalf("Failed to initialize source backend: %s", err.Error()) } - dstConfig := fmt.Sprintf(`{"kind":"sqlite","dbPath":"%s"}`, dstDb) + dstConfig := fmt.Sprintf(`{"kind":"sqlite","db-path":"%s"}`, dstDb) dstBackend, err := archive.InitBackend(json.RawMessage(dstConfig)) if err != nil { t.Fatalf("Failed to initialize destination backend: %s", err.Error()) @@ -270,7 +270,7 @@ func TestImportEmptyArchive(t *testing.T) { t.Fatalf("Failed to initialize source backend: %s", err.Error()) } - dstConfig := fmt.Sprintf(`{"kind":"sqlite","dbPath":"%s"}`, dstDb) + dstConfig := fmt.Sprintf(`{"kind":"sqlite","db-path":"%s"}`, dstDb) dstBackend, err := archive.InitBackend(json.RawMessage(dstConfig)) if err != nil { t.Fatalf("Failed to initialize destination backend: %s", err.Error()) @@ -314,7 +314,7 @@ func TestImportDuplicateJobs(t *testing.T) { t.Fatalf("Failed to initialize source backend: %s", err.Error()) } - dstConfig := fmt.Sprintf(`{"kind":"sqlite","dbPath":"%s"}`, dstDb) + dstConfig := fmt.Sprintf(`{"kind":"sqlite","db-path":"%s"}`, dstDb) dstBackend, err := archive.InitBackend(json.RawMessage(dstConfig)) if err != nil { t.Fatalf("Failed to initialize destination backend: %s", err.Error()) diff --git a/tools/archive-manager/main.go b/tools/archive-manager/main.go index fff81256..918fc7c8 100644 --- a/tools/archive-manager/main.go +++ b/tools/archive-manager/main.go @@ -438,7 +438,7 @@ func main() { cclog.Abort("Main configuration must be present") } - if err := archive.Init(json.RawMessage(archiveCfg), false); err != nil { + if err := archive.Init(json.RawMessage(archiveCfg)); err != nil { cclog.Fatal(err) } ar := archive.GetHandle() diff --git a/web/configSchema.go b/web/configSchema.go index 5690b63a..cc59d95d 100644 --- a/web/configSchema.go +++ b/web/configSchema.go @@ -8,57 +8,57 @@ package web const configSchema = `{ "type": "object", "properties": { - "jobList": { + "job-list": { "description": "Job list defaults. Applies to user- and jobs views.", "type": "object", "properties": { - "usePaging": { + "use-paging": { "description": "If classic paging is used instead of continuous scrolling by default.", "type": "boolean" }, - "showFootprint": { + "show-footprint": { "description": "If footprint bars are shown as first column by default.", "type": "boolean" } } }, - "nodeList": { + "node-list": { "description": "Node list defaults. Applies to node list view.", "type": "object", "properties": { - "usePaging": { + "use-paging": { "description": "If classic paging is used instead of continuous scrolling by default.", "type": "boolean" } } }, - "jobView": { + "job-view": { "description": "Job view defaults.", "type": "object", "properties": { - "showPolarPlot": { + "show-polar-plot": { "description": "If the job metric footprints polar plot is shown by default.", "type": "boolean" }, - "showFootprint": { + "show-footprint": { "description": "If the annotated job metric footprint bars are shown by default.", "type": "boolean" }, - "showRoofline": { + "show-roofline": { "description": "If the job roofline plot is shown by default.", "type": "boolean" }, - "showStatTable": { + "show-stat-table": { "description": "If the job metric statistics table is shown by default.", "type": "boolean" } } }, - "metricConfig": { + "metric-config": { "description": "Global initial metric selections for primary views of all clusters.", "type": "object", "properties": { - "jobListMetrics": { + "job-list-metrics": { "description": "Initial metrics shown for new users in job lists (User and jobs view).", "type": "array", "items": { @@ -66,7 +66,7 @@ const configSchema = `{ "minItems": 1 } }, - "jobViewPlotMetrics": { + "job-view-plot-metrics": { "description": "Initial metrics shown for new users as job view metric plots.", "type": "array", "items": { @@ -74,7 +74,7 @@ const configSchema = `{ "minItems": 1 } }, - "jobViewTableMetrics": { + "job-view-table-metrics": { "description": "Initial metrics shown for new users in job view statistics table.", "type": "array", "items": { @@ -91,7 +91,7 @@ const configSchema = `{ "name": { "description": "The name of the cluster." }, - "jobListMetrics": { + "job-list-metrics": { "description": "Initial metrics shown for new users in job lists (User and jobs view) for subcluster.", "type": "array", "items": { @@ -99,7 +99,7 @@ const configSchema = `{ "minItems": 1 } }, - "jobViewPlotMetrics": { + "job-view-plot-metrics": { "description": "Initial metrics shown for new users as job view timeplots for subcluster.", "type": "array", "items": { @@ -107,7 +107,7 @@ const configSchema = `{ "minItems": 1 } }, - "jobViewTableMetrics": { + "job-view-table-metrics": { "description": "Initial metrics shown for new users in job view statistics table for subcluster.", "type": "array", "items": { @@ -115,7 +115,7 @@ const configSchema = `{ "minItems": 1 } }, - "subClusters": { + "sub-clusters": { "description": "The array of overrides per subcluster.", "type": "array", "items": { @@ -125,7 +125,7 @@ const configSchema = `{ "description": "The name of the subcluster.", "type": "string" }, - "jobListMetrics": { + "job-list-metrics": { "description": "Initial metrics shown for new users in job lists (User and jobs view) for subcluster.", "type": "array", "items": { @@ -133,7 +133,7 @@ const configSchema = `{ "minItems": 1 } }, - "jobViewPlotMetrics": { + "job-view-plot-metrics": { "description": "Initial metrics shown for new users as job view timeplots for subcluster.", "type": "array", "items": { @@ -141,7 +141,7 @@ const configSchema = `{ "minItems": 1 } }, - "jobViewTableMetrics": { + "job-view-table-metrics": { "description": "Initial metrics shown for new users in job view statistics table for subcluster.", "type": "array", "items": { @@ -155,29 +155,29 @@ const configSchema = `{ } } }, - "required": ["name", "subClusters"], + "required": ["name", "sub-clusters"], "minItems": 1 } } } }, - "plotConfiguration": { + "plot-configuration": { "description": "Initial settings for plot render options.", "type": "object", "properties": { - "colorBackground": { + "color-background": { "description": "If the metric plot backgrounds are initially colored by threshold limits.", "type": "boolean" }, - "plotsPerRow": { + "plots-per-row": { "description": "How many plots are initially rendered in per row. Applies to job, single node, and analysis views.", "type": "integer" }, - "lineWidth": { + "line-width": { "description": "Initial thickness of rendered plotlines. Applies to metric plot, job compare plot and roofline.", "type": "integer" }, - "colorScheme": { + "color-scheme": { "description": "Initial colorScheme to be used for metric plots.", "type": "array", "items": { diff --git a/web/frontend/package-lock.json b/web/frontend/package-lock.json index 2820b72b..6962dc1b 100644 --- a/web/frontend/package-lock.json +++ b/web/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "cc-frontend", - "version": "1.0.0", + "version": "1.5.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "cc-frontend", - "version": "1.0.0", + "version": "1.5.0", "license": "MIT", "dependencies": { "@rollup/plugin-replace": "^6.0.3", @@ -15,19 +15,19 @@ "chart.js": "^4.5.1", "date-fns": "^4.1.0", "graphql": "^16.12.0", - "mathjs": "^15.0.0", + "mathjs": "^15.1.0", "uplot": "^1.6.32", "wonka": "^6.3.5" }, "devDependencies": { "@rollup/plugin-commonjs": "^29.0.0", - "@rollup/plugin-node-resolve": "^16.0.1", + "@rollup/plugin-node-resolve": "^16.0.3", "@rollup/plugin-terser": "^0.4.4", "@timohausmann/quadtree-js": "^1.2.6", - "rollup": "^4.54.0", + "rollup": "^4.55.1", "rollup-plugin-css-only": "^4.5.5", "rollup-plugin-svelte": "^7.2.3", - "svelte": "^5.46.1" + "svelte": "^5.46.4" } }, "node_modules/@0no-co/graphql.web": { @@ -45,9 +45,9 @@ } }, "node_modules/@babel/runtime": { - "version": "7.28.4", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.4.tgz", - "integrity": "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==", + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.6.tgz", + "integrity": "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==", "license": "MIT", "engines": { "node": ">=6.9.0" @@ -244,13 +244,12 @@ } }, "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.54.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.54.0.tgz", - "integrity": "sha512-OywsdRHrFvCdvsewAInDKCNyR3laPA2mc9bRYJ6LBp5IyvF3fvXbbNR0bSzHlZVFtn6E0xw2oZlyjg4rKCVcng==", + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.55.1.tgz", + "integrity": "sha512-9R0DM/ykwfGIlNu6+2U09ga0WXeZ9MRC2Ter8jnz8415VbuIykVuc6bhdrbORFZANDmTDvq26mJrEVTl8TdnDg==", "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -258,13 +257,12 @@ ] }, "node_modules/@rollup/rollup-android-arm64": { - "version": "4.54.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.54.0.tgz", - "integrity": "sha512-Skx39Uv+u7H224Af+bDgNinitlmHyQX1K/atIA32JP3JQw6hVODX5tkbi2zof/E69M1qH2UoN3Xdxgs90mmNYw==", + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.55.1.tgz", + "integrity": "sha512-eFZCb1YUqhTysgW3sj/55du5cG57S7UTNtdMjCW7LwVcj3dTTcowCsC8p7uBdzKsZYa8J7IDE8lhMI+HX1vQvg==", "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -272,13 +270,12 @@ ] }, "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.54.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.54.0.tgz", - "integrity": "sha512-k43D4qta/+6Fq+nCDhhv9yP2HdeKeP56QrUUTW7E6PhZP1US6NDqpJj4MY0jBHlJivVJD5P8NxrjuobZBJTCRw==", + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.55.1.tgz", + "integrity": "sha512-p3grE2PHcQm2e8PSGZdzIhCKbMCw/xi9XvMPErPhwO17vxtvCN5FEA2mSLgmKlCjHGMQTP6phuQTYWUnKewwGg==", "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -286,13 +283,12 @@ ] }, "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.54.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.54.0.tgz", - "integrity": "sha512-cOo7biqwkpawslEfox5Vs8/qj83M/aZCSSNIWpVzfU2CYHa2G3P1UN5WF01RdTHSgCkri7XOlTdtk17BezlV3A==", + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.55.1.tgz", + "integrity": "sha512-rDUjG25C9qoTm+e02Esi+aqTKSBYwVTaoS1wxcN47/Luqef57Vgp96xNANwt5npq9GDxsH7kXxNkJVEsWEOEaQ==", "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -300,13 +296,12 @@ ] }, "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.54.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.54.0.tgz", - "integrity": "sha512-miSvuFkmvFbgJ1BevMa4CPCFt5MPGw094knM64W9I0giUIMMmRYcGW/JWZDriaw/k1kOBtsWh1z6nIFV1vPNtA==", + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.55.1.tgz", + "integrity": "sha512-+JiU7Jbp5cdxekIgdte0jfcu5oqw4GCKr6i3PJTlXTCU5H5Fvtkpbs4XJHRmWNXF+hKmn4v7ogI5OQPaupJgOg==", "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -314,13 +309,12 @@ ] }, "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.54.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.54.0.tgz", - "integrity": "sha512-KGXIs55+b/ZfZsq9aR026tmr/+7tq6VG6MsnrvF4H8VhwflTIuYh+LFUlIsRdQSgrgmtM3fVATzEAj4hBQlaqQ==", + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.55.1.tgz", + "integrity": "sha512-V5xC1tOVWtLLmr3YUk2f6EJK4qksksOYiz/TCsFHu/R+woubcLWdC9nZQmwjOAbmExBIVKsm1/wKmEy4z4u4Bw==", "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -328,13 +322,12 @@ ] }, "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.54.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.54.0.tgz", - "integrity": "sha512-EHMUcDwhtdRGlXZsGSIuXSYwD5kOT9NVnx9sqzYiwAc91wfYOE1g1djOEDseZJKKqtHAHGwnGPQu3kytmfaXLQ==", + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.55.1.tgz", + "integrity": "sha512-Rn3n+FUk2J5VWx+ywrG/HGPTD9jXNbicRtTM11e/uorplArnXZYsVifnPPqNNP5BsO3roI4n8332ukpY/zN7rQ==", "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -342,13 +335,12 @@ ] }, "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.54.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.54.0.tgz", - "integrity": "sha512-+pBrqEjaakN2ySv5RVrj/qLytYhPKEUwk+e3SFU5jTLHIcAtqh2rLrd/OkbNuHJpsBgxsD8ccJt5ga/SeG0JmA==", + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.55.1.tgz", + "integrity": "sha512-grPNWydeKtc1aEdrJDWk4opD7nFtQbMmV7769hiAaYyUKCT1faPRm2av8CX1YJsZ4TLAZcg9gTR1KvEzoLjXkg==", "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -356,13 +348,12 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.54.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.54.0.tgz", - "integrity": "sha512-NSqc7rE9wuUaRBsBp5ckQ5CVz5aIRKCwsoa6WMF7G01sX3/qHUw/z4pv+D+ahL1EIKy6Enpcnz1RY8pf7bjwng==", + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.55.1.tgz", + "integrity": "sha512-a59mwd1k6x8tXKcUxSyISiquLwB5pX+fJW9TkWU46lCqD/GRDe9uDN31jrMmVP3feI3mhAdvcCClhV8V5MhJFQ==", "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -370,13 +361,12 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.54.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.54.0.tgz", - "integrity": "sha512-gr5vDbg3Bakga5kbdpqx81m2n9IX8M6gIMlQQIXiLTNeQW6CucvuInJ91EuCJ/JYvc+rcLLsDFcfAD1K7fMofg==", + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.55.1.tgz", + "integrity": "sha512-puS1MEgWX5GsHSoiAsF0TYrpomdvkaXm0CofIMG5uVkP6IBV+ZO9xhC5YEN49nsgYo1DuuMquF9+7EDBVYu4uA==", "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -384,13 +374,25 @@ ] }, "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.54.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.54.0.tgz", - "integrity": "sha512-gsrtB1NA3ZYj2vq0Rzkylo9ylCtW/PhpLEivlgWe0bpgtX5+9j9EZa0wtZiCjgu6zmSeZWyI/e2YRX1URozpIw==", + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.55.1.tgz", + "integrity": "sha512-r3Wv40in+lTsULSb6nnoudVbARdOwb2u5fpeoOAZjFLznp6tDU8kd+GTHmJoqZ9lt6/Sys33KdIHUaQihFcu7g==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.55.1.tgz", + "integrity": "sha512-MR8c0+UxAlB22Fq4R+aQSPBayvYa3+9DrwG/i1TKQXFYEaoW3B5b/rkSRIypcZDdWjWnpcvxbNaAJDcSbJU3Lw==", "cpu": [ "loong64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -398,13 +400,25 @@ ] }, "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.54.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.54.0.tgz", - "integrity": "sha512-y3qNOfTBStmFNq+t4s7Tmc9hW2ENtPg8FeUD/VShI7rKxNW7O4fFeaYbMsd3tpFlIg1Q8IapFgy7Q9i2BqeBvA==", + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.55.1.tgz", + "integrity": "sha512-3KhoECe1BRlSYpMTeVrD4sh2Pw2xgt4jzNSZIIPLFEsnQn9gAnZagW9+VqDqAHgm1Xc77LzJOo2LdigS5qZ+gw==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.55.1.tgz", + "integrity": "sha512-ziR1OuZx0vdYZZ30vueNZTg73alF59DicYrPViG0NEgDVN8/Jl87zkAPu4u6VjZST2llgEUjaiNl9JM6HH1Vdw==", "cpu": [ "ppc64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -412,13 +426,12 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.54.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.54.0.tgz", - "integrity": "sha512-89sepv7h2lIVPsFma8iwmccN7Yjjtgz0Rj/Ou6fEqg3HDhpCa+Et+YSufy27i6b0Wav69Qv4WBNl3Rs6pwhebQ==", + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.55.1.tgz", + "integrity": "sha512-uW0Y12ih2XJRERZ4jAfKamTyIHVMPQnTZcQjme2HMVDAHY4amf5u414OqNYC+x+LzRdRcnIG1YodLrrtA8xsxw==", "cpu": [ "riscv64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -426,13 +439,12 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.54.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.54.0.tgz", - "integrity": "sha512-ZcU77ieh0M2Q8Ur7D5X7KvK+UxbXeDHwiOt/CPSBTI1fBmeDMivW0dPkdqkT4rOgDjrDDBUed9x4EgraIKoR2A==", + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.55.1.tgz", + "integrity": "sha512-u9yZ0jUkOED1BFrqu3BwMQoixvGHGZ+JhJNkNKY/hyoEgOwlqKb62qu+7UjbPSHYjiVy8kKJHvXKv5coH4wDeg==", "cpu": [ "riscv64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -440,13 +452,12 @@ ] }, "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.54.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.54.0.tgz", - "integrity": "sha512-2AdWy5RdDF5+4YfG/YesGDDtbyJlC9LHmL6rZw6FurBJ5n4vFGupsOBGfwMRjBYH7qRQowT8D/U4LoSvVwOhSQ==", + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.55.1.tgz", + "integrity": "sha512-/0PenBCmqM4ZUd0190j7J0UsQ/1nsi735iPRakO8iPciE7BQ495Y6msPzaOmvx0/pn+eJVVlZrNrSh4WSYLxNg==", "cpu": [ "s390x" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -454,13 +465,12 @@ ] }, "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.54.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.54.0.tgz", - "integrity": "sha512-WGt5J8Ij/rvyqpFexxk3ffKqqbLf9AqrTBbWDk7ApGUzaIs6V+s2s84kAxklFwmMF/vBNGrVdYgbblCOFFezMQ==", + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.55.1.tgz", + "integrity": "sha512-a8G4wiQxQG2BAvo+gU6XrReRRqj+pLS2NGXKm8io19goR+K8lw269eTrPkSdDTALwMmJp4th2Uh0D8J9bEV1vg==", "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -468,27 +478,38 @@ ] }, "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.54.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.54.0.tgz", - "integrity": "sha512-JzQmb38ATzHjxlPHuTH6tE7ojnMKM2kYNzt44LO/jJi8BpceEC8QuXYA908n8r3CNuG/B3BV8VR3Hi1rYtmPiw==", + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.55.1.tgz", + "integrity": "sha512-bD+zjpFrMpP/hqkfEcnjXWHMw5BIghGisOKPj+2NaNDuVT+8Ds4mPf3XcPHuat1tz89WRL+1wbcxKY3WSbiT7w==", "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ "linux" ] }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.55.1.tgz", + "integrity": "sha512-eLXw0dOiqE4QmvikfQ6yjgkg/xDM+MdU9YJuP4ySTibXU0oAvnEWXt7UDJmD4UkYialMfOGFPJnIHSe/kdzPxg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.54.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.54.0.tgz", - "integrity": "sha512-huT3fd0iC7jigGh7n3q/+lfPcXxBi+om/Rs3yiFxjvSxbSB6aohDFXbWvlspaqjeOh+hx7DDHS+5Es5qRkWkZg==", + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.55.1.tgz", + "integrity": "sha512-xzm44KgEP11te3S2HCSyYf5zIzWmx3n8HDCc7EE59+lTcswEWNpvMLfd9uJvVX8LCg9QWG67Xt75AuHn4vgsXw==", "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -496,13 +517,12 @@ ] }, "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.54.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.54.0.tgz", - "integrity": "sha512-c2V0W1bsKIKfbLMBu/WGBz6Yci8nJ/ZJdheE0EwB73N3MvHYKiKGs3mVilX4Gs70eGeDaMqEob25Tw2Gb9Nqyw==", + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.55.1.tgz", + "integrity": "sha512-yR6Bl3tMC/gBok5cz/Qi0xYnVbIxGx5Fcf/ca0eB6/6JwOY+SRUcJfI0OpeTpPls7f194as62thCt/2BjxYN8g==", "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -510,13 +530,12 @@ ] }, "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.54.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.54.0.tgz", - "integrity": "sha512-woEHgqQqDCkAzrDhvDipnSirm5vxUXtSKDYTVpZG3nUdW/VVB5VdCYA2iReSj/u3yCZzXID4kuKG7OynPnB3WQ==", + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.55.1.tgz", + "integrity": "sha512-3fZBidchE0eY0oFZBnekYCfg+5wAB0mbpCBuofh5mZuzIU/4jIVkbESmd2dOsFNS78b53CYv3OAtwqkZZmU5nA==", "cpu": [ "ia32" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -524,13 +543,12 @@ ] }, "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.54.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.54.0.tgz", - "integrity": "sha512-dzAc53LOuFvHwbCEOS0rPbXp6SIhAf2txMP5p6mGyOXXw5mWY8NGGbPMPrs4P1WItkfApDathBj/NzMLUZ9rtQ==", + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.55.1.tgz", + "integrity": "sha512-xGGY5pXj69IxKb4yv/POoocPy/qmEGhimy/FoTpTSVju3FYXUQQMFCaZZXJVidsmGxRioZAwpThl/4zX41gRKg==", "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -538,13 +556,12 @@ ] }, "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.54.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.54.0.tgz", - "integrity": "sha512-hYT5d3YNdSh3mbCU1gwQyPgQd3T2ne0A3KG8KSBdav5TiBg6eInVmV+TeR5uHufiIgSFg0XsOWGW5/RhNcSvPg==", + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.55.1.tgz", + "integrity": "sha512-SPEpaL6DX4rmcXtnhdrQYgzQ5W2uW3SCJch88lB2zImhJRhIIK44fkUrgIV/Q8yUNfw5oyZ5vkeQsZLhCb06lw==", "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -552,9 +569,9 @@ ] }, "node_modules/@sveltejs/acorn-typescript": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.7.tgz", - "integrity": "sha512-znp1A/Y1Jj4l/Zy7PX5DZKBE0ZNY+5QBngiE21NJkfSTyzzC5iKNWOtwFXKtIrn7MXEFBck4jD95iBNkGjK92Q==", + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.8.tgz", + "integrity": "sha512-esgN+54+q0NjB0Y/4BomT9samII7jGwNy/2a3wNZbT2A2RpmXsXwUt24LvLhx6jUq2gVk4cWEvcRO6MFQbOfNA==", "license": "MIT", "peerDependencies": { "acorn": "^8.9.0" @@ -728,9 +745,9 @@ } }, "node_modules/devalue": { - "version": "5.5.0", - "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.5.0.tgz", - "integrity": "sha512-69sM5yrHfFLJt0AZ9QqZXGCPfJ7fQjvpln3Rq5+PS03LD32Ost1Q9N+eEnaQwGRIriKkMImXD56ocjQmfjbV3w==", + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.6.2.tgz", + "integrity": "sha512-nPRkjWzzDQlsejL1WVifk5rvcFi/y1onBRxjaFMjZeR9mFpqu2gmAZ9xUB9/IEanEP/vBtGeGganC/GO1fmufg==", "license": "MIT" }, "node_modules/escape-latex": { @@ -795,7 +812,6 @@ "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, "hasInstallScript": true, "license": "MIT", "optional": true, @@ -976,9 +992,9 @@ } }, "node_modules/rollup": { - "version": "4.54.0", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.54.0.tgz", - "integrity": "sha512-3nk8Y3a9Ea8szgKhinMlGMhGMw89mqule3KWczxhIzqudyHdCIOHw8WJlj/r329fACjKLEh13ZSk7oE22kyeIw==", + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.55.1.tgz", + "integrity": "sha512-wDv/Ht1BNHB4upNbK74s9usvl7hObDnvVzknxqY/E/O3X6rW1U1rV1aENEfJ54eFZDTNo7zv1f5N4edCluH7+A==", "devOptional": true, "license": "MIT", "dependencies": { @@ -992,28 +1008,31 @@ "npm": ">=8.0.0" }, "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.54.0", - "@rollup/rollup-android-arm64": "4.54.0", - "@rollup/rollup-darwin-arm64": "4.54.0", - "@rollup/rollup-darwin-x64": "4.54.0", - "@rollup/rollup-freebsd-arm64": "4.54.0", - "@rollup/rollup-freebsd-x64": "4.54.0", - "@rollup/rollup-linux-arm-gnueabihf": "4.54.0", - "@rollup/rollup-linux-arm-musleabihf": "4.54.0", - "@rollup/rollup-linux-arm64-gnu": "4.54.0", - "@rollup/rollup-linux-arm64-musl": "4.54.0", - "@rollup/rollup-linux-loong64-gnu": "4.54.0", - "@rollup/rollup-linux-ppc64-gnu": "4.54.0", - "@rollup/rollup-linux-riscv64-gnu": "4.54.0", - "@rollup/rollup-linux-riscv64-musl": "4.54.0", - "@rollup/rollup-linux-s390x-gnu": "4.54.0", - "@rollup/rollup-linux-x64-gnu": "4.54.0", - "@rollup/rollup-linux-x64-musl": "4.54.0", - "@rollup/rollup-openharmony-arm64": "4.54.0", - "@rollup/rollup-win32-arm64-msvc": "4.54.0", - "@rollup/rollup-win32-ia32-msvc": "4.54.0", - "@rollup/rollup-win32-x64-gnu": "4.54.0", - "@rollup/rollup-win32-x64-msvc": "4.54.0", + "@rollup/rollup-android-arm-eabi": "4.55.1", + "@rollup/rollup-android-arm64": "4.55.1", + "@rollup/rollup-darwin-arm64": "4.55.1", + "@rollup/rollup-darwin-x64": "4.55.1", + "@rollup/rollup-freebsd-arm64": "4.55.1", + "@rollup/rollup-freebsd-x64": "4.55.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.55.1", + "@rollup/rollup-linux-arm-musleabihf": "4.55.1", + "@rollup/rollup-linux-arm64-gnu": "4.55.1", + "@rollup/rollup-linux-arm64-musl": "4.55.1", + "@rollup/rollup-linux-loong64-gnu": "4.55.1", + "@rollup/rollup-linux-loong64-musl": "4.55.1", + "@rollup/rollup-linux-ppc64-gnu": "4.55.1", + "@rollup/rollup-linux-ppc64-musl": "4.55.1", + "@rollup/rollup-linux-riscv64-gnu": "4.55.1", + "@rollup/rollup-linux-riscv64-musl": "4.55.1", + "@rollup/rollup-linux-s390x-gnu": "4.55.1", + "@rollup/rollup-linux-x64-gnu": "4.55.1", + "@rollup/rollup-linux-x64-musl": "4.55.1", + "@rollup/rollup-openbsd-x64": "4.55.1", + "@rollup/rollup-openharmony-arm64": "4.55.1", + "@rollup/rollup-win32-arm64-msvc": "4.55.1", + "@rollup/rollup-win32-ia32-msvc": "4.55.1", + "@rollup/rollup-win32-x64-gnu": "4.55.1", + "@rollup/rollup-win32-x64-msvc": "4.55.1", "fsevents": "~2.3.2" } }, @@ -1157,9 +1176,9 @@ } }, "node_modules/svelte": { - "version": "5.46.1", - "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.46.1.tgz", - "integrity": "sha512-ynjfCHD3nP2el70kN5Pmg37sSi0EjOm9FgHYQdC4giWG/hzO3AatzXXJJgP305uIhGQxSufJLuYWtkY8uK/8RA==", + "version": "5.46.4", + "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.46.4.tgz", + "integrity": "sha512-VJwdXrmv9L8L7ZasJeWcCjoIuMRVbhuxbss0fpVnR8yorMmjNDwcjIH08vS6wmSzzzgAG5CADQ1JuXPS2nwt9w==", "license": "MIT", "dependencies": { "@jridgewell/remapping": "^2.3.4", @@ -1170,7 +1189,7 @@ "aria-query": "^5.3.1", "axobject-query": "^4.1.0", "clsx": "^2.1.1", - "devalue": "^5.5.0", + "devalue": "^5.6.2", "esm-env": "^1.2.1", "esrap": "^2.2.1", "is-reference": "^3.0.3", @@ -1192,9 +1211,9 @@ } }, "node_modules/terser": { - "version": "5.44.1", - "resolved": "https://registry.npmjs.org/terser/-/terser-5.44.1.tgz", - "integrity": "sha512-t/R3R/n0MSwnnazuPpPNVO60LX0SKL45pyl9YlvxIdkH0Of7D5qM2EVe+yASRIlY5pZ73nclYJfNANGWPwFDZw==", + "version": "5.46.0", + "resolved": "https://registry.npmjs.org/terser/-/terser-5.46.0.tgz", + "integrity": "sha512-jTwoImyr/QbOWFFso3YoU3ik0jBBDJ6JTOQiy/J2YxVJdZCc+5u7skhNwiOR3FQIygFqVUPHl7qbbxtjW2K3Qg==", "dev": true, "license": "BSD-2-Clause", "dependencies": { @@ -1217,9 +1236,9 @@ "license": "MIT" }, "node_modules/typed-function": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/typed-function/-/typed-function-4.2.1.tgz", - "integrity": "sha512-EGjWssW7Tsk4DGfE+5yluuljS1OGYWiI1J6e8puZz9nTMM51Oug8CD5Zo4gWMsOhq5BI+1bF+rWTm4Vbj3ivRA==", + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/typed-function/-/typed-function-4.2.2.tgz", + "integrity": "sha512-VwaXim9Gp1bngi/q3do8hgttYn2uC3MoT/gfuMWylnj1IeZBUAyPddHZlo1K05BDoj8DYPpMdiHqH1dDYdJf2A==", "license": "MIT", "engines": { "node": ">= 18" diff --git a/web/frontend/package.json b/web/frontend/package.json index 97b5619f..e9bcf8a2 100644 --- a/web/frontend/package.json +++ b/web/frontend/package.json @@ -1,6 +1,6 @@ { "name": "cc-frontend", - "version": "1.0.0", + "version": "1.5.0", "license": "MIT", "scripts": { "build": "rollup -c", @@ -8,13 +8,14 @@ }, "devDependencies": { "@rollup/plugin-commonjs": "^29.0.0", - "@rollup/plugin-node-resolve": "^16.0.1", + + "@rollup/plugin-node-resolve": "^16.0.3", "@rollup/plugin-terser": "^0.4.4", "@timohausmann/quadtree-js": "^1.2.6", - "rollup": "^4.54.0", + "rollup": "^4.55.1", "rollup-plugin-css-only": "^4.5.5", "rollup-plugin-svelte": "^7.2.3", - "svelte": "^5.46.1" + "svelte": "^5.46.4" }, "dependencies": { "@rollup/plugin-replace": "^6.0.3", @@ -23,7 +24,7 @@ "chart.js": "^4.5.1", "date-fns": "^4.1.0", "graphql": "^16.12.0", - "mathjs": "^15.0.0", + "mathjs": "^15.1.0", "uplot": "^1.6.32", "wonka": "^6.3.5" } diff --git a/web/frontend/src/Analysis.root.svelte b/web/frontend/src/Analysis.root.svelte index 6c5d8507..f8ca1f3b 100644 --- a/web/frontend/src/Analysis.root.svelte +++ b/web/frontend/src/Analysis.root.svelte @@ -43,11 +43,14 @@ } = $props(); // By default, look at the jobs of the last 6 hours: + // svelte-ignore state_referenced_locally if (filterPresets?.startTime == null) { + // svelte-ignore state_referenced_locally if (filterPresets == null) filterPresets = {}; let now = new Date(Date.now()); let hourAgo = new Date(now); hourAgo.setHours(hourAgo.getHours() - 6); + // svelte-ignore state_referenced_locally filterPresets.startTime = { from: hourAgo.toISOString(), to: now.toISOString(), diff --git a/web/frontend/src/DashPublic.root.svelte b/web/frontend/src/DashPublic.root.svelte index fbbf486d..c758ed37 100644 --- a/web/frontend/src/DashPublic.root.svelte +++ b/web/frontend/src/DashPublic.root.svelte @@ -6,9 +6,6 @@ --> - - - + + + { + from = new Date(Date.now() - 5 * 60 * 1000); + to = new Date(Date.now()); + clusterFrom = new Date(Date.now() - (8 * 60 * 60 * 1000)) + + if (interval) stackedFrom += Math.floor(interval / 1000); + else stackedFrom += 1 // Workaround: TimeSelection not linked, just trigger new data on manual refresh + }} + /> + + + +{#if $statusQuery.fetching || $statesTimed.fetching} + + + + + + +{:else if $statusQuery.error || $statesTimed.error} + + + + + + + {#if $statusQuery.error} - { - from = new Date(Date.now() - 5 * 60 * 1000); - to = new Date(Date.now()); - clusterFrom = new Date(Date.now() - (8 * 60 * 60 * 1000)) - - if (interval) stackedFrom += Math.floor(interval / 1000); - else stackedFrom += 1 // Workaround: TimeSelection not linked, just trigger new data on manual refresh - }} - /> + Error Requesting Status Data: {$statusQuery.error.message} - - {#if $statusQuery.fetching || $statesTimed.fetching} - - - - - + {/if} + {#if $statesTimed.error} + + Error Requesting Node Scheduler States: {$statesTimed.error.message} + + {/if} + - {:else if $statusQuery.error || $statesTimed.error} - - - - - - - {#if $statusQuery.error} - - Error Requesting Status Data: {$statusQuery.error.message} - - {/if} - {#if $statesTimed.error} - - Error Requesting Node Scheduler States: {$statesTimed.error.message} - - {/if} - - - {:else} - - - - - - -

Cluster {presetCluster.charAt(0).toUpperCase() + presetCluster.slice(1)}

- - - - -
-
- -

CPU(s)

{[...clusterInfo?.processorTypes].join(', ')}

-
-
- - - +{:else} + +
+ + - - - - - {clusterInfo?.runningJobs} - -
- Running Jobs -
- - - - {clusterInfo?.activeUsers} - -
- Active Users -
- - - - {clusterInfo?.allocatedNodes} - -
- Active Nodes -
- -
- + + + +

Cluster {presetCluster.charAt(0).toUpperCase() + presetCluster.slice(1)}

+ + + + +
+
+ +

CPU(s)

{[...clusterInfo?.processorTypes].join(', ')}

+
+
+ + + + + + + + + {clusterInfo?.runningJobs} + +
+ Running Jobs +
+ + + + {clusterInfo?.activeUsers} + +
+ Active Users +
+ + + + {clusterInfo?.allocatedNodes} + +
+ Active Nodes +
+ +
+ + + + {clusterInfo?.flopRate} {clusterInfo?.flopRateUnit} + +
+ Total Flop Rate +
+ + + + {clusterInfo?.memBwRate} {clusterInfo?.memBwRateUnit} + +
+ Total Memory Bandwidth +
+ + {#if clusterInfo?.totalAccs !== 0} - {clusterInfo?.flopRate} {clusterInfo?.flopRateUnit} + {clusterInfo?.gpuPwr} {clusterInfo?.gpuPwrUnit}
- Total Flop Rate + Total GPU Power
+ {:else} - {clusterInfo?.memBwRate} {clusterInfo?.memBwRateUnit} + {clusterInfo?.cpuPwr} {clusterInfo?.cpuPwrUnit}
- Total Memory Bandwidth + Total CPU Power
- {#if clusterInfo?.totalAccs !== 0} - - - {clusterInfo?.gpuPwr} {clusterInfo?.gpuPwrUnit} - -
- Total GPU Power -
- - {:else} - - - {clusterInfo?.cpuPwr} {clusterInfo?.cpuPwrUnit} - -
- Total CPU Power -
- - {/if} -
+ {/if} +
+ + + Active Cores + + + + {formatNumber(clusterInfo?.allocatedCores)} + {formatNumber(clusterInfo?.idleCores)} + + + + Idle Cores + + + {#if clusterInfo?.totalAccs !== 0} - Active Cores + Active GPU - - {formatNumber(clusterInfo?.allocatedCores)} - {formatNumber(clusterInfo?.idleCores)} + + {formatNumber(clusterInfo?.allocatedAccs)} + {formatNumber(clusterInfo?.idleAccs)} - Idle Cores + Idle GPU - {#if clusterInfo?.totalAccs !== 0} - - - Active GPU - - - - {formatNumber(clusterInfo?.allocatedAccs)} - {formatNumber(clusterInfo?.idleAccs)} - - - - Idle GPU - - - {/if} - - - + {/if} + + + + - -
+ + + +
+ Cluster Utilization ( + + {`${$statusQuery?.data?.clusterMetrics?.metrics[0]?.name} (${$statusQuery?.data?.clusterMetrics?.metrics[0]?.unit?.prefix}${$statusQuery?.data?.clusterMetrics?.metrics[0]?.unit?.base})`} + , + + {`${$statusQuery?.data?.clusterMetrics?.metrics[1]?.name} (${$statusQuery?.data?.clusterMetrics?.metrics[1]?.unit?.prefix}${$statusQuery?.data?.clusterMetrics?.metrics[1]?.unit?.base})`} + + ) +
+
+ {#key $statusQuery?.data?.clusterMetrics} -
- + {/key} +
+ - -
- {#key $statusQuery?.data?.nodeMetrics} - - {/key} -
- - - - {#if refinedStateData.length > 0} - -
- {#key refinedStateData} - sd.count, - )} - entities={refinedStateData.map( - (sd) => sd.state, - )} - fixColors={refinedStateData.map( - (sd) => colors['nodeStates'][sd.state], - )} - /> - {/key} -
- - + +
+ {#key $statusQuery?.data?.nodeMetrics} + + {/key} +
+ +
+ + + + + {#if refinedStateData.length > 0} + +
{#key refinedStateData} - - - - - - - {#each refinedStateData as sd, i} - - - - - - {/each} -
StateCount
{sd.state.charAt(0).toUpperCase() + sd.state.slice(1)}{sd.count}
+ sd.count, + )} + entities={refinedStateData.map( + (sd) => sd.state, + )} + fixColors={refinedStateData.map( + (sd) => colors['nodeStates'][sd.state], + )} + /> {/key} - - {:else} - - Cannot render state status: No state data returned for Pie Chart - - {/if} - - +
+ + + {#key refinedStateData} + + + + + + + {#each refinedStateData as sd, i} + + + + + + {/each} +
StateCount
{sd.state.charAt(0).toUpperCase() + sd.state.slice(1)}{sd.count}
+ {/key} + + {:else} + + Cannot render state status: No state data returned for Pie Chart + + {/if} +
+ - -
- {#key $statesTimed?.data?.nodeStatesTimed} - - {/key} -
- -
- {/if} - - + +
+ {#key $statesTimed?.data?.nodeStatesTimed} + + {/key} +
+ + +
+{/if} diff --git a/web/frontend/src/Header.svelte b/web/frontend/src/Header.svelte index ea818c62..c173a9f4 100644 --- a/web/frontend/src/Header.svelte +++ b/web/frontend/src/Header.svelte @@ -53,7 +53,9 @@ const views = [ { title: "My Jobs", + // svelte-ignore state_referenced_locally requiredRole: roles.user, + // svelte-ignore state_referenced_locally href: `/monitoring/user/${username}`, icon: "bar-chart-line", perCluster: false, @@ -61,7 +63,9 @@ menu: "none", }, { + // svelte-ignore state_referenced_locally title: jobsTitle.get(authlevel), + // svelte-ignore state_referenced_locally requiredRole: roles.user, href: `/monitoring/jobs/`, icon: "card-list", @@ -71,6 +75,7 @@ }, { title: "Tags", + // svelte-ignore state_referenced_locally requiredRole: roles.user, href: "/monitoring/tags/", icon: "tags", @@ -79,7 +84,9 @@ menu: "Jobs", }, { + // svelte-ignore state_referenced_locally title: usersTitle.get(authlevel), + // svelte-ignore state_referenced_locally requiredRole: roles.manager, href: "/monitoring/users/", icon: "people", @@ -88,7 +95,9 @@ menu: "Groups", }, { + // svelte-ignore state_referenced_locally title: projectsTitle.get(authlevel), + // svelte-ignore state_referenced_locally requiredRole: roles.manager, href: "/monitoring/projects/", icon: "journals", @@ -98,6 +107,7 @@ }, { title: "Nodes", + // svelte-ignore state_referenced_locally requiredRole: roles.support, href: "/monitoring/systems/", icon: "hdd-rack", @@ -107,6 +117,7 @@ }, { title: "Analysis", + // svelte-ignore state_referenced_locally requiredRole: roles.support, href: "/monitoring/analysis/", icon: "graph-up", @@ -116,6 +127,7 @@ }, { title: "Status", + // svelte-ignore state_referenced_locally requiredRole: roles.admin, href: "/monitoring/status/", icon: "clipboard-data", @@ -217,7 +229,7 @@ {$initq.error.message}
{:else if $initq?.data} - + {#if showSummary} + + {/if} {:else} {/if} diff --git a/web/frontend/src/Jobs.root.svelte b/web/frontend/src/Jobs.root.svelte index 0198b236..2becea35 100644 --- a/web/frontend/src/Jobs.root.svelte +++ b/web/frontend/src/Jobs.root.svelte @@ -37,7 +37,6 @@ /* Const Init */ const { query: initq } = init(); const ccconfig = getContext("cc-config"); - const presetProject = filterPresets?.project ? filterPresets.project : "" /* State Init */ let filterComponent = $state(); // see why here: https://stackoverflow.com/questions/58287729/how-can-i-export-a-function-from-a-svelte-component-that-changes-a-value-in-the @@ -51,13 +50,18 @@ let showCompare = $state(false); let isMetricsSelectionOpen = $state(false); let sorting = $state({ field: "startTime", type: "col", order: "DESC" }); - let selectedCluster = $state(filterPresets?.cluster ? filterPresets.cluster : null); - let metrics = $state(filterPresets.cluster - ? ccconfig[`metricConfig_jobListMetrics:${filterPresets.cluster}`] || - ccconfig.metricConfig_jobListMetrics + + /* Derived */ + const presetProject = $derived(filterPresets?.project ? filterPresets.project : ""); + let selectedCluster = $derived(filterPresets?.cluster ? filterPresets.cluster : null); + let selectedSubCluster = $derived(filterPresets?.partition ? filterPresets.partition : null); + let metrics = $derived(filterPresets.cluster + ? filterPresets.partition + ? ccconfig[`metricConfig_jobListMetrics:${filterPresets.cluster}:${filterPresets.partition}`] + : ccconfig[`metricConfig_jobListMetrics:${filterPresets.cluster}`] || ccconfig.metricConfig_jobListMetrics : ccconfig.metricConfig_jobListMetrics ); - let showFootprint = $state(filterPresets.cluster + let showFootprint = $derived(filterPresets.cluster ? !!ccconfig[`jobList_showFootprint:${filterPresets.cluster}`] : !!ccconfig.jobList_showFootprint ); @@ -84,6 +88,11 @@ metrics = selectedCluster ? ccconfig[`metricConfig_jobListMetrics:${selectedCluster}`] : ccconfig.metricConfig_jobListMetrics }); + $effect(() => { + // Load Metric-Selection for last selected cluster + metrics = selectedSubCluster ? ccconfig[`metricConfig_jobListMetrics:${selectedCluster}:${selectedSubCluster}`] : ccconfig[`metricConfig_jobListMetrics:${selectedCluster}`] + }); + /* On Mount */ // The filterPresets are handled by the Filters component, // so we need to wait for it to be ready before we can start a query. @@ -132,6 +141,9 @@ selectedCluster = detail.filters[0]?.cluster ? detail.filters[0].cluster.eq : null; + selectedSubCluster = detail.filters[1]?.partition + ? detail.filters[1].partition.eq + : null; filterBuffer = [...detail.filters] if (showCompare) { jobCompare.queryJobs(detail.filters); @@ -218,6 +230,7 @@ bind:showFootprint presetMetrics={metrics} cluster={selectedCluster} + subCluster={selectedSubCluster} configName="metricConfig_jobListMetrics" footprintSelect applyMetrics={(newMetrics) => diff --git a/web/frontend/src/List.root.svelte b/web/frontend/src/List.root.svelte index ec8f654d..0163cbce 100644 --- a/web/frontend/src/List.root.svelte +++ b/web/frontend/src/List.root.svelte @@ -37,12 +37,6 @@ filterPresets } = $props(); - /* Validate Type */ - console.assert( - type == "USER" || type == "PROJECT", - "Invalid list type provided!", - ); - /* Const Init */ const {} = init(); const client = getContextClient(); diff --git a/web/frontend/src/Node.root.svelte b/web/frontend/src/Node.root.svelte index cf119026..c875f2b2 100644 --- a/web/frontend/src/Node.root.svelte +++ b/web/frontend/src/Node.root.svelte @@ -54,11 +54,6 @@ const nowEpoch = Date.now(); const paging = { itemsPerPage: 50, page: 1 }; const sorting = { field: "startTime", type: "col", order: "DESC" }; - const filter = [ - { cluster: { eq: cluster } }, - { node: { contains: hostname } }, - { state: ["running"] }, - ]; const client = getContextClient(); const nodeMetricsQuery = gql` query ($cluster: String!, $nodes: [String!], $from: Time!, $to: Time!) { @@ -111,11 +106,18 @@ } /* State Init */ + // svelte-ignore state_referenced_locally let from = $state(presetFrom ? presetFrom : new Date(nowEpoch - (4 * 3600 * 1000))); + // svelte-ignore state_referenced_locally let to = $state(presetTo ? presetTo : new Date(nowEpoch)); let systemUnits = $state({}); /* Derived */ + const filter = $derived([ + { cluster: { eq: cluster } }, + { node: { contains: hostname } }, + { state: ["running"] }, + ]); const nodeMetricsData = $derived(queryStore({ client: client, query: nodeMetricsQuery, diff --git a/web/frontend/src/Systems.root.svelte b/web/frontend/src/Systems.root.svelte index d81a64cd..5c62f390 100644 --- a/web/frontend/src/Systems.root.svelte +++ b/web/frontend/src/Systems.root.svelte @@ -51,7 +51,6 @@ /* Const Init */ const { query: initq } = init(); const client = getContextClient(); - const displayNodeOverview = (displayType === 'OVERVIEW'); const ccconfig = getContext("cc-config"); const initialized = getContext("initialized"); const globalMetrics = getContext("globalMetrics"); @@ -66,7 +65,9 @@ let timeoutId = null; /* State Init */ + // svelte-ignore state_referenced_locally let to = $state(presetTo || new Date(Date.now())); + // svelte-ignore state_referenced_locally let from = $state(presetFrom || new Date(nowDate.setHours(nowDate.getHours() - 4))); let selectedResolution = $state(resampleConfig ? resampleDefault : 0); let hostnameFilter = $state(""); @@ -75,6 +76,7 @@ let isMetricsSelectionOpen = $state(false); /* Derived States */ + const displayNodeOverview = $derived((displayType === 'OVERVIEW')); const systemMetrics = $derived($initialized ? [...globalMetrics.filter((gm) => gm?.availability.find((av) => av.cluster == cluster))] : []); const presetSystemUnits = $derived(loadUnits(systemMetrics)); let selectedMetric = $derived.by(() => { diff --git a/web/frontend/src/Tags.root.svelte b/web/frontend/src/Tags.root.svelte index 21dbcf47..b8a8abf8 100644 --- a/web/frontend/src/Tags.root.svelte +++ b/web/frontend/src/Tags.root.svelte @@ -37,6 +37,7 @@ /* State Init */ let pendingChange = $state("none"); + // svelte-ignore state_referenced_locally let tagmap = $state(presetTagmap); /* Functions */ diff --git a/web/frontend/src/User.root.svelte b/web/frontend/src/User.root.svelte index 3c599272..96ee4704 100644 --- a/web/frontend/src/User.root.svelte +++ b/web/frontend/src/User.root.svelte @@ -64,17 +64,7 @@ let isSortingOpen = $state(false); let isMetricsSelectionOpen = $state(false); let sorting = $state({ field: "startTime", type: "col", order: "DESC" }); - let selectedCluster = $state(filterPresets?.cluster ? filterPresets.cluster : null); let selectedHistogramsBuffer = $state({ all: (ccconfig['userView_histogramMetrics'] || []) }) - let metrics = $state(filterPresets.cluster - ? ccconfig[`metricConfig_jobListMetrics:${filterPresets.cluster}`] || - ccconfig.metricConfig_jobListMetrics - : ccconfig.metricConfig_jobListMetrics - ); - let showFootprint = $state(filterPresets.cluster - ? !!ccconfig[`jobList_showFootprint:${filterPresets.cluster}`] - : !!ccconfig.jobList_showFootprint - ); // Histogram Vars let isHistogramSelectionOpen = $state(false); @@ -88,7 +78,17 @@ // let filterBuffer = $state([]); // let matchedCompareJobs = $state(0); - /* Derived Vars */ + /* Derived */ + let selectedCluster = $derived(filterPresets?.cluster ? filterPresets.cluster : null); + let metrics = $derived(filterPresets.cluster + ? ccconfig[`metricConfig_jobListMetrics:${filterPresets.cluster}`] || + ccconfig.metricConfig_jobListMetrics + : ccconfig.metricConfig_jobListMetrics + ); + let showFootprint = $derived(filterPresets.cluster + ? !!ccconfig[`jobList_showFootprint:${filterPresets.cluster}`] + : !!ccconfig.jobList_showFootprint + ); let selectedHistograms = $derived(selectedCluster ? selectedHistogramsBuffer[selectedCluster] : selectedHistogramsBuffer['all']); let stats = $derived( queryStore({ diff --git a/web/frontend/src/analysis/PlotSelection.svelte b/web/frontend/src/analysis/PlotSelection.svelte index ab4eda85..386d2f97 100644 --- a/web/frontend/src/analysis/PlotSelection.svelte +++ b/web/frontend/src/analysis/PlotSelection.svelte @@ -49,11 +49,13 @@ /* State Init */ let isHistogramConfigOpen = $state(false); let isScatterPlotConfigOpen = $state(false); - let metricsInHistograms = $state(presetMetricsInHistograms); - let metricsInScatterplots = $state(presetMetricsInScatterplots); let selectedMetric1 = $state(null); let selectedMetric2 = $state(null); + /* Derived */ + let metricsInHistograms = $derived(presetMetricsInHistograms); + let metricsInScatterplots = $derived(presetMetricsInScatterplots); + /* Functions */ function updateConfiguration(data) { updateConfigurationMutation({ diff --git a/web/frontend/src/config/user/PlotColorScheme.svelte b/web/frontend/src/config/user/PlotColorScheme.svelte index 6b24fd43..cfb2b5b4 100644 --- a/web/frontend/src/config/user/PlotColorScheme.svelte +++ b/web/frontend/src/config/user/PlotColorScheme.svelte @@ -28,8 +28,8 @@ updateSetting } = $props(); - /* State Init */ - let activeRow = $state(JSON.stringify(config?.plotConfiguration_colorScheme)); + /* Derived */ + let activeRow = $derived(JSON.stringify(config?.plotConfiguration_colorScheme)); /* Const Init */ const colorSchemes = { diff --git a/web/frontend/src/generic/Filters.svelte b/web/frontend/src/generic/Filters.svelte index 74417015..8cb6e88a 100644 --- a/web/frontend/src/generic/Filters.svelte +++ b/web/frontend/src/generic/Filters.svelte @@ -89,6 +89,7 @@ }; /* State Init */ + // svelte-ignore state_referenced_locally let filters = $state({ dbId: filterPresets.dbId || [], jobId: filterPresets.jobId || "", diff --git a/web/frontend/src/generic/JobCompare.svelte b/web/frontend/src/generic/JobCompare.svelte index dea324fc..d5283a9a 100644 --- a/web/frontend/src/generic/JobCompare.svelte +++ b/web/frontend/src/generic/JobCompare.svelte @@ -64,10 +64,10 @@ let plotSync = uPlot.sync("compareJobsView"); /* State Init */ - let filter = $state([...filterBuffer] || []); let tableJobIDFilter = $state(""); /* Derived*/ + let filter = $derived([...filterBuffer] || []); const compareData = $derived(queryStore({ client: client, query: compareQuery, diff --git a/web/frontend/src/generic/JobList.svelte b/web/frontend/src/generic/JobList.svelte index b636e6e2..c08a5400 100644 --- a/web/frontend/src/generic/JobList.svelte +++ b/web/frontend/src/generic/JobList.svelte @@ -99,13 +99,13 @@ /* State Init */ let headerPaddingTop = $state(0); let jobs = $state([]); - let filter = $state([...filterBuffer]); let page = $state(1); let itemsPerPage = $state(usePaging ? (ccconfig?.jobList_jobsPerPage || 10) : 10); let triggerMetricRefresh = $state(false); let tableWidth = $state(0); /* Derived */ + let filter = $derived([...filterBuffer]); let paging = $derived({ itemsPerPage, page }); const plotWidth = $derived.by(() => { return Math.floor( diff --git a/web/frontend/src/generic/filters/Cluster.svelte b/web/frontend/src/generic/filters/Cluster.svelte index 2d35e84d..562ee879 100644 --- a/web/frontend/src/generic/filters/Cluster.svelte +++ b/web/frontend/src/generic/filters/Cluster.svelte @@ -34,9 +34,9 @@ const clusters = getContext("clusters"); const initialized = getContext("initialized"); - /* State Init */ - let pendingCluster = $state(presetCluster); - let pendingPartition = $state(presetPartition); + /* Derived */ + let pendingCluster = $derived(presetCluster); + let pendingPartition = $derived(presetPartition); (isOpen = !isOpen)}> diff --git a/web/frontend/src/generic/filters/Duration.svelte b/web/frontend/src/generic/filters/Duration.svelte index 3056d9f9..34f7afb2 100644 --- a/web/frontend/src/generic/filters/Duration.svelte +++ b/web/frontend/src/generic/filters/Duration.svelte @@ -31,14 +31,13 @@ setFilter } = $props(); - /* State Init */ - let pendingDuration = $state(presetDuration); - let lessState = $state(secsToHoursAndMins(presetDuration?.lessThan)); - let moreState = $state(secsToHoursAndMins(presetDuration?.moreThan)); - let fromState = $state(secsToHoursAndMins(presetDuration?.from)); - let toState = $state(secsToHoursAndMins(presetDuration?.to)); + /* Derived */ + let pendingDuration = $derived(presetDuration); + let lessState = $derived(secsToHoursAndMins(presetDuration?.lessThan)); + let moreState = $derived(secsToHoursAndMins(presetDuration?.moreThan)); + let fromState = $derived(secsToHoursAndMins(presetDuration?.from)); + let toState = $derived(secsToHoursAndMins(presetDuration?.to)); - /* Derived Init */ const lessDisabled = $derived( moreState.hours !== 0 || moreState.mins !== 0 || diff --git a/web/frontend/src/generic/filters/Energy.svelte b/web/frontend/src/generic/filters/Energy.svelte index 0a5a8362..4d542add 100644 --- a/web/frontend/src/generic/filters/Energy.svelte +++ b/web/frontend/src/generic/filters/Energy.svelte @@ -28,8 +28,8 @@ setFilter, } = $props(); - /* State Init */ - let energyState = $state(presetEnergy); + /* Derived */ + let energyState = $derived(presetEnergy); (isOpen = !isOpen)}> diff --git a/web/frontend/src/generic/filters/JobStates.svelte b/web/frontend/src/generic/filters/JobStates.svelte index dc622a20..acddef5c 100644 --- a/web/frontend/src/generic/filters/JobStates.svelte +++ b/web/frontend/src/generic/filters/JobStates.svelte @@ -67,10 +67,10 @@ "single_user", ]; - /* State Init */ - let pendingStates = $state([...presetStates]); - let pendingShared = $state(presetShared); - let pendingSchedule = $state(presetSchedule); + /* Derived */ + let pendingStates = $derived([...presetStates]); + let pendingShared = $derived(presetShared); + let pendingSchedule = $derived(presetSchedule); diff --git a/web/frontend/src/generic/filters/Resources.svelte b/web/frontend/src/generic/filters/Resources.svelte index 49a28f8c..aa215025 100644 --- a/web/frontend/src/generic/filters/Resources.svelte +++ b/web/frontend/src/generic/filters/Resources.svelte @@ -79,18 +79,19 @@ let maxNumNodes = $state(128); let maxNumHWThreads = $state(0); let maxNumAccelerators = $state(0); - // Pending - let pendingNumNodes = $state(presetNumNodes); - let pendingNumHWThreads = $state(presetNumHWThreads); - let pendingNumAccelerators = $state(presetNumAccelerators); - let pendingNamedNode = $state(presetNamedNode); - let pendingNodeMatch = $state(presetNodeMatch); - // Changable States - let nodesState = $state(presetNumNodes); - let threadState = $state(presetNumHWThreads); - let accState = $state(presetNumAccelerators); /* Derived States */ + // Pending + let pendingNumNodes = $derived(presetNumNodes); + let pendingNumHWThreads = $derived(presetNumHWThreads); + let pendingNumAccelerators = $derived(presetNumAccelerators); + let pendingNamedNode = $derived(presetNamedNode); + let pendingNodeMatch = $derived(presetNodeMatch); + // Changable States + let nodesState = $derived(presetNumNodes); + let threadState = $derived(presetNumHWThreads); + let accState = $derived(presetNumAccelerators); + const clusters = $derived(getContext("clusters")); const initialized = $derived(getContext("initialized")); // Is Selection Active diff --git a/web/frontend/src/generic/filters/StartTime.svelte b/web/frontend/src/generic/filters/StartTime.svelte index c35acb2a..5d9340e3 100644 --- a/web/frontend/src/generic/filters/StartTime.svelte +++ b/web/frontend/src/generic/filters/StartTime.svelte @@ -48,12 +48,11 @@ const resetFrom = { date: format(ago, "yyyy-MM-dd"), time: format(ago, "HH:mm")}; const resetTo = { date: format(now, "yyyy-MM-dd"), time: format(now, "HH:mm")}; - /* State Init */ - let pendingStartTime = $state(presetStartTime); - let fromState = $state(fromRFC3339(presetStartTime?.from, resetFrom)); - let toState = $state(fromRFC3339(presetStartTime?.to, resetTo)); + /* Derived */ + let pendingStartTime = $derived(presetStartTime); + let fromState = $derived(fromRFC3339(presetStartTime?.from, resetFrom)); + let toState = $derived(fromRFC3339(presetStartTime?.to, resetTo)); - /* Derived Init*/ const rangeSelect = $derived(pendingStartTime?.range ? pendingStartTime.range : "") /* Functions */ diff --git a/web/frontend/src/generic/filters/Tags.svelte b/web/frontend/src/generic/filters/Tags.svelte index 9d726e51..39c2b036 100644 --- a/web/frontend/src/generic/filters/Tags.svelte +++ b/web/frontend/src/generic/filters/Tags.svelte @@ -35,8 +35,11 @@ const initialized = $derived(getContext("initialized")) /* State Init */ - let pendingTags = $state(presetTags); let searchTerm = $state(""); + + /* Derived */ + let pendingTags = $derived(presetTags); + (isOpen = !isOpen)}> diff --git a/web/frontend/src/generic/helper/Refresher.svelte b/web/frontend/src/generic/helper/Refresher.svelte index ca05bf6d..7c477440 100644 --- a/web/frontend/src/generic/helper/Refresher.svelte +++ b/web/frontend/src/generic/helper/Refresher.svelte @@ -18,12 +18,12 @@ onRefresh } = $props(); - /* State Init */ - let refreshInterval = $state(initially ? initially * 1000 : null); - /* Var Init */ let refreshIntervalId = null; + /* Derived */ + let refreshInterval = $derived(initially ? initially * 1000 : null); + /* Functions */ function refreshIntervalChanged() { if (refreshIntervalId != null) clearInterval(refreshIntervalId); diff --git a/web/frontend/src/generic/helper/TagManagement.svelte b/web/frontend/src/generic/helper/TagManagement.svelte index 8d0d5a82..53e80537 100644 --- a/web/frontend/src/generic/helper/TagManagement.svelte +++ b/web/frontend/src/generic/helper/TagManagement.svelte @@ -45,8 +45,6 @@ } = $props(); /* Const Init */ - const isAdmin = (roles && authlevel == roles.admin); - const isSupport = (roles && authlevel == roles.support); const client = getContextClient(); /* State Init */ @@ -54,12 +52,14 @@ let allTags = getContext("tags") let newTagType = $state(""); let newTagName = $state(""); - let newTagScope = $state(username); let filterTerm = $state(""); let pendingChange = $state(false); let isOpen = $state(false); - /* Derived Init */ + /* Derived */ + let newTagScope = $derived(username); + const isAdmin = $derived((roles && authlevel == roles.admin)); + const isSupport = $derived((roles && authlevel == roles.support)); const allTagsFiltered = $derived(($initialized, jobTags, fuzzySearchTags(filterTerm, allTags))); // $init und JobTags only for triggering react const usedTagsFiltered = $derived(matchJobTags(jobTags, allTagsFiltered, 'used', isAdmin, isSupport)); const unusedTagsFiltered = $derived(matchJobTags(jobTags, allTagsFiltered, 'unused', isAdmin, isSupport)); diff --git a/web/frontend/src/generic/helper/TextFilter.svelte b/web/frontend/src/generic/helper/TextFilter.svelte index 020b7999..c38fe33d 100644 --- a/web/frontend/src/generic/helper/TextFilter.svelte +++ b/web/frontend/src/generic/helper/TextFilter.svelte @@ -26,14 +26,16 @@ /* Var Init */ let user = ""; - let project = presetProject ? presetProject : ""; let jobName = ""; let timeoutId = null; /* State Init */ - let mode = $state(presetProject ? "jobName" : "project"); let term = $state(""); + /* Derived */ + let project = $derived(presetProject ? presetProject : ""); + let mode = $derived(presetProject ? "jobName" : "project"); + /* Functions */ function modeChanged() { if (mode == "user") { diff --git a/web/frontend/src/generic/joblist/JobListRow.svelte b/web/frontend/src/generic/joblist/JobListRow.svelte index 8019b967..4604883a 100644 --- a/web/frontend/src/generic/joblist/JobListRow.svelte +++ b/web/frontend/src/generic/joblist/JobListRow.svelte @@ -37,13 +37,7 @@ /* Const Init */ const client = getContextClient(); - const jobId = job.id; const cluster = getContext("clusters"); - const scopes = (job.numNodes == 1) - ? (job.numAcc >= 1) - ? ["core", "accelerator"] - : ["core"] - : ["node"]; const resampleConfig = getContext("resampling") || null; const resampleDefault = resampleConfig ? Math.max(...resampleConfig.resolutions) : 0; const query = gql` @@ -84,6 +78,15 @@ let thresholdStates = $state({}); /* Derived */ + const jobId = $derived(job?.id); + const scopes = $derived.by(() => { + if (job.numNodes == 1) { + if (job.numAcc >= 1) return ["core", "accelerator"]; + else return ["core"]; + } else { + return ["node"]; + }; + }); let isSelected = $derived(previousSelect); let metricsQuery = $derived(queryStore({ client: client, diff --git a/web/frontend/src/generic/plots/Comparogram.svelte b/web/frontend/src/generic/plots/Comparogram.svelte index b391a1f5..b1835c2c 100644 --- a/web/frontend/src/generic/plots/Comparogram.svelte +++ b/web/frontend/src/generic/plots/Comparogram.svelte @@ -73,6 +73,7 @@ ] // UPLOT SCALES INIT // + // svelte-ignore state_referenced_locally if (forResources) { const resSeries = [ { @@ -136,8 +137,11 @@ // UPLOT OPTIONS // const opts = { + // svelte-ignore state_referenced_locally width, + // svelte-ignore state_referenced_locally height, + // svelte-ignore state_referenced_locally title, plugins: [legendAsTooltipPlugin()], series: plotSeries, @@ -147,6 +151,7 @@ space: 25, // Tick Spacing rotate: 30, show: true, + // svelte-ignore state_referenced_locally label: xlabel, values(self, splits) { return splits.map(s => xticks[s]); @@ -164,16 +169,19 @@ scale: "y", grid: { show: true }, labelFont: "sans-serif", + // svelte-ignore state_referenced_locally label: ylabel + (yunit ? ` (${yunit})` : ''), values: (u, vals) => vals.map((v) => formatNumber(v)), }, ], + // svelte-ignore state_referenced_locally bands: forResources ? [] : plotBands, padding: [5, 10, 0, 0], hooks: { draw: [ (u) => { // Draw plot type label: + // svelte-ignore state_referenced_locally let textl = forResources ? "Job Resources by Type" : "Metric Min/Avg/Max for Job Duration"; let textr = "Earlier <- StartTime -> Later"; u.ctx.save(); @@ -196,6 +204,7 @@ x: { time: false }, xst: { time: false }, xrt: { time: false }, + // svelte-ignore state_referenced_locally y: {auto: true, distr: forResources ? 3 : 1}, }, legend: { diff --git a/web/frontend/src/generic/plots/DoubleMetricPlot.svelte b/web/frontend/src/generic/plots/DoubleMetricPlot.svelte index 9579f36b..c865dd68 100644 --- a/web/frontend/src/generic/plots/DoubleMetricPlot.svelte +++ b/web/frontend/src/generic/plots/DoubleMetricPlot.svelte @@ -4,123 +4,56 @@ Only width/height should change reactively. Properties: - - `metric String`: The metric name - - `scope String?`: Scope of the displayed data [Default: node] + - `metricData [Data]`: Two series of metric data including unit info + - `timestep Number`: Data timestep + - `numNodes Number`: Number of nodes from which metric data is aggregated + - `cluster String`: Cluster name of the parent job / data [Default: ""] + - `forNode Bool?`: If this plot is used for node data display; will render x-axis as negative time with $now as maximum [Default: true] + - `enableFlip Bool?`: Whether to use legend tooltip flipping based on canvas size [Default: false] + - `publicMode Bool?`: Disables tooltip legend and enables larger colored axis labels [Default: false] - `height Number?`: The plot height [Default: 300] - - `timestep Number`: The timestep used for X-axis rendering - - `series [GraphQL.Series]`: The metric data object - - `statisticsSeries [GraphQL.StatisticsSeries]?`: Min/Max/Median representation of metric data [Default: null] - - `cluster String?`: Cluster name of the parent job / data [Default: ""] - - `subCluster String`: Name of the subCluster of the parent job - - `isShared Bool?`: If this job used shared resources; for additional legend display [Default: false] - - `forNode Bool?`: If this plot is used for node data display; will render x-axis as negative time with $now as maximum [Default: false] - - `numhwthreads Number?`: Number of job HWThreads [Default: 0] - - `numaccs Number?`: Number of job Accelerators [Default: 0] - - `zoomState Object?`: The last zoom state to preserve on user zoom [Default: null] - - `thersholdState Object?`: The last threshold state to preserve on user zoom [Default: null] - - `extendedLegendData Object?`: Additional information to be rendered in an extended legend [Default: null] - - `onZoom Func`: Callback function to handle zoom-in event --> -
+
@@ -143,5 +148,7 @@ .chart-container { margin: auto; position: relative; + height: var(--container-height); + width: var(--container-width); } \ No newline at end of file diff --git a/web/frontend/src/generic/plots/Roofline.svelte b/web/frontend/src/generic/plots/Roofline.svelte index 7b7df31b..20c91872 100644 --- a/web/frontend/src/generic/plots/Roofline.svelte +++ b/web/frontend/src/generic/plots/Roofline.svelte @@ -40,8 +40,7 @@ useColors = true, useLegend = true, colorBackground = false, - width = 600, - height = 380, + height = 300, } = $props(); /* Const Init */ @@ -53,11 +52,12 @@ /* State Init */ let plotWrapper = $state(null); + let width = $state(0); // Wrapper Width let uplot = $state(null); /* Effect */ $effect(() => { - if (allowSizeChange) sizeChanged(width, height); + if (allowSizeChange) onSizeChange(width, height); }); // Copied Example Vars for Uplot Bubble @@ -517,11 +517,11 @@ } // Main Functions - function sizeChanged() { + function onSizeChange() { if (timeoutId != null) clearTimeout(timeoutId); timeoutId = setTimeout(() => { timeoutId = null; - if (uplot) uplot.destroy(); + if (uplot) uplot.destroy(); // Prevents Multi-Render render(roofData, jobsData, nodesData); }, 200); } @@ -995,7 +995,7 @@ {#if roofData != null} -
+
{:else} Cannot render roofline: No data! {/if} diff --git a/web/frontend/src/generic/plots/RooflineHeatmap.svelte b/web/frontend/src/generic/plots/RooflineHeatmap.svelte index ff2ba84d..0fd0f8e2 100644 --- a/web/frontend/src/generic/plots/RooflineHeatmap.svelte +++ b/web/frontend/src/generic/plots/RooflineHeatmap.svelte @@ -23,9 +23,6 @@ height = 300, } = $props(); - /* Check Before */ - console.assert(tiles, "you must provide tiles!") - /* Const Init */ const axesColor = '#aaaaaa'; const tickFontSize = 10; @@ -42,10 +39,10 @@ /* State Init */ let ctx = $state(); let canvasElement = $state(); - let prevWidth = $state(width); - let prevHeight = $state(height); /* Derived */ + let prevWidth = $derived(width); + let prevHeight = $derived(height); const data = $derived({ tiles: tiles, xLabel: 'Intensity [FLOPS/byte]', diff --git a/web/frontend/src/generic/plots/Stacked.svelte b/web/frontend/src/generic/plots/Stacked.svelte index 06dd83e4..1dc57945 100644 --- a/web/frontend/src/generic/plots/Stacked.svelte +++ b/web/frontend/src/generic/plots/Stacked.svelte @@ -2,7 +2,6 @@ @component Node State/Health Data Stacked Plot Component, based on uPlot; states by timestamp Properties: - - `width Number?`: The plot width [Default: 0] - `height Number?`: The plot height [Default: 300] - `data [Array]`: The data object [Default: null] - `xlabel String?`: Plot X axis label [Default: ""] @@ -15,12 +14,11 @@ - - - {#if showFootprintTab} - + + {#if showFootprintTab && !showPolarTab} + + {:else if !showFootprintTab && showPolarTab} + + {:else if showFootprintTab && showPolarTab} + + - {/if} - {#if showPolarTab} - + - + - {/if} - + + {:else} + + + Config + + +

Footprint and PolarPlot Display Disabled.

+
+
+ {/if}
diff --git a/web/frontend/src/job/Metric.svelte b/web/frontend/src/job/Metric.svelte index 886b4839..ca32d9f9 100644 --- a/web/frontend/src/job/Metric.svelte +++ b/web/frontend/src/job/Metric.svelte @@ -45,7 +45,6 @@ const statsPattern = /(.*)-stat$/; const resampleConfig = getContext("resampling") || null; const resampleDefault = resampleConfig ? Math.max(...resampleConfig.resolutions) : 0; - const unit = (metricUnit?.prefix ? metricUnit.prefix : "") + (metricUnit?.base ? metricUnit.base : ""); const subQuery = gql` query ($dbid: ID!, $selectedMetrics: [String!]!, $selectedScopes: [MetricScope!]!, $selectedResolution: Int) { singleUpdate: jobMetrics(id: $dbid, metrics: $selectedMetrics, scopes: $selectedScopes, resolution: $selectedResolution) { @@ -79,14 +78,14 @@ `; /* State Init */ - let requestedScopes = $state(presetScopes); let selectedResolution = $state(resampleDefault); - let selectedHost = $state(null); let zoomState = $state(null); let thresholdState = $state(null); /* Derived */ + let requestedScopes = $derived(presetScopes); + const unit = $derived.by(() => { return (metricUnit?.prefix ? metricUnit.prefix : "") + (metricUnit?.base ? metricUnit.base : "")}); const metricData = $derived(queryStore({ client: client, query: subQuery, diff --git a/web/frontend/src/job/jobsummary/JobFootprintBars.svelte b/web/frontend/src/job/jobsummary/JobFootprintBars.svelte index ac92be2e..34518112 100644 --- a/web/frontend/src/job/jobsummary/JobFootprintBars.svelte +++ b/web/frontend/src/job/jobsummary/JobFootprintBars.svelte @@ -127,7 +127,7 @@ } - + {#if jobFootprintData.length === 0}
No footprint data for job available.
{:else} diff --git a/web/frontend/src/job/jobsummary/JobFootprintPolar.svelte b/web/frontend/src/job/jobsummary/JobFootprintPolar.svelte index 73df99f9..80611838 100644 --- a/web/frontend/src/job/jobsummary/JobFootprintPolar.svelte +++ b/web/frontend/src/job/jobsummary/JobFootprintPolar.svelte @@ -3,6 +3,7 @@ Properties: - `job Object`: The GQL job object + - `showLegend Bool?`: Polar Legend Display --> - + {#if $polarData.fetching} {:else if $polarData.error} @@ -71,6 +73,7 @@ {:else} {/if} diff --git a/web/frontend/src/job/statstab/StatsTable.svelte b/web/frontend/src/job/statstab/StatsTable.svelte index 9430fbd5..06b2d105 100644 --- a/web/frontend/src/job/statstab/StatsTable.svelte +++ b/web/frontend/src/job/statstab/StatsTable.svelte @@ -25,9 +25,13 @@ } = $props(); /* State Init */ + // svelte-ignore state_referenced_locally let sortedHosts = $state(hosts); + // svelte-ignore state_referenced_locally let sorting = $state(setupSorting(selectedMetrics)); + // svelte-ignore state_referenced_locally let availableScopes = $state(setupAvailable(jobStats)); + // svelte-ignore state_referenced_locally let selectedScopes = $state(setupSelected(availableScopes)); /* Derived Init */ diff --git a/web/frontend/src/status/DashInternal.svelte b/web/frontend/src/status/DashInternal.svelte index 40ef4d91..87e3c6b3 100644 --- a/web/frontend/src/status/DashInternal.svelte +++ b/web/frontend/src/status/DashInternal.svelte @@ -58,10 +58,6 @@ let to = $state(new Date(Date.now())); let stackedFrom = $state(Math.floor(Date.now() / 1000) - 14400); let colWidthJobs = $state(0); - let colWidthRoof = $state(0); - let colWidthTotals =$state(0); - let colWidthStacked1 = $state(0); - let colWidthStacked2 = $state(0); /* Derived */ // States for Stacked charts @@ -277,6 +273,11 @@ } } + // Get Idle Infos after Sums + if (!rawInfos['idleNodes']) rawInfos['idleNodes'] = rawInfos['totalNodes'] - rawInfos['allocatedNodes']; + if (!rawInfos['idleCores']) rawInfos['idleCores'] = rawInfos['totalCores'] - rawInfos['allocatedCores']; + if (!rawInfos['idleAccs']) rawInfos['idleAccs'] = rawInfos['totalAccs'] - rawInfos['allocatedAccs']; + // Keymetrics (Data on Cluster-Scope) let rawFlops = $statusQuery?.data?.nodeMetrics?.reduce((sum, node) => sum + (node.metrics.find((m) => m.name == 'flops_any')?.metric?.series[0]?.statistics?.avg || 0), @@ -435,50 +436,41 @@
- Allocated Nodes + {formatNumber(clusterInfo?.allocatedNodes)} Active Nodes
- + + + +
- {clusterInfo?.allocatedNodes} / {clusterInfo?.totalNodes} - Nodes + {formatNumber(clusterInfo?.idleNodes)} Idle Nodes - Allocated Cores + {formatNumber(clusterInfo?.allocatedCores)} Active Cores
- + + + +
- {formatNumber(clusterInfo?.allocatedCores)} / {formatNumber(clusterInfo?.totalCores)} - Cores + {formatNumber(clusterInfo?.idleCores)} Idle Cores {#if clusterInfo?.totalAccs !== 0} - Allocated Accelerators + {formatNumber(clusterInfo?.allocatedAccs)} Active Accelerators
- + + + +
- {clusterInfo?.allocatedAccs} / {clusterInfo?.totalAccs} - Accelerators + {formatNumber(clusterInfo?.idleAccs)} Idle Accelerators {/if} @@ -487,7 +479,7 @@ - {#if topJobsQuery?.data?.jobsStatistics?.length > 0} + {#if $topJobsQuery?.data?.jobsStatistics?.length > 0}
@@ -529,19 +521,17 @@ {:else} Cannot render job status: No state data returned for Pie ChartCannot render jobs by project: No data returned {/if} -
+
{#key $statusQuery?.data?.jobsMetricStats} -
- +
+ {#key $statusQuery?.data?.clusterMetrics} + + {/key}
-
+
{#key $statesTimed?.data?.nodeStates} -
+
{#key $statesTimed?.data?.healthStates} -
+
{#key $statesTimed?.data?.nodeStates}

{cluster.charAt(0).toUpperCase() + cluster.slice(1)} Node States Over Time

-
+
{#key $statesTimed?.data?.healthStates}

{cluster.charAt(0).toUpperCase() + cluster.slice(1)} Health States Over Time

-
+
{#key $statusQuery?.data?.nodeMetrics} -
+
{#key $statusQuery?.data?.jobsMetricStats} data.subCluster == subCluster.name, diff --git a/web/frontend/src/status/dashdetails/UsageDash.svelte b/web/frontend/src/status/dashdetails/UsageDash.svelte index 2071465a..928ef957 100644 --- a/web/frontend/src/status/dashdetails/UsageDash.svelte +++ b/web/frontend/src/status/dashdetails/UsageDash.svelte @@ -44,7 +44,6 @@ const durationBinOptions = ["1m","10m","1h","6h","12h"]; /* State Init */ - let cluster = $state(presetCluster) let pagingState = $state({page: 1, itemsPerPage: 10}) // Top 10 let selectedHistograms = $state([]) // Dummy For Refresh let colWidthJobs = $state(0); @@ -53,6 +52,7 @@ let numDurationBins = $state("1h"); /* Derived */ + let cluster = $derived(presetCluster) const topJobsQuery = $derived(queryStore({ client: client, query: gql` diff --git a/web/frontend/src/systems/NodeList.svelte b/web/frontend/src/systems/NodeList.svelte index fa758c18..f512a6ea 100644 --- a/web/frontend/src/systems/NodeList.svelte +++ b/web/frontend/src/systems/NodeList.svelte @@ -38,7 +38,6 @@ /* Const Init */ const client = getContextClient(); - const usePaging = ccconfig?.nodeList_usePaging || false; const nodeListQuery = gql` query ($cluster: String!, $subCluster: String!, $nodeFilter: String!, $stateFilter: String!, $metrics: [String!], $scopes: [MetricScope!]!, $from: Time!, $to: Time!, $paging: PageRequest!, $selectedResolution: Int @@ -94,13 +93,14 @@ /* State Init */ let nodes = $state([]); - let selectedMetrics = $state(pendingSelectedMetrics); let page = $state(1); - let itemsPerPage = $state(usePaging ? (ccconfig?.nodeList_nodesPerPage || 10) : 10); let headerPaddingTop = $state(0); let matchedNodes = $state(0); /* Derived */ + let selectedMetrics = $derived(pendingSelectedMetrics); + let itemsPerPage = $derived(usePaging ? (ccconfig?.nodeList_nodesPerPage || 10) : 10); + const usePaging = $derived(ccconfig?.nodeList_usePaging || false); const paging = $derived({ itemsPerPage, page }); const nodesQuery = $derived(queryStore({ client: client, diff --git a/web/frontend/src/systems/nodelist/NodeInfo.svelte b/web/frontend/src/systems/nodelist/NodeInfo.svelte index 6d1c5266..de5ca14a 100644 --- a/web/frontend/src/systems/nodelist/NodeInfo.svelte +++ b/web/frontend/src/systems/nodelist/NodeInfo.svelte @@ -38,10 +38,6 @@ } = $props(); /* Const Init */ - // Not at least one returned, selected metric: NodeHealth warning - const healthWarn = !dataHealth.includes(true); - // At least one non-returned selected metric: Metric config error? - const metricWarn = dataHealth.includes(false); // Node State Colors const stateColors = { allocated: 'success', @@ -54,6 +50,10 @@ } /* Derived */ + // Not at least one returned, selected metric: NodeHealth warning + const healthWarn = $derived(!dataHealth.includes(true)); + // At least one non-returned selected metric: Metric config error? + const metricWarn = $derived(dataHealth.includes(false)); const userList = $derived(nodeJobsData ? Array.from(new Set(nodeJobsData.jobs.items.map((j) => scrambleNames ? scramble(j.user) : j.user))).sort((a, b) => a.localeCompare(b)) : [] diff --git a/web/frontend/src/systems/nodelist/NodeListRow.svelte b/web/frontend/src/systems/nodelist/NodeListRow.svelte index e7e095ea..0b34fbad 100644 --- a/web/frontend/src/systems/nodelist/NodeListRow.svelte +++ b/web/frontend/src/systems/nodelist/NodeListRow.svelte @@ -27,17 +27,13 @@ } = $props(); /* Var Init*/ + // svelte-ignore state_referenced_locally let plotSync = uPlot.sync(`nodeMetricStack-${nodeData.host}`); /* Const Init */ const client = getContextClient(); const paging = { itemsPerPage: 50, page: 1 }; const sorting = { field: "startTime", type: "col", order: "DESC" }; - const filter = [ - { cluster: { eq: cluster } }, - { node: { contains: nodeData.host } }, - { state: ["running"] }, - ]; const nodeJobsQuery = gql` query ( $filter: [JobFilter!]! @@ -61,6 +57,11 @@ `; /* Derived */ + const filter = $derived([ + { cluster: { eq: cluster } }, + { node: { contains: nodeData.host } }, + { state: ["running"] }, + ]); const nodeJobsData = $derived(queryStore({ client: client, query: nodeJobsQuery, @@ -70,7 +71,7 @@ let extendedLegendData = $derived($nodeJobsData?.data ? buildExtendedLegend() : null); let refinedData = $derived(nodeData?.metrics ? sortAndSelectScope(nodeData.metrics) : null); - let dataHealth = $derived(refinedData.filter((rd) => rd.disabled === false).map((enabled) => (enabled.data.metric.series.length > 0))); + let dataHealth = $derived(refinedData.filter((rd) => rd.disabled === false).map((enabled) => (enabled?.data?.metric?.series?.length > 0))); /* Functions */ const selectScope = (nodeMetrics) => diff --git a/web/web.go b/web/web.go index 66df18b5..32d70a45 100644 --- a/web/web.go +++ b/web/web.go @@ -22,56 +22,56 @@ import ( ) type WebConfig struct { - JobList JobListConfig `json:"jobList"` - NodeList NodeListConfig `json:"nodeList"` - JobView JobViewConfig `json:"jobView"` - MetricConfig MetricConfig `json:"metricConfig"` - PlotConfiguration PlotConfiguration `json:"plotConfiguration"` + JobList JobListConfig `json:"job-list"` + NodeList NodeListConfig `json:"node-list"` + JobView JobViewConfig `json:"job-view"` + MetricConfig MetricConfig `json:"metric-config"` + PlotConfiguration PlotConfiguration `json:"plot-configuration"` } type JobListConfig struct { - UsePaging bool `json:"usePaging"` - ShowFootprint bool `json:"showFootprint"` + UsePaging bool `json:"use-paging"` + ShowFootprint bool `json:"show-footprint"` } type NodeListConfig struct { - UsePaging bool `json:"usePaging"` + UsePaging bool `json:"use-paging"` } type JobViewConfig struct { - ShowPolarPlot bool `json:"showPolarPlot"` - ShowFootprint bool `json:"showFootprint"` - ShowRoofline bool `json:"showRoofline"` - ShowStatTable bool `json:"showStatTable"` + ShowPolarPlot bool `json:"show-polar-plot"` + ShowFootprint bool `json:"show-footprint"` + ShowRoofline bool `json:"show-roofline"` + ShowStatTable bool `json:"show-stat-table"` } type MetricConfig struct { - JobListMetrics []string `json:"jobListMetrics"` - JobViewPlotMetrics []string `json:"jobViewPlotMetrics"` - JobViewTableMetrics []string `json:"jobViewTableMetrics"` + JobListMetrics []string `json:"job-list-metrics"` + JobViewPlotMetrics []string `json:"job-view-plot-metrics"` + JobViewTableMetrics []string `json:"job-view-table-metrics"` Clusters []ClusterConfig `json:"clusters"` } type ClusterConfig struct { Name string `json:"name"` - JobListMetrics []string `json:"jobListMetrics"` - JobViewPlotMetrics []string `json:"jobViewPlotMetrics"` - JobViewTableMetrics []string `json:"jobViewTableMetrics"` - SubClusters []SubClusterConfig `json:"subClusters"` + JobListMetrics []string `json:"job-list-metrics"` + JobViewPlotMetrics []string `json:"job-view-plot-metrics"` + JobViewTableMetrics []string `json:"job-view-table-metrics"` + SubClusters []SubClusterConfig `json:"sub-clusters"` } type SubClusterConfig struct { Name string `json:"name"` - JobListMetrics []string `json:"jobListMetrics"` - JobViewPlotMetrics []string `json:"jobViewPlotMetrics"` - JobViewTableMetrics []string `json:"jobViewTableMetrics"` + JobListMetrics []string `json:"job-list-metrics"` + JobViewPlotMetrics []string `json:"job-view-plot-metrics"` + JobViewTableMetrics []string `json:"job-view-table-metrics"` } type PlotConfiguration struct { - ColorBackground bool `json:"colorBackground"` - PlotsPerRow int `json:"plotsPerRow"` - LineWidth int `json:"lineWidth"` - ColorScheme []string `json:"colorScheme"` + ColorBackground bool `json:"color-background"` + PlotsPerRow int `json:"plots-per-row"` + LineWidth int `json:"line-width"` + ColorScheme []string `json:"color-scheme"` } var UIDefaults = WebConfig{ diff --git a/web/webConfig_test.go b/web/webConfig_test.go index 514fdabb..82305f7c 100644 --- a/web/webConfig_test.go +++ b/web/webConfig_test.go @@ -20,38 +20,38 @@ func TestInit(t *testing.T) { Init(cfg) - if UIDefaultsMap["nodelist_usePaging"] == false { + if UIDefaultsMap["nodeList_usePaging"] == false { t.Errorf("wrong option\ngot: %v \nwant: true", UIDefaultsMap["NodeList_UsePaging"]) } } func TestSimpleDefaults(t *testing.T) { const s = `{ - "joblist": { - "showFootprint": false + "job-list": { + "show-footprint": false } }` Init(json.RawMessage(s)) - if UIDefaultsMap["joblist_usePaging"] == true { + if UIDefaultsMap["jobList_usePaging"] == true { t.Errorf("wrong option\ngot: %v \nwant: false", UIDefaultsMap["NodeList_UsePaging"]) } } func TestOverwrite(t *testing.T) { const s = `{ - "metricConfig": { - "jobListMetrics": ["flops_sp", "flops_dp"], + "metric-config": { + "job-list-metrics": ["flops_sp", "flops_dp"], "clusters": [ { "name": "fritz", - "jobListMetrics": ["flops_any", "mem_bw", "load"], - "subClusters": [ + "job-list-metrics": ["flops_any", "mem_bw", "load"], + "sub-clusters": [ { "name": "icelake", - "jobListMetrics": ["flops_any", "mem_bw", "power", "load"], - "jobViewPlotMetrics": ["load"] + "job-list-metrics": ["flops_any", "mem_bw", "power", "load"], + "job-view-plot-metrics": ["load"] } ] }