mirror of
https://github.com/ClusterCockpit/cc-backend
synced 2026-07-07 17:00:36 +02:00
@@ -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
|
||||
|
||||
|
||||
@@ -163,8 +163,6 @@ ln -s <your-existing-job-archive> ./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 <your-existing-job-archive> ./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)
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
+23
-11
@@ -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"
|
||||
|
||||
+21
-21
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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] == "*" {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
+25
-18
@@ -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": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"provider": {
|
||||
"description": "",
|
||||
"description": "OpenID Connect provider URL.",
|
||||
"type": "string"
|
||||
},
|
||||
"syncUserOnLogin": {
|
||||
"description": "",
|
||||
"sync-user-on-login": {
|
||||
"description": "Add non-existent user to DB at login attempt with values provided.",
|
||||
"type": "boolean"
|
||||
},
|
||||
"updateUserOnLogin": {
|
||||
"description": "",
|
||||
"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"]
|
||||
}`
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"]
|
||||
}`
|
||||
}
|
||||
}`
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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())
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -184,7 +184,6 @@ var (
|
||||
initOnce sync.Once
|
||||
cache *lrucache.Cache = lrucache.New(128 * 1024 * 1024)
|
||||
ar ArchiveBackend
|
||||
useArchive bool
|
||||
mutex sync.Mutex
|
||||
)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -36,11 +36,11 @@ 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
|
||||
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:"usePathStyle"` // Use path-style URLs (required for MinIO)
|
||||
UsePathStyle bool `json:"use-path-style"` // Use path-style URLs (required for MinIO)
|
||||
}
|
||||
|
||||
// S3Archive implements ArchiveBackend using AWS S3 or S3-compatible object storage.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 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 {
|
||||
@@ -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,25 +69,29 @@ 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 {
|
||||
if delete && cleanupDir == "" {
|
||||
cclog.Infof("[METRICSTORE]> done: %d checkpoints deleted", n)
|
||||
} else {
|
||||
cclog.Infof("[METRICSTORE]> done: %d files zipped and moved to archive", n)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
},
|
||||
"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"]
|
||||
}`
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
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
|
||||
// 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 {
|
||||
@@ -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
|
||||
}
|
||||
@@ -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())
|
||||
|
||||
@@ -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()
|
||||
|
||||
+27
-27
@@ -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": {
|
||||
|
||||
Generated
+158
-139
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -6,9 +6,6 @@
|
||||
-->
|
||||
|
||||
<script>
|
||||
// import {
|
||||
// getContext
|
||||
// } from "svelte"
|
||||
import {
|
||||
queryStore,
|
||||
gql,
|
||||
@@ -55,9 +52,6 @@
|
||||
let to = $state(new Date(Date.now()));
|
||||
let stackedFrom = $state(Math.floor(Date.now() / 1000) - 14400);
|
||||
let colWidthStates = $state(0);
|
||||
let colWidthRoof = $state(0);
|
||||
let colWidthTotals = $state(0);
|
||||
let colWidthStacked = $state(0);
|
||||
|
||||
/* Derived */
|
||||
// States for Stacked charts
|
||||
@@ -354,9 +348,7 @@
|
||||
|
||||
</script>
|
||||
|
||||
<Card style="height: 98vh;">
|
||||
<CardBody class="align-content-center p-2">
|
||||
<Row>
|
||||
<Row>
|
||||
<Col>
|
||||
<Refresher
|
||||
hideSelector
|
||||
@@ -371,15 +363,16 @@
|
||||
}}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
{#if $statusQuery.fetching || $statesTimed.fetching}
|
||||
</Row>
|
||||
|
||||
{#if $statusQuery.fetching || $statesTimed.fetching}
|
||||
<Row class="justify-content-center">
|
||||
<Col xs="auto">
|
||||
<Spinner />
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
{:else if $statusQuery.error || $statesTimed.error}
|
||||
{:else if $statusQuery.error || $statesTimed.error}
|
||||
<Row class="mb-2">
|
||||
<Col class="d-flex justify-content-end">
|
||||
<Button color="secondary" href="/">
|
||||
@@ -400,8 +393,10 @@
|
||||
{/if}
|
||||
</Row>
|
||||
|
||||
{:else}
|
||||
<Row cols={{xs:1, md:2}}>
|
||||
{:else}
|
||||
<!-- View Supposed to be Viewed at Max Viewport Size -->
|
||||
<div class="align-content-center p-2">
|
||||
<Row cols={{xs:1, md:2}} style="height: 24vh; margin-bottom: 1rem;">
|
||||
<Col> <!-- General Cluster Info Card -->
|
||||
<Card class="h-100">
|
||||
<CardHeader>
|
||||
@@ -493,9 +488,9 @@
|
||||
Active Cores
|
||||
</Col>
|
||||
<Col xs={8}>
|
||||
<Progress multi style="height:2.5rem;font-size:x-large;">
|
||||
<Progress bar color="success" value={clusterInfo?.allocatedCores}>{formatNumber(clusterInfo?.allocatedCores)}</Progress>
|
||||
<Progress bar color="light" value={clusterInfo?.idleCores}>{formatNumber(clusterInfo?.idleCores)}</Progress>
|
||||
<Progress multi max={clusterInfo?.totalCores} style="height:2.5rem;font-size:x-large;">
|
||||
<Progress bar color="success" value={clusterInfo?.allocatedCores} title={`${clusterInfo?.allocatedCores} active`}>{formatNumber(clusterInfo?.allocatedCores)}</Progress>
|
||||
<Progress bar color="light" value={clusterInfo?.idleCores} title={`${clusterInfo?.idleCores} idle`}>{formatNumber(clusterInfo?.idleCores)}</Progress>
|
||||
</Progress>
|
||||
</Col>
|
||||
<Col xs={2} style="font-size:large;">
|
||||
@@ -508,9 +503,9 @@
|
||||
Active GPU
|
||||
</Col>
|
||||
<Col xs={8}>
|
||||
<Progress multi style="height:2.5rem;font-size:x-large;">
|
||||
<Progress bar color="success" value={clusterInfo?.allocatedAccs}>{formatNumber(clusterInfo?.allocatedAccs)}</Progress>
|
||||
<Progress bar color="light" value={clusterInfo?.idleAccs}>{formatNumber(clusterInfo?.idleAccs)}</Progress>
|
||||
<Progress multi max={clusterInfo?.totalAccs} style="height:2.5rem;font-size:x-large;">
|
||||
<Progress bar color="success" value={clusterInfo?.allocatedAccs} title={`${clusterInfo?.allocatedAccs} active`}>{formatNumber(clusterInfo?.allocatedAccs)}</Progress>
|
||||
<Progress bar color="light" value={clusterInfo?.idleAccs} title={`${clusterInfo?.idleAccs} idle`}>{formatNumber(clusterInfo?.idleAccs)}</Progress>
|
||||
</Progress>
|
||||
</Col>
|
||||
<Col xs={2} style="font-size:large;">
|
||||
@@ -521,40 +516,55 @@
|
||||
</CardBody>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Col> <!-- Total Cluster Metric in Time SUMS-->
|
||||
<div bind:clientWidth={colWidthTotals}>
|
||||
<Row cols={{xs:1, md:2}} style="height: 35vh; margin-bottom: 1rem;">
|
||||
<!-- Total Cluster Metric in Time SUMS-->
|
||||
<Col class="text-center">
|
||||
<h5 class="mt-2 mb-0">
|
||||
Cluster Utilization (
|
||||
<span style="color: #0000ff;">
|
||||
{`${$statusQuery?.data?.clusterMetrics?.metrics[0]?.name} (${$statusQuery?.data?.clusterMetrics?.metrics[0]?.unit?.prefix}${$statusQuery?.data?.clusterMetrics?.metrics[0]?.unit?.base})`}
|
||||
</span>,
|
||||
<span style="color: #ff0000;">
|
||||
{`${$statusQuery?.data?.clusterMetrics?.metrics[1]?.name} (${$statusQuery?.data?.clusterMetrics?.metrics[1]?.unit?.prefix}${$statusQuery?.data?.clusterMetrics?.metrics[1]?.unit?.base})`}
|
||||
</span>
|
||||
)
|
||||
</h5>
|
||||
<div>
|
||||
{#key $statusQuery?.data?.clusterMetrics}
|
||||
<DoubleMetric
|
||||
width={colWidthTotals}
|
||||
timestep={$statusQuery?.data?.clusterMetrics[0]?.timestep || 60}
|
||||
numNodes={$statusQuery?.data?.clusterMetrics?.nodeCount || 0}
|
||||
metricData={$statusQuery?.data?.clusterMetrics?.metrics || []}
|
||||
cluster={presetCluster}
|
||||
fixLinewidth={2}
|
||||
publicMode
|
||||
/>
|
||||
{/key}
|
||||
</div>
|
||||
</Col>
|
||||
|
||||
<Col> <!-- Nodes Roofline -->
|
||||
<div bind:clientWidth={colWidthRoof}>
|
||||
<div>
|
||||
{#key $statusQuery?.data?.nodeMetrics}
|
||||
<Roofline
|
||||
colorBackground
|
||||
useColors={false}
|
||||
useLegend={false}
|
||||
allowSizeChange
|
||||
width={colWidthRoof}
|
||||
height={300}
|
||||
cluster={presetCluster}
|
||||
subCluster={clusterInfo?.roofData ? clusterInfo.roofData : null}
|
||||
roofData={transformNodesStatsToData($statusQuery?.data?.nodeMetrics)}
|
||||
nodesData={transformNodesStatsToInfo($statusQuery?.data?.nodeMetrics)}
|
||||
fixTitle="Node Utilization"
|
||||
yMinimum={1.0}
|
||||
height={330}
|
||||
/>
|
||||
{/key}
|
||||
</div>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Row cols={{xs:1, md:2}} style="height: 35vh;">
|
||||
<Col> <!-- Pie Last States -->
|
||||
<Row>
|
||||
{#if refinedStateData.length > 0}
|
||||
@@ -607,12 +617,11 @@
|
||||
</Col>
|
||||
|
||||
<Col> <!-- Stacked SchedState -->
|
||||
<div bind:clientWidth={colWidthStacked}>
|
||||
<div>
|
||||
{#key $statesTimed?.data?.nodeStatesTimed}
|
||||
<Stacked
|
||||
data={$statesTimed?.data?.nodeStatesTimed}
|
||||
width={colWidthStacked}
|
||||
height={260}
|
||||
height={300}
|
||||
ylabel="Nodes"
|
||||
yunit = "#Count"
|
||||
title = "Cluster Status"
|
||||
@@ -622,6 +631,5 @@
|
||||
</div>
|
||||
</Col>
|
||||
</Row>
|
||||
{/if}
|
||||
</CardBody>
|
||||
</Card>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -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 @@
|
||||
</DropdownToggle>
|
||||
<DropdownMenu class="dropdown-menu-lg-end">
|
||||
<NavbarLinks
|
||||
{clustersNames}
|
||||
{clusterNames}
|
||||
{subclusterMap}
|
||||
direction="right"
|
||||
links={views.filter(
|
||||
|
||||
@@ -52,6 +52,7 @@
|
||||
|
||||
/* Const Init */
|
||||
// Important: init() needs to be first const declaration or contextclient will not be initialized before "const client = ..."
|
||||
// svelte-ignore state_referenced_locally
|
||||
const { query: initq } = init(`
|
||||
job(id: "${dbid}") {
|
||||
id, jobId, user, project, cluster, startTime,
|
||||
@@ -93,6 +94,7 @@
|
||||
let totalMetrics = $state(0);
|
||||
|
||||
/* Derived */
|
||||
const showSummary = $derived((!!ccconfig[`jobView_showFootprint`] || !!ccconfig[`jobView_showPolarPlot`]))
|
||||
const jobMetrics = $derived(queryStore({
|
||||
client: client,
|
||||
query: query,
|
||||
@@ -260,7 +262,9 @@
|
||||
{#if $initq.error}
|
||||
<Card body color="danger">{$initq.error.message}</Card>
|
||||
{:else if $initq?.data}
|
||||
{#if showSummary}
|
||||
<JobSummary job={$initq.data.job}/>
|
||||
{/if}
|
||||
{:else}
|
||||
<Spinner secondary />
|
||||
{/if}
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -37,6 +37,7 @@
|
||||
|
||||
/* State Init */
|
||||
let pendingChange = $state("none");
|
||||
// svelte-ignore state_referenced_locally
|
||||
let tagmap = $state(presetTagmap);
|
||||
|
||||
/* Functions */
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -89,6 +89,7 @@
|
||||
};
|
||||
|
||||
/* State Init */
|
||||
// svelte-ignore state_referenced_locally
|
||||
let filters = $state({
|
||||
dbId: filterPresets.dbId || [],
|
||||
jobId: filterPresets.jobId || "",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
</script>
|
||||
|
||||
<Modal {isOpen} toggle={() => (isOpen = !isOpen)}>
|
||||
|
||||
@@ -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 ||
|
||||
|
||||
@@ -28,8 +28,8 @@
|
||||
setFilter,
|
||||
} = $props();
|
||||
|
||||
/* State Init */
|
||||
let energyState = $state(presetEnergy);
|
||||
/* Derived */
|
||||
let energyState = $derived(presetEnergy);
|
||||
</script>
|
||||
|
||||
<Modal {isOpen} toggle={() => (isOpen = !isOpen)}>
|
||||
|
||||
@@ -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);
|
||||
|
||||
</script>
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -35,8 +35,11 @@
|
||||
const initialized = $derived(getContext("initialized"))
|
||||
|
||||
/* State Init */
|
||||
let pendingTags = $state(presetTags);
|
||||
let searchTerm = $state("");
|
||||
|
||||
/* Derived */
|
||||
let pendingTags = $derived(presetTags);
|
||||
|
||||
</script>
|
||||
|
||||
<Modal {isOpen} toggle={() => (isOpen = !isOpen)}>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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
|
||||
-->
|
||||
|
||||
<script>
|
||||
import uPlot from "uplot";
|
||||
import { formatNumber, formatDurationTime } from "../units.js";
|
||||
import { getContext, onMount, onDestroy } from "svelte";
|
||||
import { getContext, onDestroy } from "svelte";
|
||||
import { Card } from "@sveltestrap/sveltestrap";
|
||||
|
||||
/* Svelte 5 Props */
|
||||
let {
|
||||
// metric,
|
||||
width = 0,
|
||||
height = 300,
|
||||
fixLinewidth = null,
|
||||
metricData,
|
||||
timestep,
|
||||
numNodes,
|
||||
metricData,
|
||||
// useStatsSeries = false,
|
||||
// statisticsSeries = null,
|
||||
cluster = "",
|
||||
cluster,
|
||||
forNode = true,
|
||||
// zoomState = null,
|
||||
// thresholdState = null,
|
||||
enableFlip = false,
|
||||
// onZoom
|
||||
publicMode = false,
|
||||
height = 300,
|
||||
} = $props();
|
||||
|
||||
/* Const Init */
|
||||
const clusterCockpitConfig = getContext("cc-config");
|
||||
// const resampleConfig = getContext("resampling");
|
||||
// const subClusterTopology = getContext("getHardwareTopology")(cluster, subCluster);
|
||||
// const metricConfig = getContext("getMetricConfig")(cluster, subCluster, metric);
|
||||
const lineColors = clusterCockpitConfig.plotConfiguration_colorScheme;
|
||||
const lineWidth = fixLinewidth ? fixLinewidth : clusterCockpitConfig.plotConfiguration_lineWidth / window.devicePixelRatio;
|
||||
// const cbmode = clusterCockpitConfig?.plotConfiguration_colorblindMode || false;
|
||||
const fixedLineColors = ["#0000ff", "#ff0000"]; // Plot only uses 2 Datasets: High Contrast
|
||||
const renderSleepTime = 200;
|
||||
// const normalLineColor = "#000000";
|
||||
// const backgroundColors = {
|
||||
// normal: "rgba(255, 255, 255, 1.0)",
|
||||
// caution: cbmode ? "rgba(239, 230, 69, 0.3)" : "rgba(255, 128, 0, 0.3)",
|
||||
// alert: cbmode ? "rgba(225, 86, 44, 0.3)" : "rgba(255, 0, 0, 0.3)",
|
||||
// };
|
||||
|
||||
/* Var Init */
|
||||
let timeoutId = null;
|
||||
|
||||
/* State Init */
|
||||
let plotWrapper = $state(null);
|
||||
let width = $state(0); // Wrapper Width
|
||||
let uplot = $state(null);
|
||||
|
||||
/* Derived */
|
||||
// const usesMeanStatsSeries = $derived((statisticsSeries?.mean && statisticsSeries.mean.length != 0));
|
||||
// const resampleTrigger = $derived(resampleConfig?.trigger ? Number(resampleConfig.trigger) : null);
|
||||
// const resampleResolutions = $derived(resampleConfig?.resolutions ? [...resampleConfig.resolutions] : null);
|
||||
// const resampleMinimum = $derived(resampleConfig?.resolutions ? Math.min(...resampleConfig.resolutions) : null);
|
||||
// const thresholds = $derived(findJobAggregationThresholds(
|
||||
// subClusterTopology,
|
||||
// metricConfig,
|
||||
// scope,
|
||||
// numhwthreads,
|
||||
// numaccs
|
||||
// ));
|
||||
const longestSeries = $derived.by(() => {
|
||||
// if (useStatsSeries) {
|
||||
// return usesMeanStatsSeries ? statisticsSeries?.mean?.length : statisticsSeries?.median?.length;
|
||||
// } else {
|
||||
return metricData.reduce((n, m) => Math.max(n, m.data.length), 0);
|
||||
// }
|
||||
});
|
||||
const maxX = $derived(longestSeries * timestep);
|
||||
// const maxY = $derived.by(() => {
|
||||
// let pendingY = 0;
|
||||
// // if (useStatsSeries) {
|
||||
// // pendingY = statisticsSeries.max.reduce(
|
||||
// // (max, x) => Math.max(max, x),
|
||||
// // thresholds?.normal,
|
||||
// // ) || thresholds?.normal
|
||||
// // } else {
|
||||
// pendingY = series.reduce(
|
||||
// (max, series) => Math.max(max, series?.statistics?.max),
|
||||
// thresholds?.normal,
|
||||
// ) || thresholds?.normal;
|
||||
// // }
|
||||
const lineWidth = $derived(publicMode ? 2 : clusterCockpitConfig.plotConfiguration_lineWidth / window.devicePixelRatio);
|
||||
const longestSeries = $derived.by(() => {
|
||||
return metricData.reduce((n, m) => Math.max(n, m.data.length), 0);
|
||||
});
|
||||
|
||||
// if (pendingY >= 10 * thresholds.peak) {
|
||||
// // Hard y-range render limit if outliers in series data
|
||||
// return (10 * thresholds.peak);
|
||||
// } else {
|
||||
// return pendingY;
|
||||
// }
|
||||
// });
|
||||
// const plotBands = $derived.by(() => {
|
||||
// if (useStatsSeries) {
|
||||
// return [
|
||||
// { series: [2, 3], fill: cbmode ? "rgba(0,0,255,0.1)" : "rgba(0,255,0,0.1)" },
|
||||
// { series: [3, 1], fill: cbmode ? "rgba(0,255,0,0.1)" : "rgba(255,0,0,0.1)" },
|
||||
// ];
|
||||
// };
|
||||
// return null;
|
||||
// })
|
||||
const plotData = $derived.by(() => {
|
||||
// Derive Plot Params
|
||||
let plotData = $derived.by(() => {
|
||||
let pendingData = [new Array(longestSeries)];
|
||||
// X
|
||||
if (forNode === true) {
|
||||
@@ -135,25 +68,15 @@
|
||||
};
|
||||
};
|
||||
// Y
|
||||
// if (useStatsSeries) {
|
||||
// pendingData.push(statisticsSeries.min);
|
||||
// pendingData.push(statisticsSeries.max);
|
||||
// if (usesMeanStatsSeries) {
|
||||
// pendingData.push(statisticsSeries.mean);
|
||||
// } else {
|
||||
// pendingData.push(statisticsSeries.median);
|
||||
// }
|
||||
|
||||
// } else {
|
||||
for (let i = 0; i < metricData.length; i++) {
|
||||
pendingData.push(metricData[i]?.data);
|
||||
};
|
||||
// };
|
||||
return pendingData;
|
||||
})
|
||||
const plotSeries = $derived.by(() => {
|
||||
|
||||
let plotSeries = $derived.by(() => {
|
||||
// X
|
||||
let pendingSeries = [
|
||||
// Note: X-Legend Will not be shown as soon as Y-Axis are in extendedMode
|
||||
{
|
||||
label: "Runtime",
|
||||
value: (u, ts, sidx, didx) =>
|
||||
@@ -161,87 +84,108 @@
|
||||
}
|
||||
];
|
||||
// Y
|
||||
// if (useStatsSeries) {
|
||||
// pendingSeries.push({
|
||||
// label: "min",
|
||||
// scale: "y",
|
||||
// width: lineWidth,
|
||||
// stroke: cbmode ? "rgb(0,255,0)" : "red",
|
||||
// });
|
||||
// pendingSeries.push({
|
||||
// label: "max",
|
||||
// scale: "y",
|
||||
// width: lineWidth,
|
||||
// stroke: cbmode ? "rgb(0,0,255)" : "green",
|
||||
// });
|
||||
// pendingSeries.push({
|
||||
// label: usesMeanStatsSeries ? "mean" : "median",
|
||||
// scale: "y",
|
||||
// width: lineWidth,
|
||||
// stroke: "black",
|
||||
// });
|
||||
|
||||
// } else {
|
||||
for (let i = 0; i < metricData.length; i++) {
|
||||
// Default
|
||||
// if (!extendedLegendData) {
|
||||
pendingSeries.push({
|
||||
label: `${metricData[i]?.name} (${metricData[i]?.unit?.prefix}${metricData[i]?.unit?.base})`,
|
||||
label: publicMode ? null : `${metricData[i]?.name} (${metricData[i]?.unit?.prefix}${metricData[i]?.unit?.base})`,
|
||||
scale: `y${i+1}`,
|
||||
width: lineWidth,
|
||||
stroke: lineColor(i, metricData.length),
|
||||
stroke: fixedLineColors[i],
|
||||
});
|
||||
// }
|
||||
// Extended Legend For NodeList
|
||||
// else {
|
||||
// pendingSeries.push({
|
||||
// label:
|
||||
// scope === "node"
|
||||
// ? series[i].hostname
|
||||
// : scope === "accelerator"
|
||||
// ? 'Acc #' + (i + 1) // series[i].id.slice(9, 14) | Too Hardware Specific
|
||||
// : scope + " #" + (i + 1),
|
||||
// scale: "y",
|
||||
// width: lineWidth,
|
||||
// stroke: lineColor(i, series?.length),
|
||||
// values: (u, sidx, idx) => {
|
||||
// // "i" = "sidx - 1" : sidx contains x-axis-data
|
||||
// if (idx == null)
|
||||
// return {
|
||||
// time: '-',
|
||||
// value: '-',
|
||||
// user: '-',
|
||||
// job: '-'
|
||||
// };
|
||||
|
||||
// if (series[i].id in extendedLegendData) {
|
||||
// return {
|
||||
// time: formatDurationTime(plotData[0][idx], forNode),
|
||||
// value: plotData[sidx][idx],
|
||||
// user: extendedLegendData[series[i].id].user,
|
||||
// job: extendedLegendData[series[i].id].job,
|
||||
// };
|
||||
// } else {
|
||||
// return {
|
||||
// time: formatDurationTime(plotData[0][idx], forNode),
|
||||
// value: plotData[sidx][idx],
|
||||
// user: '-',
|
||||
// job: '-',
|
||||
// };
|
||||
// }
|
||||
// }
|
||||
// });
|
||||
// }
|
||||
// };
|
||||
};
|
||||
return pendingSeries;
|
||||
})
|
||||
|
||||
/* Effects */
|
||||
// $effect(() => {
|
||||
// if (!useStatsSeries && statisticsSeries != null) useStatsSeries = true;
|
||||
// })
|
||||
// Set Options
|
||||
function getOpts(optWidth, optHeight) {
|
||||
let baseOpts = {
|
||||
width: optWidth,
|
||||
height: optHeight,
|
||||
series: plotSeries,
|
||||
axes: [
|
||||
{
|
||||
scale: "x",
|
||||
incrs: timeIncrs(timestep, maxX, forNode),
|
||||
values: (_, vals) => vals.map((v) => formatDurationTime(v, forNode)),
|
||||
},
|
||||
{
|
||||
scale: "y1",
|
||||
grid: { show: true },
|
||||
values: (u, vals) => vals.map((v) => formatNumber(v)),
|
||||
},
|
||||
{
|
||||
side: 1,
|
||||
scale: "y2",
|
||||
grid: { show: false },
|
||||
values: (u, vals) => vals.map((v) => formatNumber(v)),
|
||||
},
|
||||
],
|
||||
// bands: plotBands,
|
||||
padding: [5, 10, -20, 0],
|
||||
hooks: {},
|
||||
scales: {
|
||||
x: { time: false },
|
||||
y1: { auto: true },
|
||||
y2: { auto: true },
|
||||
},
|
||||
legend: {
|
||||
show: !publicMode,
|
||||
live: !publicMode
|
||||
},
|
||||
cursor: {
|
||||
drag: { x: true, y: true },
|
||||
}
|
||||
}
|
||||
|
||||
if (publicMode) {
|
||||
// X
|
||||
baseOpts.axes[0].space = 60;
|
||||
baseOpts.axes[0].font = '16px Arial';
|
||||
// Y1
|
||||
baseOpts.axes[1].space = 50;
|
||||
baseOpts.axes[1].size = 60;
|
||||
baseOpts.axes[1].font = '16px Arial';
|
||||
baseOpts.axes[1].stroke = fixedLineColors[0];
|
||||
// Y2
|
||||
baseOpts.axes[2].space = 40;
|
||||
baseOpts.axes[2].size = 60;
|
||||
baseOpts.axes[2].font = '16px Arial';
|
||||
baseOpts.axes[2].stroke = fixedLineColors[1];
|
||||
} else {
|
||||
baseOpts.title = 'Cluster Utilization';
|
||||
baseOpts.plugins = [legendAsTooltipPlugin()];
|
||||
// X
|
||||
baseOpts.axes[0].label = 'Time';
|
||||
// Y1
|
||||
baseOpts.axes[1].label = `${metricData[0]?.name} (${metricData[0]?.unit?.prefix}${metricData[0]?.unit?.base})`;
|
||||
// Y2
|
||||
baseOpts.axes[2].label = `${metricData[1]?.name} (${metricData[1]?.unit?.prefix}${metricData[1]?.unit?.base})`;
|
||||
baseOpts.hooks.draw = [
|
||||
(u) => {
|
||||
// Draw plot type label:
|
||||
let textl = `Cluster ${cluster}`
|
||||
let textr = `Sums of ${numNodes} nodes`
|
||||
u.ctx.save();
|
||||
u.ctx.textAlign = "start"; // 'end'
|
||||
u.ctx.fillStyle = "black";
|
||||
u.ctx.fillText(textl, u.bbox.left + 10, u.bbox.top + (forNode ? 0 : 10));
|
||||
u.ctx.textAlign = "end";
|
||||
u.ctx.fillStyle = "black";
|
||||
u.ctx.fillText(
|
||||
textr,
|
||||
u.bbox.left + u.bbox.width - 10,
|
||||
u.bbox.top + (forNode ? 0 : 10),
|
||||
);
|
||||
|
||||
u.ctx.restore();
|
||||
return;
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
return baseOpts;
|
||||
};
|
||||
|
||||
/* Effects */
|
||||
// This updates plot on all size changes if wrapper (== data) exists
|
||||
$effect(() => {
|
||||
if (plotWrapper) {
|
||||
@@ -262,73 +206,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
// removed arg "subcluster": input metricconfig and topology now directly derived from subcluster
|
||||
// function findJobAggregationThresholds(
|
||||
// subClusterTopology,
|
||||
// metricConfig,
|
||||
// scope,
|
||||
// numhwthreads,
|
||||
// numaccs
|
||||
// ) {
|
||||
|
||||
// if (!subClusterTopology || !metricConfig || !scope) {
|
||||
// console.warn("Argument missing for findJobAggregationThresholds!");
|
||||
// return null;
|
||||
// }
|
||||
|
||||
// // handle special *-stat scopes
|
||||
// if (scope.match(/(.*)-stat$/)) {
|
||||
// const statParts = scope.split('-');
|
||||
// scope = statParts[0]
|
||||
// }
|
||||
|
||||
// if (metricConfig?.aggregation == "avg") {
|
||||
// // Return as Configured
|
||||
// return {
|
||||
// normal: metricConfig.normal,
|
||||
// caution: metricConfig.caution,
|
||||
// alert: metricConfig.alert,
|
||||
// peak: metricConfig.peak,
|
||||
// };
|
||||
// }
|
||||
|
||||
// if (metricConfig?.aggregation == "sum") {
|
||||
// // Scale Thresholds
|
||||
// let fraction;
|
||||
// if (numaccs > 0) fraction = subClusterTopology.accelerators.length / numaccs;
|
||||
// else if (numhwthreads > 0) fraction = subClusterTopology.core.length / numhwthreads;
|
||||
// else fraction = 1; // Fallback
|
||||
|
||||
// let divisor;
|
||||
// // Exclusive: Fraction = 1; Shared: Fraction > 1
|
||||
// if (scope == 'node') divisor = fraction;
|
||||
// // Cap divisor at number of available sockets or domains
|
||||
// else if (scope == 'socket') divisor = (fraction < subClusterTopology.socket.length) ? subClusterTopology.socket.length : fraction;
|
||||
// else if (scope == "memoryDomain") divisor = (fraction < subClusterTopology.memoryDomain.length) ? subClusterTopology.socket.length : fraction;
|
||||
// // Use Maximum Division for Smallest Scopes
|
||||
// else if (scope == "core") divisor = subClusterTopology.core.length;
|
||||
// else if (scope == "hwthread") divisor = subClusterTopology.core.length; // alt. name for core
|
||||
// else if (scope == "accelerator") divisor = subClusterTopology.accelerators.length;
|
||||
// else {
|
||||
// console.log('Unknown scope, return default aggregation thresholds for sum', scope)
|
||||
// divisor = 1;
|
||||
// }
|
||||
|
||||
// return {
|
||||
// peak: metricConfig.peak / divisor,
|
||||
// normal: metricConfig.normal / divisor,
|
||||
// caution: metricConfig.caution / divisor,
|
||||
// alert: metricConfig.alert / divisor,
|
||||
// };
|
||||
// }
|
||||
|
||||
// console.warn(
|
||||
// "Missing or unkown aggregation mode (sum/avg) for metric:",
|
||||
// metricConfig,
|
||||
// );
|
||||
// return null;
|
||||
// }
|
||||
|
||||
// UPLOT PLUGIN // converts the legend into a simple tooltip
|
||||
function legendAsTooltipPlugin({
|
||||
className,
|
||||
@@ -408,219 +285,22 @@
|
||||
}
|
||||
}
|
||||
|
||||
// RETURN BG COLOR FROM THRESHOLD
|
||||
// function backgroundColor() {
|
||||
// if (
|
||||
// clusterCockpitConfig.plotConfiguration_colorBackground == false ||
|
||||
// // !thresholds ||
|
||||
// !(series && series.every((s) => s.statistics != null))
|
||||
// )
|
||||
// return backgroundColors.normal;
|
||||
|
||||
// let cond =
|
||||
// thresholds.alert < thresholds.caution
|
||||
// ? (a, b) => a <= b
|
||||
// : (a, b) => a >= b;
|
||||
|
||||
// let avg =
|
||||
// series.reduce((sum, series) => sum + series.statistics.avg, 0) /
|
||||
// series.length;
|
||||
|
||||
// if (Number.isNaN(avg)) return backgroundColors.normal;
|
||||
|
||||
// if (cond(avg, thresholds.alert)) return backgroundColors.alert;
|
||||
|
||||
// if (cond(avg, thresholds.caution)) return backgroundColors.caution;
|
||||
|
||||
// return backgroundColors.normal;
|
||||
// }
|
||||
|
||||
function lineColor(i, n) {
|
||||
if (n && n >= lineColors.length) return lineColors[i % lineColors.length];
|
||||
else return lineColors[Math.floor((i / n) * lineColors.length)];
|
||||
}
|
||||
|
||||
function render(ren_width, ren_height) {
|
||||
// Set Options
|
||||
const opts = {
|
||||
width,
|
||||
height,
|
||||
title: 'Cluster Utilization',
|
||||
plugins: [legendAsTooltipPlugin()],
|
||||
series: plotSeries,
|
||||
axes: [
|
||||
{
|
||||
scale: "x",
|
||||
space: 35,
|
||||
incrs: timeIncrs(timestep, maxX, forNode),
|
||||
label: "Time",
|
||||
values: (_, vals) => vals.map((v) => formatDurationTime(v, forNode)),
|
||||
},
|
||||
{
|
||||
scale: "y1",
|
||||
grid: { show: true },
|
||||
label: `${metricData[0]?.name} (${metricData[0]?.unit?.prefix}${metricData[0]?.unit?.base})`,
|
||||
values: (u, vals) => vals.map((v) => formatNumber(v)),
|
||||
},
|
||||
{
|
||||
side: 1,
|
||||
scale: "y2",
|
||||
grid: { show: false },
|
||||
label: `${metricData[1]?.name} (${metricData[1]?.unit?.prefix}${metricData[1]?.unit?.base})`,
|
||||
values: (u, vals) => vals.map((v) => formatNumber(v)),
|
||||
},
|
||||
],
|
||||
// bands: plotBands,
|
||||
padding: [5, 10, -20, 0],
|
||||
hooks: {
|
||||
// init: [
|
||||
// (u) => {
|
||||
// /* IF Zoom Enabled */
|
||||
// if (resampleConfig && !forNode) {
|
||||
// u.over.addEventListener("dblclick", (e) => {
|
||||
// // console.log('Dispatch: Zoom Reset')
|
||||
// onZoom({
|
||||
// lastZoomState: {
|
||||
// x: { time: false },
|
||||
// y: { auto: true }
|
||||
// }
|
||||
// });
|
||||
// });
|
||||
// };
|
||||
// },
|
||||
// ],
|
||||
draw: [
|
||||
(u) => {
|
||||
// Draw plot type label:
|
||||
let textl = `Cluster ${cluster}`
|
||||
// let textl = `${scope}${plotSeries.length > 2 ? "s" : ""}${
|
||||
// useStatsSeries
|
||||
// ? (usesMeanStatsSeries ? ": min/mean/max" : ": min/median/max")
|
||||
// : metricConfig != null && scope != metricConfig.scope
|
||||
// ? ` (${metricConfig.aggregation})`
|
||||
// : ""
|
||||
// }`;
|
||||
let textr = `Sums of ${numNodes} nodes`
|
||||
//let textr = `${isShared && scope != "core" && scope != "accelerator" ? "[Shared]" : ""}`;
|
||||
u.ctx.save();
|
||||
u.ctx.textAlign = "start"; // 'end'
|
||||
u.ctx.fillStyle = "black";
|
||||
u.ctx.fillText(textl, u.bbox.left + 10, u.bbox.top + (forNode ? 0 : 10));
|
||||
u.ctx.textAlign = "end";
|
||||
u.ctx.fillStyle = "black";
|
||||
u.ctx.fillText(
|
||||
textr,
|
||||
u.bbox.left + u.bbox.width - 10,
|
||||
u.bbox.top + (forNode ? 0 : 10),
|
||||
);
|
||||
// u.ctx.fillText(text, u.bbox.left + u.bbox.width - 10, u.bbox.top + u.bbox.height - 10) // Recipe for bottom right
|
||||
|
||||
// if (!thresholds) {
|
||||
u.ctx.restore();
|
||||
return;
|
||||
// }
|
||||
|
||||
// let y = u.valToPos(thresholds.normal, "y", true);
|
||||
// u.ctx.save();
|
||||
// u.ctx.lineWidth = lineWidth;
|
||||
// u.ctx.strokeStyle = normalLineColor;
|
||||
// u.ctx.setLineDash([5, 5]);
|
||||
// u.ctx.beginPath();
|
||||
// u.ctx.moveTo(u.bbox.left, y);
|
||||
// u.ctx.lineTo(u.bbox.left + u.bbox.width, y);
|
||||
// u.ctx.stroke();
|
||||
// u.ctx.restore();
|
||||
},
|
||||
],
|
||||
// setScale: [
|
||||
// (u, key) => { // If ZoomResample is Configured && Not System/Node View
|
||||
// if (resampleConfig && !forNode && key === 'x') {
|
||||
// const numX = (u.series[0].idxs[1] - u.series[0].idxs[0])
|
||||
// if (numX <= resampleTrigger && timestep !== resampleMinimum) {
|
||||
// /* Get closest zoom level; prevents multiple iterative zoom requests for big zoom-steps (e.g. 600 -> 300 -> 120 -> 60) */
|
||||
// // Which resolution to theoretically request to achieve 30 or more visible data points:
|
||||
// const target = (numX * timestep) / resampleTrigger
|
||||
// // Which configured resolution actually matches the closest to theoretical target:
|
||||
// const closest = resampleResolutions.reduce(function(prev, curr) {
|
||||
// return (Math.abs(curr - target) < Math.abs(prev - target) ? curr : prev);
|
||||
// });
|
||||
// // Prevents non-required dispatches
|
||||
// if (timestep !== closest) {
|
||||
// // console.log('Dispatch: Zoom with Res from / to', timestep, closest)
|
||||
// onZoom({
|
||||
// newRes: closest,
|
||||
// lastZoomState: u?.scales,
|
||||
// lastThreshold: thresholds?.normal
|
||||
// });
|
||||
// }
|
||||
// } else {
|
||||
// // console.log('Dispatch: Zoom Update States')
|
||||
// onZoom({
|
||||
// lastZoomState: u?.scales,
|
||||
// lastThreshold: thresholds?.normal
|
||||
// });
|
||||
// };
|
||||
// };
|
||||
// },
|
||||
// ]
|
||||
},
|
||||
scales: {
|
||||
x: { time: false },
|
||||
y1: { auto: true },
|
||||
y1: { auto: true },
|
||||
},
|
||||
legend: {
|
||||
// Display legend until max 12 Y-dataseries
|
||||
show: true, // metricData.length <= 12 || useStatsSeries,
|
||||
live: true // But This Plot always for 2 Data-Series
|
||||
},
|
||||
cursor: {
|
||||
drag: { x: true, y: true },
|
||||
}
|
||||
};
|
||||
|
||||
// Handle Render
|
||||
if (!uplot) {
|
||||
opts.width = ren_width;
|
||||
opts.height = ren_height;
|
||||
|
||||
// if (plotSync) {
|
||||
// opts.cursor.sync = {
|
||||
// key: plotSync.key,
|
||||
// scales: ["x", null],
|
||||
// }
|
||||
// }
|
||||
|
||||
// if (zoomState && metricConfig?.aggregation == "avg") {
|
||||
// opts.scales = {...zoomState}
|
||||
// } else if (zoomState && metricConfig?.aggregation == "sum") {
|
||||
// // Allow Zoom In === Ymin changed
|
||||
// if (zoomState.y.min !== 0) { // scope change?: only use zoomState if thresholds match
|
||||
// if ((thresholdState === thresholds?.normal)) { opts.scales = {...zoomState} };
|
||||
// } // else: reset scaling to default
|
||||
// }
|
||||
|
||||
uplot = new uPlot(opts, plotData, plotWrapper);
|
||||
} else {
|
||||
uplot.setSize({ width: ren_width, height: ren_height });
|
||||
}
|
||||
}
|
||||
|
||||
function onSizeChange(chg_width, chg_height) {
|
||||
if (!uplot) return;
|
||||
function onSizeChange(chgWidth, chgHeight) {
|
||||
if (timeoutId != null) clearTimeout(timeoutId);
|
||||
timeoutId = setTimeout(() => {
|
||||
timeoutId = null;
|
||||
render(chg_width, chg_height);
|
||||
render(chgWidth, chgHeight);
|
||||
}, renderSleepTime);
|
||||
}
|
||||
|
||||
/* On Mount */
|
||||
onMount(() => {
|
||||
if (plotWrapper) {
|
||||
render(width, height);
|
||||
function render(renWidth, renHeight) {
|
||||
if (!uplot) {
|
||||
let opts = getOpts(renWidth, renHeight);
|
||||
uplot = new uPlot(opts, plotData, plotWrapper);
|
||||
} else {
|
||||
uplot.setSize({ width: renWidth, height: renHeight });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
/* On Destroy */
|
||||
onDestroy(() => {
|
||||
@@ -635,8 +315,12 @@
|
||||
<div bind:this={plotWrapper} bind:clientWidth={width}
|
||||
class={forNode ? 'py-2 rounded' : 'rounded'}
|
||||
></div>
|
||||
{:else if cluster}
|
||||
<Card body color="warning" class="mx-4"
|
||||
>Cannot render plot: No series data returned for <code>{cluster}</code>.</Card
|
||||
>
|
||||
{:else}
|
||||
<Card body color="warning" class="mx-4"
|
||||
>Cannot render plot: No series data returned for <code>{cluster}</code></Card
|
||||
>Cannot render plot: No series data returned.</Card
|
||||
>
|
||||
{/if}
|
||||
|
||||
@@ -56,9 +56,6 @@
|
||||
/* Const Init */
|
||||
const clusterCockpitConfig = getContext("cc-config");
|
||||
const resampleConfig = getContext("resampling");
|
||||
const subClusterTopology = getContext("getHardwareTopology")(cluster, subCluster);
|
||||
const metricConfig = getContext("getMetricConfig")(cluster, subCluster, metric);
|
||||
const lineColors = clusterCockpitConfig.plotConfiguration_colorScheme;
|
||||
const lineWidth = clusterCockpitConfig.plotConfiguration_lineWidth / window.devicePixelRatio;
|
||||
const cbmode = clusterCockpitConfig?.plotConfiguration_colorblindMode || false;
|
||||
const renderSleepTime = 200;
|
||||
@@ -77,6 +74,8 @@
|
||||
let uplot = $state(null);
|
||||
|
||||
/* Derived */
|
||||
const subClusterTopology = $derived(getContext("getHardwareTopology")(cluster, subCluster));
|
||||
const metricConfig = $derived(getContext("getMetricConfig")(cluster, subCluster, metric));
|
||||
const usesMeanStatsSeries = $derived((statisticsSeries?.mean && statisticsSeries.mean.length != 0));
|
||||
const resampleTrigger = $derived(resampleConfig?.trigger ? Number(resampleConfig.trigger) : null);
|
||||
const resampleResolutions = $derived(resampleConfig?.resolutions ? [...resampleConfig.resolutions] : null);
|
||||
@@ -200,7 +199,7 @@
|
||||
: scope + " #" + (i + 1),
|
||||
scale: "y",
|
||||
width: lineWidth,
|
||||
stroke: lineColor(i, series?.length),
|
||||
stroke: lineColor(i, clusterCockpitConfig.plotConfiguration_colorScheme),
|
||||
});
|
||||
}
|
||||
// Extended Legend For NodeList
|
||||
@@ -214,7 +213,7 @@
|
||||
: scope + " #" + (i + 1),
|
||||
scale: "y",
|
||||
width: lineWidth,
|
||||
stroke: lineColor(i, series?.length),
|
||||
stroke: lineColor(i, clusterCockpitConfig.plotConfiguration_colorScheme),
|
||||
values: (u, sidx, idx) => {
|
||||
// "i" = "sidx - 1" : sidx contains x-axis-data
|
||||
if (idx == null)
|
||||
@@ -446,9 +445,8 @@
|
||||
return backgroundColors.normal;
|
||||
}
|
||||
|
||||
function lineColor(i, n) {
|
||||
if (n && n >= lineColors.length) return lineColors[i % lineColors.length];
|
||||
else return lineColors[Math.floor((i / n) * lineColors.length)];
|
||||
function lineColor(index, colors) {
|
||||
return colors[index % colors.length];
|
||||
}
|
||||
|
||||
function render(ren_width, ren_height) {
|
||||
|
||||
@@ -95,6 +95,7 @@
|
||||
animation: false,
|
||||
plugins: {
|
||||
legend: {
|
||||
// svelte-ignore state_referenced_locally
|
||||
display: displayLegend
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
- `polarMetrics [Object]?`: Metric names and scaled peak values for rendering polar plot [Default: [] ]
|
||||
- `polarData [GraphQL.JobMetricStatWithName]?`: Metric data [Default: null]
|
||||
- `canvasId String?`: Unique ID for correct parallel chart.js rendering [Default: "polar-default"]
|
||||
- `height Number?`: Plot height [Default: 365]
|
||||
- `showLegend Bool?`: Legend Display [Default: true]
|
||||
-->
|
||||
|
||||
<script>
|
||||
@@ -38,22 +38,28 @@
|
||||
polarMetrics = [],
|
||||
polarData = [],
|
||||
canvasId = "polar-default",
|
||||
height = 350,
|
||||
size = 375,
|
||||
showLegend = true,
|
||||
} = $props();
|
||||
|
||||
/* Const Init */
|
||||
const options = {
|
||||
maintainAspectRatio: true,
|
||||
/* Derived */
|
||||
const options = $derived({
|
||||
responsive: true, // Default
|
||||
maintainAspectRatio: true, // Default
|
||||
animation: false,
|
||||
scales: { // fix scale
|
||||
r: {
|
||||
suggestedMin: 0.0,
|
||||
suggestedMax: 1.0
|
||||
}
|
||||
},
|
||||
plugins: {
|
||||
legend: {
|
||||
display: showLegend
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
/* Derived */
|
||||
const labels = $derived(polarMetrics
|
||||
.filter((m) => (m.peak != null))
|
||||
.map(pm => pm.name)
|
||||
@@ -77,7 +83,7 @@
|
||||
{
|
||||
label: 'Avg',
|
||||
data: loadData('avg'), // Node Scope Only
|
||||
fill: 2,
|
||||
fill: true, // fill: 2 if min active
|
||||
backgroundColor: 'rgba(255, 210, 0, 0.25)',
|
||||
borderColor: 'rgb(255, 210, 0)',
|
||||
pointBackgroundColor: 'rgb(255, 210, 0)',
|
||||
@@ -85,17 +91,17 @@
|
||||
pointHoverBackgroundColor: '#fff',
|
||||
pointHoverBorderColor: 'rgb(255, 210, 0)'
|
||||
},
|
||||
{
|
||||
label: 'Min',
|
||||
data: loadData('min'), // Node Scope Only
|
||||
fill: true,
|
||||
backgroundColor: 'rgba(255, 0, 0, 0.25)',
|
||||
borderColor: 'rgb(255, 0, 0)',
|
||||
pointBackgroundColor: 'rgb(255, 0, 0)',
|
||||
pointBorderColor: '#fff',
|
||||
pointHoverBackgroundColor: '#fff',
|
||||
pointHoverBorderColor: 'rgb(255, 0, 0)'
|
||||
}
|
||||
// {
|
||||
// label: 'Min',
|
||||
// data: loadData('min'), // Node Scope Only
|
||||
// fill: true,
|
||||
// backgroundColor: 'rgba(255, 0, 0, 0.25)',
|
||||
// borderColor: 'rgb(255, 0, 0)',
|
||||
// pointBackgroundColor: 'rgb(255, 0, 0)',
|
||||
// pointBorderColor: '#fff',
|
||||
// pointHoverBackgroundColor: '#fff',
|
||||
// pointHoverBorderColor: 'rgb(255, 0, 0)'
|
||||
// }
|
||||
]
|
||||
});
|
||||
|
||||
@@ -127,15 +133,14 @@
|
||||
{
|
||||
type: 'radar',
|
||||
data: data,
|
||||
options: options,
|
||||
height: height
|
||||
options: options
|
||||
}
|
||||
);
|
||||
});
|
||||
</script>
|
||||
|
||||
<!-- <div style="width: 500px;"><canvas id="dimensions"></canvas></div><br/> -->
|
||||
<div class="chart-container">
|
||||
<div class="chart-container d-flex justify-content-center" style="--container-width: {size}px; --container-height: {size}px">
|
||||
<canvas id={canvasId}></canvas>
|
||||
</div>
|
||||
|
||||
@@ -143,5 +148,7 @@
|
||||
.chart-container {
|
||||
margin: auto;
|
||||
position: relative;
|
||||
height: var(--container-height);
|
||||
width: var(--container-width);
|
||||
}
|
||||
</style>
|
||||
@@ -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 @@
|
||||
</script>
|
||||
|
||||
{#if roofData != null}
|
||||
<div bind:this={plotWrapper} class="p-2"></div>
|
||||
<div bind:this={plotWrapper} bind:clientWidth={width} class="p-2"></div>
|
||||
{:else}
|
||||
<Card class="mx-4 my-2" body color="warning">Cannot render roofline: No data!</Card>
|
||||
{/if}
|
||||
|
||||
@@ -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]',
|
||||
|
||||
@@ -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 @@
|
||||
<script>
|
||||
import uPlot from "uplot";
|
||||
import { formatUnixTime } from "../units.js";
|
||||
import { getContext, onMount, onDestroy } from "svelte";
|
||||
import { getContext, onDestroy } from "svelte";
|
||||
import { Card } from "@sveltestrap/sveltestrap";
|
||||
|
||||
/* Svelte 5 Props */
|
||||
let {
|
||||
width = 0,
|
||||
height = 300,
|
||||
data = null,
|
||||
xlabel = null,
|
||||
@@ -100,12 +98,6 @@
|
||||
}
|
||||
};
|
||||
|
||||
// Data Prep For uPlot
|
||||
const sortedData = data?.sort((a, b) => a.state.localeCompare(b.state)) || [];
|
||||
const collectLabel = sortedData.map(d => d.state);
|
||||
// Align Data to Timesteps, Introduces 'undefied' as placeholder, reiterate and set those to 0
|
||||
const collectData = (sortedData.length > 0) ? uPlot.join(sortedData.map(d => [d.times, d.counts])).map(d => d.map(i => i ? i : 0)) : [];
|
||||
|
||||
// STACKED CHART FUNCTIONS //
|
||||
function stack(data, omit) {
|
||||
let data2 = [];
|
||||
@@ -135,17 +127,17 @@
|
||||
};
|
||||
}
|
||||
|
||||
function getStackedOpts(title, width, height, series, data) {
|
||||
function getStackedOpts(optTitle, optWidth, optHeight, optSeries, optData) {
|
||||
let opts = {
|
||||
width,
|
||||
height,
|
||||
title,
|
||||
width: optWidth,
|
||||
height: optHeight,
|
||||
title: optTitle,
|
||||
plugins: [legendAsTooltipPlugin()],
|
||||
series,
|
||||
series: optSeries,
|
||||
axes: [
|
||||
{
|
||||
scale: "x",
|
||||
space: 25, // Tick Spacing
|
||||
// space: 25, // Tick Spacing
|
||||
rotate: 30,
|
||||
show: true,
|
||||
label: xlabel,
|
||||
@@ -174,25 +166,25 @@
|
||||
}
|
||||
};
|
||||
|
||||
let stacked = stack(data, i => false);
|
||||
let stacked = stack(optData, i => false);
|
||||
opts.bands = stacked.bands;
|
||||
|
||||
opts.cursor = opts.cursor || {};
|
||||
opts.cursor.dataIdx = (u, seriesIdx, closestIdx, xValue) => {
|
||||
return data[seriesIdx][closestIdx] == null ? null : closestIdx;
|
||||
return optData[seriesIdx][closestIdx] == null ? null : closestIdx;
|
||||
};
|
||||
|
||||
opts.series.forEach(s => {
|
||||
// Format Time Info from Unix TS to LocalTimeString
|
||||
s.value = (u, v, si, i) => (si === 0) ? formatUnixTime(data[si][i]) : data[si][i];
|
||||
s.value = (u, v, si, i) => (si === 0) ? formatUnixTime(optData[si][i]) : optData[si][i];
|
||||
|
||||
s.points = s.points || {};
|
||||
|
||||
// scan raw unstacked data to return only real points
|
||||
// scan raw unstacked optData to return only real points
|
||||
s.points.filter = (u, seriesIdx, show, gaps) => {
|
||||
if (show) {
|
||||
let pts = [];
|
||||
data[seriesIdx].forEach((v, i) => {
|
||||
optData[seriesIdx].forEach((v, i) => {
|
||||
v != null && pts.push(i);
|
||||
});
|
||||
return pts;
|
||||
@@ -212,7 +204,7 @@
|
||||
opts.hooks = {
|
||||
setSeries: [
|
||||
(u, i) => {
|
||||
let stacked = stack(data, i => !u.series[i].show);
|
||||
let stacked = stack(optData, i => !u.series[i].show);
|
||||
u.delBand(null);
|
||||
stacked.bands.forEach(b => u.addBand(b));
|
||||
u.setData(stacked.data);
|
||||
@@ -287,21 +279,28 @@
|
||||
};
|
||||
}
|
||||
|
||||
// UPLOT SERIES INIT
|
||||
const plotSeries = [
|
||||
{
|
||||
label: "Time",
|
||||
scale: "x"
|
||||
},
|
||||
...collectLabel.map(l => seriesConfig[l])
|
||||
]
|
||||
// UPLOT SERIES INIT: DERIVED FROM PROPS
|
||||
// Data Prep For uPlot
|
||||
const sortedData = $derived(data?.sort((a, b) => a.state.localeCompare(b.state)) || []);
|
||||
const collectLabel = $derived(sortedData.map(d => d.state));
|
||||
// Align Data to Timesteps, Introduces 'undefied' as placeholder, reiterate and set those to 0
|
||||
const collectData = $derived.by(() => {
|
||||
if (sortedData.length > 0) {
|
||||
return uPlot.join(sortedData.map(d => [d.times, d.counts])).map(d => d.map(i => i ? i : 0))
|
||||
} else {
|
||||
return [];
|
||||
}
|
||||
});
|
||||
// Build Series
|
||||
const plotSeries = $derived([{label: "Time", scale: "x"}, ...collectLabel.map(l => seriesConfig[l])]);
|
||||
|
||||
/* Var Init */
|
||||
let timeoutId = null;
|
||||
let uplot = null;
|
||||
|
||||
/* State Init */
|
||||
let plotWrapper = $state(null);
|
||||
let width = $state(0); // Wrapper Width
|
||||
let uplot = $state(null);
|
||||
|
||||
/* Effects */
|
||||
$effect(() => {
|
||||
@@ -311,6 +310,14 @@
|
||||
});
|
||||
|
||||
/* Functions */
|
||||
function onSizeChange(chg_width, chg_height) {
|
||||
if (timeoutId != null) clearTimeout(timeoutId);
|
||||
timeoutId = setTimeout(() => {
|
||||
timeoutId = null;
|
||||
render(chg_width, chg_height);
|
||||
}, 200);
|
||||
}
|
||||
|
||||
function render(ren_width, ren_height) {
|
||||
if (!uplot) {
|
||||
let { opts, data } = getStackedOpts(title, ren_width, ren_height, plotSeries, collectData);
|
||||
@@ -320,22 +327,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
function onSizeChange(chg_width, chg_height) {
|
||||
if (!uplot) return;
|
||||
if (timeoutId != null) clearTimeout(timeoutId);
|
||||
timeoutId = setTimeout(() => {
|
||||
timeoutId = null;
|
||||
render(chg_width, chg_height);
|
||||
}, 200);
|
||||
}
|
||||
|
||||
/* On Mount */
|
||||
onMount(() => {
|
||||
if (plotWrapper) {
|
||||
render(width, height);
|
||||
}
|
||||
});
|
||||
|
||||
/* On Destroy */
|
||||
onDestroy(() => {
|
||||
if (timeoutId != null) clearTimeout(timeoutId);
|
||||
|
||||
@@ -27,14 +27,16 @@
|
||||
} = $props();
|
||||
|
||||
/* State Init */
|
||||
let pendingValues = $state([fromPreset, toPreset]);
|
||||
let sliderFrom = $state(Math.max(((fromPreset == null ? sliderMin : fromPreset) - sliderMin) / (sliderMax - sliderMin), 0.));
|
||||
let sliderTo = $state(Math.min(((toPreset == null ? sliderMin : toPreset) - sliderMin) / (sliderMax - sliderMin), 1.));
|
||||
let inputFieldFrom = $state(fromPreset.toString());
|
||||
let inputFieldTo = $state(toPreset.toString());
|
||||
let leftHandle = $state();
|
||||
let sliderMain = $state();
|
||||
|
||||
/* Derived */
|
||||
let pendingValues = $derived([fromPreset, toPreset]);
|
||||
let sliderFrom = $derived(Math.max(((fromPreset == null ? sliderMin : fromPreset) - sliderMin) / (sliderMax - sliderMin), 0.));
|
||||
let sliderTo = $derived(Math.min(((toPreset == null ? sliderMin : toPreset) - sliderMin) / (sliderMax - sliderMin), 1.));
|
||||
let inputFieldFrom = $derived(fromPreset.toString());
|
||||
let inputFieldTo = $derived(toPreset.toString());
|
||||
|
||||
/* Var Init */
|
||||
let timeoutId = null;
|
||||
|
||||
|
||||
@@ -41,11 +41,11 @@
|
||||
]);
|
||||
|
||||
/* State Init */
|
||||
let sorting = $state({...presetSorting})
|
||||
let activeColumnIdx = $state(0);
|
||||
let metricSortables = $state([]);
|
||||
|
||||
/* Derived */
|
||||
let sorting = $derived({...presetSorting})
|
||||
let sortableColumns = $derived([...fixedSortables, ...metricSortables]);
|
||||
|
||||
/* Effect */
|
||||
|
||||
@@ -57,7 +57,7 @@ export function formatUnixTime(t, withDate = false) {
|
||||
return t;
|
||||
} else {
|
||||
if (withDate) return new Date(t * 1000).toLocaleString();
|
||||
else return new Date(t * 1000).toLocaleTimeString();
|
||||
else return new Date(t * 1000).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,19 +29,30 @@
|
||||
const showPolarTab = !!getContext("cc-config")[`jobView_showPolarPlot`];
|
||||
</script>
|
||||
|
||||
<Card class="overflow-auto" style="width: {width}; height: {height}">
|
||||
<Card style="width: {width}; height: {height}">
|
||||
{#if showFootprintTab && !showPolarTab}
|
||||
<JobFootprintBars {job} />
|
||||
{:else if !showFootprintTab && showPolarTab}
|
||||
<JobFootprintPolar {job}/>
|
||||
{:else if showFootprintTab && showPolarTab}
|
||||
<TabContent>
|
||||
{#if showFootprintTab}
|
||||
<TabPane tabId="foot" tab="Footprint" active={showFootprintTab}>
|
||||
<TabPane tabId="foot" tab="Footprint" active>
|
||||
<!-- Bars CardBody Here-->
|
||||
<JobFootprintBars {job} />
|
||||
</TabPane>
|
||||
{/if}
|
||||
{#if showPolarTab}
|
||||
<TabPane tabId="polar" tab="Polar" active={showPolarTab && !showFootprintTab}>
|
||||
<TabPane tabId="polar" tab="Polar">
|
||||
<!-- Polar Plot CardBody Here -->
|
||||
<JobFootprintPolar {job} />
|
||||
<JobFootprintPolar {job} showLegend={false}/>
|
||||
</TabPane>
|
||||
{/if}
|
||||
</TabContent>
|
||||
{:else}
|
||||
<Card color="info" class="m-2">
|
||||
<CardHeader class="mb-0">
|
||||
<b>Config</b>
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
<p class="mb-1">Footprint and PolarPlot Display Disabled.</p>
|
||||
</CardBody>
|
||||
</Card>
|
||||
{/if}
|
||||
</Card>
|
||||
|
||||
@@ -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,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user