Merge pull request #472 from ClusterCockpit/dev

Dev
This commit is contained in:
Jan Eitzinger
2026-01-23 07:54:17 +01:00
committed by GitHub
112 changed files with 2888 additions and 2092 deletions
+2 -2
View File
@@ -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
+2 -2
View File
@@ -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)
+3 -4
View File
@@ -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())
}
}
+3 -3
View File
@@ -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)
}
+1 -1
View File
@@ -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"
+5 -5
View File
@@ -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
}
}
}
+24 -12
View File
@@ -5,17 +5,22 @@
"https-key-file": "/etc/letsencrypt/live/url/privkey.pem",
"user": "clustercockpit",
"group": "clustercockpit",
"apiAllowedIPs": ["*"],
"api-allowed-ips": [
"*"
],
"short-running-jobs-duration": 300,
"enable-job-taggers": true,
"resampling": {
"minimumPoints": 600,
"minimum-points": 600,
"trigger": 180,
"resolutions": [240, 60]
"resolutions": [
240,
60
]
},
"apiSubjects": {
"subjectJobEvent": "cc.job.event",
"subjectNodeState": "cc.node.state"
"api-subjects": {
"subject-job-event": "cc.job.event",
"subject-node-state": "cc.node.state"
}
},
"nats": {
@@ -37,8 +42,8 @@
"kind": "s3",
"endpoint": "http://x.x.x.x",
"bucket": "jobarchive",
"accessKey": "xx",
"secretKey": "xx",
"access-key": "xx",
"secret-key": "xx",
"retention": {
"policy": "move",
"age": 365,
@@ -47,10 +52,17 @@
},
"metric-store": {
"checkpoints": {
"interval": "1h"
"interval": "12h",
"directory": "./var/checkpoints"
},
"retention-in-memory": "12h",
"subscriptions": [
"memory-cap": 100,
"retention-in-memory": "48h",
"cleanup": {
"mode": "archive",
"interval": "48h",
"directory": "./var/archive"
},
"nats-subscriptions": [
{
"subscribe-to": "hpc-nats",
"cluster-tag": "fritz"
@@ -62,4 +74,4 @@
]
},
"ui-file": "ui-config.json"
}
}
+21 -21
View File
@@ -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",
+4 -4
View File
@@ -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)
}
+1 -1
View File
@@ -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"
+4 -4
View File
@@ -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)
}
-8
View File
@@ -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)
}
+9 -4
View File
@@ -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] == "*" {
+5 -5
View File
@@ -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 {
+8 -8
View File
@@ -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 {
+2 -2
View File
@@ -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 {
+31 -24
View File
@@ -15,37 +15,44 @@ var configSchema = `
"description": "Configure how long a token is valid. As string parsable by time.ParseDuration()",
"type": "string"
},
"cookieName": {
"cookie-name": {
"description": "Cookie that should be checked for a JWT token.",
"type": "string"
},
"validateUser": {
"validate-user": {
"description": "Deny login for users not in database (but defined in JWT). Overwrite roles in JWT with database roles.",
"type": "boolean"
},
"trustedIssuer": {
"trusted-issuer": {
"description": "Issuer that should be accepted when validating external JWTs ",
"type": "string"
},
"syncUserOnLogin": {
"sync-user-on-login": {
"description": "Add non-existent user to DB at login attempt with values provided in JWT.",
"type": "boolean"
},
"update-user-on-login": {
"description": "Should an existent user attributes in the DB be updated at login attempt with values provided in JWT.",
"type": "boolean"
}
},
"required": ["max-age"]
},
"oidc": {
"provider": {
"description": "",
"type": "string"
},
"syncUserOnLogin": {
"description": "",
"type": "boolean"
},
"updateUserOnLogin": {
"description": "",
"type": "boolean"
"type": "object",
"properties": {
"provider": {
"description": "OpenID Connect provider URL.",
"type": "string"
},
"sync-user-on-login": {
"description": "Add non-existent user to DB at login attempt with values provided.",
"type": "boolean"
},
"update-user-on-login": {
"description": "Should an existent user attributes in the DB be updated at login attempt with values provided.",
"type": "boolean"
}
},
"required": ["provider"]
},
@@ -57,40 +64,40 @@ var configSchema = `
"description": "URL of LDAP directory server.",
"type": "string"
},
"user_base": {
"user-base": {
"description": "Base DN of user tree root.",
"type": "string"
},
"search_dn": {
"search-dn": {
"description": "DN for authenticating LDAP admin account with general read rights.",
"type": "string"
},
"user_bind": {
"user-bind": {
"description": "Expression used to authenticate users via LDAP bind. Must contain uid={username}.",
"type": "string"
},
"user_filter": {
"user-filter": {
"description": "Filter to extract users for syncing.",
"type": "string"
},
"username_attr": {
"username-attr": {
"description": "Attribute with full username. Default: gecos",
"type": "string"
},
"sync_interval": {
"sync-interval": {
"description": "Interval used for syncing local user table with LDAP directory. Parsed using time.ParseDuration.",
"type": "string"
},
"sync_del_old_users": {
"sync-del-old-users": {
"description": "Delete obsolete users in database.",
"type": "boolean"
},
"syncUserOnLogin": {
"sync-user-on-login": {
"description": "Add non-existent user to DB at login attempt if user exists in Ldap directory",
"type": "boolean"
}
},
"required": ["url", "user_base", "search_dn", "user_bind", "user_filter"]
"required": ["url", "user-base", "search-dn", "user-bind", "user-filter"]
},
"required": ["jwts"]
}`
+7 -18
View File
@@ -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,
+1 -1
View File
@@ -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 {
+9 -14
View File
@@ -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"]
}`
}
}`
+3 -3
View File
@@ -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()
}
+5 -7
View File
@@ -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)
}
-190
View File
@@ -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
}
-128
View File
@@ -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
}
-215
View File
@@ -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
}
+2 -6
View File
@@ -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())
+3 -3
View File
@@ -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)
}
}
+1 -1
View File
@@ -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()
}
+2 -2
View File
@@ -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")
+1 -1
View File
@@ -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"
+5 -5
View File
@@ -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"
},
+7 -10
View File
@@ -181,11 +181,10 @@ type JobContainer struct {
}
var (
initOnce sync.Once
cache *lrucache.Cache = lrucache.New(128 * 1024 * 1024)
ar ArchiveBackend
useArchive bool
mutex sync.Mutex
initOnce sync.Once
cache *lrucache.Cache = lrucache.New(128 * 1024 * 1024)
ar ArchiveBackend
mutex sync.Mutex
)
// Init initializes the archive backend with the provided configuration.
@@ -197,12 +196,10 @@ var (
//
// The configuration determines which backend is used (file, s3, or sqlite).
// Returns an error if initialization fails or version is incompatible.
func Init(rawConfig json.RawMessage, disableArchive bool) error {
func Init(rawConfig json.RawMessage) error {
var err error
initOnce.Do(func() {
useArchive = !disableArchive
var cfg struct {
Kind string `json:"kind"`
}
@@ -378,7 +375,7 @@ func UpdateMetadata(job *schema.Job, metadata map[string]string) error {
mutex.Lock()
defer mutex.Unlock()
if job.State == schema.JobStateRunning || !useArchive {
if job.State == schema.JobStateRunning {
return nil
}
@@ -401,7 +398,7 @@ func UpdateTags(job *schema.Job, tags []*schema.Tag) error {
mutex.Lock()
defer mutex.Unlock()
if job.State == schema.JobStateRunning || !useArchive {
if job.State == schema.JobStateRunning {
return nil
}
+1 -1
View File
@@ -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)
}
+1 -1
View File
@@ -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)
}
+6 -6
View File
@@ -35,12 +35,12 @@ import (
// S3ArchiveConfig holds the configuration for the S3 archive backend.
type S3ArchiveConfig struct {
Endpoint string `json:"endpoint"` // S3 endpoint URL (optional, for MinIO/localstack)
AccessKey string `json:"accessKey"` // AWS access key ID
SecretKey string `json:"secretKey"` // AWS secret access key
Bucket string `json:"bucket"` // S3 bucket name
Region string `json:"region"` // AWS region
UsePathStyle bool `json:"usePathStyle"` // Use path-style URLs (required for MinIO)
Endpoint string `json:"endpoint"` // S3 endpoint URL (optional, for MinIO/localstack)
AccessKey string `json:"access-key"` // AWS access key ID
SecretKey string `json:"secret-key"` // AWS secret access key
Bucket string `json:"bucket"` // S3 bucket name
Region string `json:"region"` // AWS region
UsePathStyle bool `json:"use-path-style"` // Use path-style URLs (required for MinIO)
}
// S3Archive implements ArchiveBackend using AWS S3 or S3-compatible object storage.
+3 -3
View File
@@ -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
+1 -1
View File
@@ -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.
+12 -12
View File
@@ -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 = errors.New("[METRICSTORE]> cluster name cannot be empty")
// ErrEmptyCluster is returned when a query with ForAllNodes has no cluster specified
ErrEmptyCluster = errors.New("[METRICSTORE]> cluster name cannot be empty")
)
// APIMetricData represents the response data for a single metric query.
//
// It contains both the time-series data points and computed statistics (avg, min, max).
// If an error occurred during data retrieval, the Error field will be set and other
// fields may be incomplete.
type APIMetricData struct {
Error *string `json:"error,omitempty"`
Data schema.FloatArray `json:"data,omitempty"`
@@ -30,6 +40,13 @@ type APIMetricData struct {
Max schema.Float `json:"max"`
}
// APIQueryRequest represents a batch query request for metric data.
//
// It supports two modes of operation:
// 1. Explicit queries via the Queries field
// 2. Automatic query generation via ForAllNodes (queries all specified metrics for all nodes in the cluster)
//
// The request can be customized with flags to include/exclude statistics, raw data, and padding.
type APIQueryRequest struct {
Cluster string `json:"cluster"`
Queries []APIQuery `json:"queries"`
@@ -41,11 +58,25 @@ type APIQueryRequest struct {
WithPadding bool `json:"with-padding"`
}
// APIQueryResponse represents the response to an APIQueryRequest.
//
// Results is a 2D array where each outer element corresponds to a query,
// and each inner element corresponds to a selector within that query
// (e.g., multiple CPUs or cores).
type APIQueryResponse struct {
Queries []APIQuery `json:"queries,omitempty"`
Results [][]APIMetricData `json:"results"`
}
// APIQuery represents a single metric query with optional hierarchical selectors.
//
// The hierarchical selection works as follows:
// - Hostname: The node to query
// - Type + TypeIds: First level of hierarchy (e.g., "cpu" + ["0", "1", "2"])
// - SubType + SubTypeIds: Second level of hierarchy (e.g., "core" + ["0", "1"])
//
// If Aggregate is true, data from multiple type/subtype IDs will be aggregated according
// to the metric's aggregation strategy. Otherwise, separate results are returned for each combination.
type APIQuery struct {
Type *string `json:"type,omitempty"`
SubType *string `json:"subtype,omitempty"`
@@ -58,6 +89,11 @@ type APIQuery struct {
Aggregate bool `json:"aggreg"`
}
// AddStats computes and populates the Avg, Min, and Max fields from the Data array.
//
// NaN values in the data are ignored during computation. If all values are NaN,
// the statistics fields will be set to NaN.
//
// TODO: Optimize this, just like the stats endpoint!
func (data *APIMetricData) AddStats() {
n := 0
@@ -83,6 +119,10 @@ func (data *APIMetricData) AddStats() {
}
}
// ScaleBy multiplies all data points and statistics by the given factor.
//
// This is commonly used for unit conversion (e.g., bytes to gigabytes).
// Scaling by 0 or 1 is a no-op for performance reasons.
func (data *APIMetricData) ScaleBy(f schema.Float) {
if f == 0 || f == 1 {
return
@@ -96,6 +136,17 @@ func (data *APIMetricData) ScaleBy(f schema.Float) {
}
}
// PadDataWithNull pads the beginning of the data array with NaN values if needed.
//
// This ensures that the data aligns with the requested 'from' timestamp, even if
// the metric store doesn't have data for the earliest time points. This is useful
// for maintaining consistent array indexing across multiple queries.
//
// Parameters:
// - ms: MemoryStore instance to lookup metric configuration
// - from: The requested start timestamp
// - to: The requested end timestamp (unused but kept for API consistency)
// - metric: The metric name to lookup frequency information
func (data *APIMetricData) PadDataWithNull(ms *MemoryStore, from, to int64, metric string) {
minfo, ok := ms.Metrics[metric]
if !ok {
@@ -115,6 +166,31 @@ func (data *APIMetricData) PadDataWithNull(ms *MemoryStore, from, to int64, metr
}
}
// FetchData executes a batch metric query request and returns the results.
//
// This is the primary API for retrieving metric data from the memory store. It supports:
// - Individual queries via req.Queries
// - Batch queries for all nodes via req.ForAllNodes
// - Hierarchical selector construction (cluster → host → type → subtype)
// - Optional statistics computation (avg, min, max)
// - Optional data scaling
// - Optional data padding with NaN values
//
// The function constructs selectors based on the query parameters and calls MemoryStore.Read()
// for each selector. If a query specifies Aggregate=false with multiple type/subtype IDs,
// separate results are returned for each combination.
//
// Parameters:
// - req: The query request containing queries, time range, and options
//
// Returns:
// - APIQueryResponse containing results for each query, or error if validation fails
//
// Errors:
// - ErrInvalidTimeRange if req.From > req.To
// - ErrEmptyCluster if req.ForAllNodes is used without specifying a cluster
// - Error if MemoryStore is not initialized
// - Individual query errors are stored in APIMetricData.Error field
func FetchData(req APIQueryRequest) (*APIQueryResponse, error) {
if req.From > req.To {
return nil, ErrInvalidTimeRange
@@ -126,7 +202,7 @@ func FetchData(req APIQueryRequest) (*APIQueryResponse, error) {
req.WithData = true
ms := GetMemoryStore()
if ms == nil {
return nil, fmt.Errorf("memorystore not initialized")
return nil, fmt.Errorf("[METRICSTORE]> memorystore not initialized")
}
response := APIQueryResponse{
@@ -195,8 +271,6 @@ func FetchData(req APIQueryRequest) (*APIQueryResponse, error) {
}
}
// log.Printf("query: %#v\n", query)
// log.Printf("sels: %#v\n", sels)
var err error
res := make([]APIMetricData, 0, len(sels))
for _, sel := range sels {
@@ -21,12 +21,40 @@ import (
cclog "github.com/ClusterCockpit/cc-lib/v2/ccLogger"
)
func Archiving(wg *sync.WaitGroup, ctx context.Context) {
// Worker for either Archiving or Deleting files
func CleanUp(wg *sync.WaitGroup, ctx context.Context) {
if Keys.Cleanup.Mode == "archive" {
// Run as Archiver
cleanUpWorker(wg, ctx,
Keys.Cleanup.Interval,
"archiving",
Keys.Cleanup.RootDir,
false,
)
} else {
if Keys.Cleanup.Interval == "" {
Keys.Cleanup.Interval = Keys.RetentionInMemory
}
// Run as Deleter
cleanUpWorker(wg, ctx,
Keys.Cleanup.Interval,
"deleting",
"",
true,
)
}
}
// runWorker takes simple values to configure what it does
func cleanUpWorker(wg *sync.WaitGroup, ctx context.Context, interval string, mode string, cleanupDir string, delete bool) {
go func() {
defer wg.Done()
d, err := time.ParseDuration(Keys.Archive.ArchiveInterval)
d, err := time.ParseDuration(interval)
if err != nil {
cclog.Fatalf("[METRICSTORE]> error parsing archive interval duration: %v\n", err)
cclog.Fatalf("[METRICSTORE]> error parsing %s interval duration: %v\n", mode, err)
}
if d <= 0 {
return
@@ -41,14 +69,18 @@ func Archiving(wg *sync.WaitGroup, ctx context.Context) {
return
case <-ticker.C:
t := time.Now().Add(-d)
cclog.Infof("[METRICSTORE]> start archiving checkpoints (older than %s)...", t.Format(time.RFC3339))
n, err := ArchiveCheckpoints(Keys.Checkpoints.RootDir,
Keys.Archive.RootDir, t.Unix(), Keys.Archive.DeleteInstead)
cclog.Infof("[METRICSTORE]> start %s checkpoints (older than %s)...", mode, t.Format(time.RFC3339))
n, err := CleanupCheckpoints(Keys.Checkpoints.RootDir, cleanupDir, t.Unix(), delete)
if err != nil {
cclog.Errorf("[METRICSTORE]> archiving failed: %s", err.Error())
cclog.Errorf("[METRICSTORE]> %s failed: %s", mode, err.Error())
} else {
cclog.Infof("[METRICSTORE]> done: %d files zipped and moved to archive", n)
if delete && cleanupDir == "" {
cclog.Infof("[METRICSTORE]> done: %d checkpoints deleted", n)
} else {
cclog.Infof("[METRICSTORE]> done: %d files zipped and moved to archive", n)
}
}
}
}
@@ -57,9 +89,9 @@ func Archiving(wg *sync.WaitGroup, ctx context.Context) {
var ErrNoNewArchiveData error = errors.New("all data already archived")
// ZIP all checkpoint files older than `from` together and write them to the `archiveDir`,
// deleting them from the `checkpointsDir`.
func ArchiveCheckpoints(checkpointsDir, archiveDir string, from int64, deleteInstead bool) (int, error) {
// Delete or ZIP all checkpoint files older than `from` together and write them to the `cleanupDir`,
// deleting/moving them from the `checkpointsDir`.
func CleanupCheckpoints(checkpointsDir, cleanupDir string, from int64, deleteInstead bool) (int, error) {
entries1, err := os.ReadDir(checkpointsDir)
if err != nil {
return 0, err
@@ -79,7 +111,7 @@ func ArchiveCheckpoints(checkpointsDir, archiveDir string, from int64, deleteIns
go func() {
defer wg.Done()
for workItem := range work {
m, err := archiveCheckpoints(workItem.cdir, workItem.adir, from, deleteInstead)
m, err := cleanupCheckpoints(workItem.cdir, workItem.adir, from, deleteInstead)
if err != nil {
cclog.Errorf("error while archiving %s/%s: %s", workItem.cluster, workItem.host, err.Error())
atomic.AddInt32(&errs, 1)
@@ -97,7 +129,7 @@ func ArchiveCheckpoints(checkpointsDir, archiveDir string, from int64, deleteIns
for _, de2 := range entries2 {
cdir := filepath.Join(checkpointsDir, de1.Name(), de2.Name())
adir := filepath.Join(archiveDir, de1.Name(), de2.Name())
adir := filepath.Join(cleanupDir, de1.Name(), de2.Name())
work <- workItem{
adir: adir, cdir: cdir,
cluster: de1.Name(), host: de2.Name(),
@@ -118,8 +150,8 @@ func ArchiveCheckpoints(checkpointsDir, archiveDir string, from int64, deleteIns
return int(n), nil
}
// Helper function for `ArchiveCheckpoints`.
func archiveCheckpoints(dir string, archiveDir string, from int64, deleteInstead bool) (int, error) {
// Helper function for `CleanupCheckpoints`.
func cleanupCheckpoints(dir string, cleanupDir string, from int64, deleteInstead bool) (int, error) {
entries, err := os.ReadDir(dir)
if err != nil {
return 0, err
@@ -143,10 +175,10 @@ func archiveCheckpoints(dir string, archiveDir string, from int64, deleteInstead
return n, nil
}
filename := filepath.Join(archiveDir, fmt.Sprintf("%d.zip", from))
filename := filepath.Join(cleanupDir, fmt.Sprintf("%d.zip", from))
f, err := os.OpenFile(filename, os.O_CREATE|os.O_WRONLY, CheckpointFilePerms)
if err != nil && os.IsNotExist(err) {
err = os.MkdirAll(archiveDir, CheckpointDirPerms)
err = os.MkdirAll(cleanupDir, CheckpointDirPerms)
if err == nil {
f, err = os.OpenFile(filename, os.O_CREATE|os.O_WRONLY, CheckpointFilePerms)
}
+318
View File
@@ -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())
}
}
}
+261
View File
@@ -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"
}
},
"required": ["interval", "directory"]
"if": {
"properties": {
"mode": { "const": "archive" }
}
},
"then": {
"required": ["interval", "directory"]
}
},
"retention-in-memory": {
"description": "Keep the metrics within memory for given time interval. Retention for X hours, then the metrics would be freed.",
@@ -56,13 +68,13 @@ const configSchema = `{
"type": "integer"
},
"nats-subscriptions": {
"description": "Array of various subscriptions. Allows to subscibe to different subjects and publishers.",
"description": "Array of various subscriptions. Allows to subscribe to different subjects and publishers.",
"type": "array",
"items": {
"type": "object",
"properties": {
"subscribe-to": {
"description": "Channel name",
"description": "Subject name",
"type": "string"
},
"cluster-tag": {
@@ -70,8 +82,9 @@ const configSchema = `{
"type": "string"
}
}
"required": ["subscribe-to"]
}
}
},
"required": ["checkpoints", "retention-in-memory"]
"required": ["checkpoints", "retention-in-memory", "memory-cap"]
}`
+388
View File
@@ -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
shutdownFunc context.CancelFunc
shutdownFuncMu sync.Mutex // Protects shutdownFunc from concurrent access
// Create a global instance
state = &GlobalState{}
)
// NodeProvider provides information about nodes currently in use by running jobs.
//
// This interface allows metricstore to query job information without directly
// depending on the repository package, breaking the import cycle.
//
// Implementations should return nodes that are actively processing jobs started
// before the given timestamp. These nodes will be excluded from retention-based
// garbage collection to prevent data loss for jobs that are still running or
// recently completed.
type NodeProvider interface {
// GetUsedNodes returns a map of cluster names to sorted lists of unique hostnames
// that are currently in use by jobs that started before the given timestamp.
//
// Parameters:
// - ts: Unix timestamp threshold - returns nodes with jobs started before this time
//
// Returns:
// - Map of cluster names to lists of node hostnames that should be excluded from garbage collection
// - Error if the query fails
GetUsedNodes(ts int64) (map[string][]string, error)
}
// Metric represents a single metric data point to be written to the store.
type Metric struct {
Name string
Value schema.Float
Name string
Value schema.Float
// MetricConfig contains frequency and aggregation settings for this metric.
// If Frequency is 0, configuration will be looked up from MemoryStore.Metrics during Write().
MetricConfig MetricConfig
}
// MemoryStore is the main in-memory time-series metric storage implementation.
//
// It organizes metrics in a hierarchical tree structure where each level represents
// a component of the system hierarchy (e.g., cluster → host → CPU). Each level can
// store multiple metrics as time-series buffers.
//
// The store is initialized as a singleton via InitMetrics() and accessed via GetMemoryStore().
// All public methods are safe for concurrent use.
type MemoryStore struct {
Metrics map[string]MetricConfig
root Level
nodeProvider NodeProvider // Injected dependency for querying running jobs
nodeProvider NodeProvider
}
// Init initializes the metric store from configuration and starts background workers.
//
// This function must be called exactly once before any other metricstore operations.
// It performs the following initialization steps:
// 1. Validates and decodes the metric store configuration
// 2. Configures worker pool size (defaults to NumCPU/2+1, max 10)
// 3. Loads metric configurations from all registered clusters
// 4. Restores checkpoints within the retention window
// 5. Starts background workers for retention, checkpointing, archiving, and monitoring
// 6. Optionally subscribes to NATS for real-time metric ingestion
//
// Parameters:
// - rawConfig: JSON configuration for the metric store (see MetricStoreConfig)
// - wg: WaitGroup that will be incremented for each background goroutine started
//
// The function will call cclog.Fatal on critical errors during initialization.
// Use Shutdown() to cleanly stop all background workers started by Init().
//
// Note: Signal handling must be implemented by the caller. Call Shutdown() when
// receiving termination signals to ensure checkpoint data is persisted.
func Init(rawConfig json.RawMessage, wg *sync.WaitGroup) {
startupTime := time.Now()
@@ -142,25 +196,30 @@ func Init(rawConfig json.RawMessage, wg *sync.WaitGroup) {
retentionGoroutines := 1
checkpointingGoroutines := 1
dataStagingGoroutines := 1
archivingGoroutines := 0
if Keys.Archive != nil {
archivingGoroutines = 1
}
totalGoroutines := retentionGoroutines + checkpointingGoroutines + dataStagingGoroutines + archivingGoroutines
archivingGoroutines := 1
memoryUsageTracker := 1
totalGoroutines := retentionGoroutines +
checkpointingGoroutines +
dataStagingGoroutines +
archivingGoroutines +
memoryUsageTracker
wg.Add(totalGoroutines)
Retention(wg, ctx)
Checkpointing(wg, ctx)
if Keys.Archive != nil {
Archiving(wg, ctx)
}
CleanUp(wg, ctx)
DataStaging(wg, ctx)
MemoryUsageTracker(wg, ctx)
// Note: Signal handling has been removed from this function.
// The caller is responsible for handling shutdown signals and calling
// the shutdown() function when appropriate.
// Store the shutdown function for later use by Shutdown()
shutdownFuncMu.Lock()
shutdownFunc = shutdown
shutdownFuncMu.Unlock()
if Keys.Subscriptions != nil {
err = ReceiveNats(ms, 1, ctx)
@@ -170,8 +229,17 @@ func Init(rawConfig json.RawMessage, wg *sync.WaitGroup) {
}
}
// InitMetrics creates a new, initialized instance of a MemoryStore.
// Will panic if values in the metric configurations are invalid.
// InitMetrics initializes the singleton MemoryStore instance with the given metric configurations.
//
// This function must be called before GetMemoryStore() and can only be called once due to
// the singleton pattern. It assigns each metric an internal offset for efficient buffer indexing.
//
// Parameters:
// - metrics: Map of metric names to their configurations (frequency and aggregation strategy)
//
// Panics if any metric has Frequency == 0, which indicates an invalid configuration.
//
// After this call, the global msInstance is ready for use via GetMemoryStore().
func InitMetrics(metrics map[string]MetricConfig) {
singleton.Do(func() {
offset := 0
@@ -198,6 +266,11 @@ func InitMetrics(metrics map[string]MetricConfig) {
})
}
// GetMemoryStore returns the singleton MemoryStore instance.
//
// Returns the initialized MemoryStore singleton. Calls cclog.Fatal if InitMetrics() was not called first.
//
// This function is safe for concurrent use after initialization.
func GetMemoryStore() *MemoryStore {
if msInstance == nil {
cclog.Fatalf("[METRICSTORE]> MemoryStore not initialized!")
@@ -214,7 +287,22 @@ func (ms *MemoryStore) SetNodeProvider(provider NodeProvider) {
ms.nodeProvider = provider
}
// Shutdown performs a graceful shutdown of the metric store.
//
// This function cancels all background goroutines started by Init() and writes
// a final checkpoint to disk before returning. It should be called when the
// application receives a termination signal.
//
// The function will:
// 1. Cancel the context to stop all background workers
// 2. Close NATS message channels if using Avro format
// 3. Write a final checkpoint to preserve in-memory data
// 4. Log any errors encountered during shutdown
//
// Note: This function blocks until the final checkpoint is written.
func Shutdown() {
shutdownFuncMu.Lock()
defer shutdownFuncMu.Unlock()
if shutdownFunc != nil {
shutdownFunc()
}
@@ -241,6 +329,17 @@ func Shutdown() {
cclog.Infof("[METRICSTORE]> Done! (%d files written)\n", files)
}
// Retention starts a background goroutine that periodically frees old metric data.
//
// This worker runs at half the retention interval and calls Free() to remove buffers
// older than the configured retention time. It respects the NodeProvider to preserve
// data for nodes with active jobs.
//
// Parameters:
// - wg: WaitGroup to signal completion when context is cancelled
// - ctx: Context for cancellation signal
//
// The goroutine exits when ctx is cancelled.
func Retention(wg *sync.WaitGroup, ctx context.Context) {
ms := GetMemoryStore()
@@ -266,7 +365,12 @@ func Retention(wg *sync.WaitGroup, ctx context.Context) {
case <-ctx.Done():
return
case <-ticker.C:
state.mu.Lock()
t := time.Now().Add(-d)
state.lastRetentionTime = t.Unix()
cclog.Infof("[METRICSTORE]> start freeing buffers (older than %s)...\n", t.Format(time.RFC3339))
freed, err := Free(ms, t)
@@ -275,11 +379,125 @@ func Retention(wg *sync.WaitGroup, ctx context.Context) {
} else {
cclog.Infof("[METRICSTORE]> done: %d buffers freed\n", freed)
}
state.mu.Unlock()
}
}
}()
}
// MemoryUsageTracker starts a background goroutine that monitors memory usage.
//
// This worker checks memory usage every minute and force-frees buffers if memory
// exceeds the configured cap. It protects against infinite loops by limiting
// iterations and forcing garbage collection between attempts.
//
// Parameters:
// - wg: WaitGroup to signal completion when context is cancelled
// - ctx: Context for cancellation signal
//
// The goroutine exits when ctx is cancelled.
func MemoryUsageTracker(wg *sync.WaitGroup, ctx context.Context) {
ms := GetMemoryStore()
go func() {
defer wg.Done()
d := DefaultMemoryUsageTrackerInterval
if d <= 0 {
return
}
ticker := time.NewTicker(d)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
state.mu.RLock()
memoryUsageGB := ms.SizeInGB()
cclog.Infof("[METRICSTORE]> current memory usage: %.2f GB\n", memoryUsageGB)
freedTotal := 0
var err error
// First force-free all the checkpoints that were
if state.lastRetentionTime != 0 && state.selectorsExcluded {
freedTotal, err = ms.Free(nil, state.lastRetentionTime)
if err != nil {
cclog.Errorf("[METRICSTORE]> error while force-freeing the excluded buffers: %s", err)
}
// Calling runtime.GC() twice in succession tp completely empty a bufferPool (sync.Pool)
runtime.GC()
runtime.GC()
cclog.Infof("[METRICSTORE]> done: %d excluded buffers force-freed\n", freedTotal)
}
state.mu.RUnlock()
memoryUsageGB = ms.SizeInGB()
if memoryUsageGB > float64(Keys.MemoryCap) {
cclog.Warnf("[METRICSTORE]> memory usage is still greater than the Memory Cap: %d GB\n", Keys.MemoryCap)
cclog.Warnf("[METRICSTORE]> starting to force-free the buffers from the Metric Store\n")
const maxIterations = 100
for range maxIterations {
memoryUsageGB = ms.SizeInGB()
if memoryUsageGB < float64(Keys.MemoryCap) {
break
}
freed, err := ms.ForceFree()
if err != nil {
cclog.Errorf("[METRICSTORE]> error while force-freeing the buffers: %s", err)
}
if freed == 0 {
cclog.Errorf("[METRICSTORE]> 0 buffers force-freed in last try, %d total buffers force-freed, memory usage of %.2f GB remains higher than the memory cap of %d GB and there are no buffers left to force-free\n", freedTotal, memoryUsageGB, Keys.MemoryCap)
break
}
freedTotal += freed
runtime.GC()
}
if memoryUsageGB >= float64(Keys.MemoryCap) {
cclog.Errorf("[METRICSTORE]> reached maximum iterations (%d) or no more buffers to free, current memory usage: %.2f GB\n", maxIterations, memoryUsageGB)
} else {
cclog.Infof("[METRICSTORE]> done: %d buffers force-freed\n", freedTotal)
cclog.Infof("[METRICSTORE]> current memory usage after force-freeing the buffers: %.2f GB\n", memoryUsageGB)
}
}
}
}
}()
}
// Free removes metric data older than the given time while preserving data for active nodes.
//
// This function implements intelligent retention by consulting the NodeProvider (if configured)
// to determine which nodes are currently in use by running jobs. Data for these nodes is
// preserved even if older than the retention time.
//
// Parameters:
// - ms: The MemoryStore instance
// - t: Time threshold - buffers with data older than this will be freed
//
// Returns:
// - Number of buffers freed
// - Error if NodeProvider query fails
//
// Behavior:
// - If no NodeProvider is set: frees all buffers older than t
// - If NodeProvider returns empty map: frees all buffers older than t
// - Otherwise: preserves buffers for nodes returned by GetUsedNodes(), frees others
func Free(ms *MemoryStore, t time.Time) (int, error) {
// If no NodeProvider is configured, free all buffers older than t
if ms.nodeProvider == nil {
@@ -291,30 +509,34 @@ func Free(ms *MemoryStore, t time.Time) (int, error) {
return 0, err
}
// excludeSelectors := make(map[string][]string, 0)
// excludeSelectors := map[string][]string{
// "alex": {"a0122", "a0123", "a0225"},
// "fritz": {"f0201", "f0202"},
// }
switch lenMap := len(excludeSelectors); lenMap {
// If the length of the map returned by GetUsedNodes() is 0,
// then use default Free method with nil selector
case 0:
state.selectorsExcluded = false
return ms.Free(nil, t.Unix())
// Else formulate selectors, exclude those from the map
// and free the rest of the selectors
default:
state.selectorsExcluded = true
selectors := GetSelectors(ms, excludeSelectors)
return FreeSelected(ms, selectors, t)
}
}
// A function to free specific selectors. Used when we want to retain some specific nodes
// beyond the retention time.
// FreeSelected frees buffers for specific selectors while preserving others.
//
// This function is used when we want to retain some specific nodes beyond the retention time.
// It iterates through the provided selectors and frees their associated buffers.
//
// Parameters:
// - ms: The MemoryStore instance
// - selectors: List of selector paths to free (e.g., [["cluster1", "node1"], ["cluster2", "node2"]])
// - t: Time threshold for freeing buffers
//
// Returns the total number of buffers freed and any error encountered.
func FreeSelected(ms *MemoryStore, selectors [][]string, t time.Time) (int, error) {
freed := 0
@@ -331,8 +553,22 @@ func FreeSelected(ms *MemoryStore, selectors [][]string, t time.Time) (int, erro
return freed, nil
}
// This function will populate all the second last levels - meaning nodes
// From that we can exclude the specific selectosr/node we want to retain.
// GetSelectors returns all selectors at depth 2 (cluster/node level) that are NOT in the exclusion map.
//
// This function generates a list of selectors whose buffers should be freed by excluding
// selectors that correspond to nodes currently in use by running jobs.
//
// Parameters:
// - ms: The MemoryStore instance
// - excludeSelectors: Map of cluster names to node hostnames that should NOT be freed
//
// Returns a list of selectors ([]string paths) that can be safely freed.
//
// Example:
//
// If the tree has paths ["emmy", "node001"] and ["emmy", "node002"],
// and excludeSelectors contains {"emmy": ["node001"]},
// then only [["emmy", "node002"]] is returned.
func GetSelectors(ms *MemoryStore, excludeSelectors map[string][]string) [][]string {
allSelectors := ms.GetPaths(2)
@@ -361,9 +597,6 @@ func GetSelectors(ms *MemoryStore, excludeSelectors map[string][]string) [][]str
}
}
// fmt.Printf("All selectors: %#v\n\n", allSelectors)
// fmt.Printf("filteredSelectors: %#v\n\n", filteredSelectors)
return filteredSelectors
}
@@ -386,6 +619,7 @@ func (m *MemoryStore) Write(selector []string, ts int64, metrics []Metric) error
if metric.MetricConfig.Frequency == 0 {
metric.MetricConfig, ok = m.Metrics[metric.Name]
if !ok {
cclog.Debugf("[METRICSTORE]> Unknown metric '%s' in Write() - skipping", metric.Name)
metric.MetricConfig.Frequency = 0
}
metrics[i] = metric
@@ -506,6 +740,12 @@ func (m *MemoryStore) Free(selector []string, t int64) (int, error) {
return m.GetLevel(selector).free(t)
}
// Free releases all buffers for the selected level and all its children that
// contain only values older than `t`.
func (m *MemoryStore) ForceFree() (int, error) {
return m.GetLevel(nil).forceFree()
}
func (m *MemoryStore) FreeAll() error {
for k := range m.root.children {
delete(m.root.children, k)
@@ -518,6 +758,10 @@ func (m *MemoryStore) SizeInBytes() int64 {
return m.root.sizeInBytes()
}
func (m *MemoryStore) SizeInGB() float64 {
return float64(m.root.sizeInBytes()) / 1e9
}
// ListChildren , given a selector, returns a list of all children of the level
// selected.
func (m *MemoryStore) ListChildren(selector []string) []string {
@@ -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
}
+4 -4
View File
@@ -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())
+1 -1
View File
@@ -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
View File
@@ -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": {
+158 -139
View File
@@ -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"
+6 -5
View File
@@ -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"
}
+3
View File
@@ -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(),
+250 -242
View File
@@ -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,274 +348,288 @@
</script>
<Card style="height: 98vh;">
<CardBody class="align-content-center p-2">
<Row>
<Row>
<Col>
<Refresher
hideSelector
initially={60}
onRefresh={(interval) => {
from = new Date(Date.now() - 5 * 60 * 1000);
to = new Date(Date.now());
clusterFrom = new Date(Date.now() - (8 * 60 * 60 * 1000))
if (interval) stackedFrom += Math.floor(interval / 1000);
else stackedFrom += 1 // Workaround: TimeSelection not linked, just trigger new data on manual refresh
}}
/>
</Col>
</Row>
{#if $statusQuery.fetching || $statesTimed.fetching}
<Row class="justify-content-center">
<Col xs="auto">
<Spinner />
</Col>
</Row>
{:else if $statusQuery.error || $statesTimed.error}
<Row class="mb-2">
<Col class="d-flex justify-content-end">
<Button color="secondary" href="/">
<Icon name="x"/>
</Button>
</Col>
</Row>
<Row cols={{xs:1, md:2}}>
{#if $statusQuery.error}
<Col>
<Refresher
hideSelector
initially={60}
onRefresh={(interval) => {
from = new Date(Date.now() - 5 * 60 * 1000);
to = new Date(Date.now());
clusterFrom = new Date(Date.now() - (8 * 60 * 60 * 1000))
if (interval) stackedFrom += Math.floor(interval / 1000);
else stackedFrom += 1 // Workaround: TimeSelection not linked, just trigger new data on manual refresh
}}
/>
<Card color="danger"><CardBody>Error Requesting Status Data: {$statusQuery.error.message}</CardBody></Card>
</Col>
</Row>
{#if $statusQuery.fetching || $statesTimed.fetching}
<Row class="justify-content-center">
<Col xs="auto">
<Spinner />
</Col>
</Row>
{/if}
{#if $statesTimed.error}
<Col>
<Card color="danger"><CardBody>Error Requesting Node Scheduler States: {$statesTimed.error.message}</CardBody></Card>
</Col>
{/if}
</Row>
{:else if $statusQuery.error || $statesTimed.error}
<Row class="mb-2">
<Col class="d-flex justify-content-end">
<Button color="secondary" href="/">
<Icon name="x"/>
</Button>
</Col>
</Row>
<Row cols={{xs:1, md:2}}>
{#if $statusQuery.error}
<Col>
<Card color="danger"><CardBody>Error Requesting Status Data: {$statusQuery.error.message}</CardBody></Card>
</Col>
{/if}
{#if $statesTimed.error}
<Col>
<Card color="danger"><CardBody>Error Requesting Node Scheduler States: {$statesTimed.error.message}</CardBody></Card>
</Col>
{/if}
</Row>
{:else}
<Row cols={{xs:1, md:2}}>
<Col> <!-- General Cluster Info Card -->
<Card class="h-100">
<CardHeader>
<Row>
<Col xs="11" class="text-center">
<h2 class="mb-0">Cluster {presetCluster.charAt(0).toUpperCase() + presetCluster.slice(1)}</h2>
</Col>
<Col xs="1" class="d-flex justify-content-end">
<Button color="light" href="/">
<Icon name="x"/>
</Button>
</Col>
</Row>
</CardHeader>
<CardBody>
<h4>CPU(s)</h4><p><strong>{[...clusterInfo?.processorTypes].join(', ')}</strong></p>
</CardBody>
</Card>
</Col>
<Col> <!-- Utilization Info Card -->
{: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">
<CardBody>
<Row class="mb-1">
<Col xs={4} class="d-inline-flex align-items-center justify-content-center">
<Badge color="primary" style="font-size:x-large;margin-right:0.25rem;">
{clusterInfo?.runningJobs}
</Badge>
<div style="font-size:large;">
Running Jobs
</div>
</Col>
<Col xs={4} class="d-inline-flex align-items-center justify-content-center">
<Badge color="primary" style="font-size:x-large;margin-right:0.25rem;">
{clusterInfo?.activeUsers}
</Badge>
<div style="font-size:large;">
Active Users
</div>
</Col>
<Col xs={4} class="d-inline-flex align-items-center justify-content-center">
<Badge color="primary" style="font-size:x-large;margin-right:0.25rem;">
{clusterInfo?.allocatedNodes}
</Badge>
<div style="font-size:large;">
Active Nodes
</div>
</Col>
</Row>
<Row class="mt-1 mb-2">
<CardHeader>
<Row>
<Col xs="11" class="text-center">
<h2 class="mb-0">Cluster {presetCluster.charAt(0).toUpperCase() + presetCluster.slice(1)}</h2>
</Col>
<Col xs="1" class="d-flex justify-content-end">
<Button color="light" href="/">
<Icon name="x"/>
</Button>
</Col>
</Row>
</CardHeader>
<CardBody>
<h4>CPU(s)</h4><p><strong>{[...clusterInfo?.processorTypes].join(', ')}</strong></p>
</CardBody>
</Card>
</Col>
<Col> <!-- Utilization Info Card -->
<Card class="h-100">
<CardBody>
<Row class="mb-1">
<Col xs={4} class="d-inline-flex align-items-center justify-content-center">
<Badge color="primary" style="font-size:x-large;margin-right:0.25rem;">
{clusterInfo?.runningJobs}
</Badge>
<div style="font-size:large;">
Running Jobs
</div>
</Col>
<Col xs={4} class="d-inline-flex align-items-center justify-content-center">
<Badge color="primary" style="font-size:x-large;margin-right:0.25rem;">
{clusterInfo?.activeUsers}
</Badge>
<div style="font-size:large;">
Active Users
</div>
</Col>
<Col xs={4} class="d-inline-flex align-items-center justify-content-center">
<Badge color="primary" style="font-size:x-large;margin-right:0.25rem;">
{clusterInfo?.allocatedNodes}
</Badge>
<div style="font-size:large;">
Active Nodes
</div>
</Col>
</Row>
<Row class="mt-1 mb-2">
<Col xs={4} class="d-inline-flex align-items-center justify-content-center">
<Badge color="secondary" style="font-size:x-large;margin-right:0.25rem;">
{clusterInfo?.flopRate} {clusterInfo?.flopRateUnit}
</Badge>
<div style="font-size:large;">
Total Flop Rate
</div>
</Col>
<Col xs={4} class="d-inline-flex align-items-center justify-content-center">
<Badge color="secondary" style="font-size:x-large;margin-right:0.25rem;">
{clusterInfo?.memBwRate} {clusterInfo?.memBwRateUnit}
</Badge>
<div style="font-size:large;">
Total Memory Bandwidth
</div>
</Col>
{#if clusterInfo?.totalAccs !== 0}
<Col xs={4} class="d-inline-flex align-items-center justify-content-center">
<Badge color="secondary" style="font-size:x-large;margin-right:0.25rem;">
{clusterInfo?.flopRate} {clusterInfo?.flopRateUnit}
{clusterInfo?.gpuPwr} {clusterInfo?.gpuPwrUnit}
</Badge>
<div style="font-size:large;">
Total Flop Rate
Total GPU Power
</div>
</Col>
{:else}
<Col xs={4} class="d-inline-flex align-items-center justify-content-center">
<Badge color="secondary" style="font-size:x-large;margin-right:0.25rem;">
{clusterInfo?.memBwRate} {clusterInfo?.memBwRateUnit}
{clusterInfo?.cpuPwr} {clusterInfo?.cpuPwrUnit}
</Badge>
<div style="font-size:large;">
Total Memory Bandwidth
Total CPU Power
</div>
</Col>
{#if clusterInfo?.totalAccs !== 0}
<Col xs={4} class="d-inline-flex align-items-center justify-content-center">
<Badge color="secondary" style="font-size:x-large;margin-right:0.25rem;">
{clusterInfo?.gpuPwr} {clusterInfo?.gpuPwrUnit}
</Badge>
<div style="font-size:large;">
Total GPU Power
</div>
</Col>
{:else}
<Col xs={4} class="d-inline-flex align-items-center justify-content-center">
<Badge color="secondary" style="font-size:x-large;margin-right:0.25rem;">
{clusterInfo?.cpuPwr} {clusterInfo?.cpuPwrUnit}
</Badge>
<div style="font-size:large;">
Total CPU Power
</div>
</Col>
{/if}
</Row>
{/if}
</Row>
<Row class="my-1 align-items-baseline">
<Col xs={2} style="font-size:large;">
Active Cores
</Col>
<Col xs={8}>
<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;">
Idle Cores
</Col>
</Row>
{#if clusterInfo?.totalAccs !== 0}
<Row class="my-1 align-items-baseline">
<Col xs={2} style="font-size:large;">
Active Cores
Active GPU
</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?.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;">
Idle Cores
Idle GPU
</Col>
</Row>
{#if clusterInfo?.totalAccs !== 0}
<Row class="my-1 align-items-baseline">
<Col xs={2} style="font-size:large;">
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>
</Col>
<Col xs={2} style="font-size:large;">
Idle GPU
</Col>
</Row>
{/if}
</CardBody>
</Card>
</Col>
{/if}
</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
/>
</div>
</Col>
{/key}
</div>
</Col>
<Col> <!-- Nodes Roofline -->
<div bind:clientWidth={colWidthRoof}>
{#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}
/>
{/key}
</div>
</Col>
<Col> <!-- Pie Last States -->
<Row>
{#if refinedStateData.length > 0}
<Col class="px-3 mt-2 mt-lg-0">
<div bind:clientWidth={colWidthStates}>
{#key refinedStateData}
<Pie
canvasId="hpcpie-slurm"
size={colWidthStates * 0.66}
sliceLabel="Nodes"
quantities={refinedStateData.map(
(sd) => sd.count,
)}
entities={refinedStateData.map(
(sd) => sd.state,
)}
fixColors={refinedStateData.map(
(sd) => colors['nodeStates'][sd.state],
)}
/>
{/key}
</div>
</Col>
<Col class="px-4 py-2">
<Col> <!-- Nodes Roofline -->
<div>
{#key $statusQuery?.data?.nodeMetrics}
<Roofline
colorBackground
useColors={false}
useLegend={false}
allowSizeChange
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}
<Col class="px-3 mt-2 mt-lg-0">
<div bind:clientWidth={colWidthStates}>
{#key refinedStateData}
<Table>
<tr class="mb-2">
<th></th>
<th class="h4">State</th>
<th class="h4">Count</th>
</tr>
{#each refinedStateData as sd, i}
<tr>
<td><Icon name="circle-fill" style="color: {colors['nodeStates'][sd.state]}; font-size: 30px;"/></td>
<td class="h5">{sd.state.charAt(0).toUpperCase() + sd.state.slice(1)}</td>
<td class="h5">{sd.count}</td>
</tr>
{/each}
</Table>
<Pie
canvasId="hpcpie-slurm"
size={colWidthStates * 0.66}
sliceLabel="Nodes"
quantities={refinedStateData.map(
(sd) => sd.count,
)}
entities={refinedStateData.map(
(sd) => sd.state,
)}
fixColors={refinedStateData.map(
(sd) => colors['nodeStates'][sd.state],
)}
/>
{/key}
</Col>
{:else}
<Col>
<Card body color="warning" class="mx-4 my-2"
>Cannot render state status: No state data returned for <code>Pie Chart</code></Card
>
</Col>
{/if}
</Row>
</Col>
</div>
</Col>
<Col class="px-4 py-2">
{#key refinedStateData}
<Table>
<tr class="mb-2">
<th></th>
<th class="h4">State</th>
<th class="h4">Count</th>
</tr>
{#each refinedStateData as sd, i}
<tr>
<td><Icon name="circle-fill" style="color: {colors['nodeStates'][sd.state]}; font-size: 30px;"/></td>
<td class="h5">{sd.state.charAt(0).toUpperCase() + sd.state.slice(1)}</td>
<td class="h5">{sd.count}</td>
</tr>
{/each}
</Table>
{/key}
</Col>
{:else}
<Col>
<Card body color="warning" class="mx-4 my-2"
>Cannot render state status: No state data returned for <code>Pie Chart</code></Card
>
</Col>
{/if}
</Row>
</Col>
<Col> <!-- Stacked SchedState -->
<div bind:clientWidth={colWidthStacked}>
{#key $statesTimed?.data?.nodeStatesTimed}
<Stacked
data={$statesTimed?.data?.nodeStatesTimed}
width={colWidthStacked}
height={260}
ylabel="Nodes"
yunit = "#Count"
title = "Cluster Status"
stateType = "Node"
/>
{/key}
</div>
</Col>
</Row>
{/if}
</CardBody>
</Card>
<Col> <!-- Stacked SchedState -->
<div>
{#key $statesTimed?.data?.nodeStatesTimed}
<Stacked
data={$statesTimed?.data?.nodeStatesTimed}
height={300}
ylabel="Nodes"
yunit = "#Count"
title = "Cluster Status"
stateType = "Node"
/>
{/key}
</div>
</Col>
</Row>
</div>
{/if}
+13 -1
View File
@@ -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(
+5 -1
View File
@@ -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}
<JobSummary job={$initq.data.job}/>
{#if showSummary}
<JobSummary job={$initq.data.job}/>
{/if}
{:else}
<Spinner secondary />
{/if}
+19 -6
View File
@@ -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) =>
-6
View File
@@ -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();
+7 -5
View File
@@ -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,
+3 -1
View File
@@ -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(() => {
+1
View File
@@ -37,6 +37,7 @@
/* State Init */
let pendingChange = $state("none");
// svelte-ignore state_referenced_locally
let tagmap = $state(presetTagmap);
/* Functions */
+11 -11
View File
@@ -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 = {
+1
View File
@@ -89,6 +89,7 @@
};
/* State Init */
// svelte-ignore state_referenced_locally
let filters = $state({
dbId: filterPresets.dbId || [],
jobId: filterPresets.jobId || "",
+1 -1
View File
@@ -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,
+1 -1
View File
@@ -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 */
+4 -1
View File
@@ -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);
};
// };
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})`,
scale: `y${i+1}`,
width: lineWidth,
stroke: lineColor(i, metricData.length),
});
// }
// 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: '-',
// };
// }
// }
// });
// }
// };
for (let i = 0; i < metricData.length; i++) {
pendingSeries.push({
label: publicMode ? null : `${metricData[i]?.name} (${metricData[i]?.unit?.prefix}${metricData[i]?.unit?.base})`,
scale: `y${i+1}`,
width: lineWidth,
stroke: fixedLineColors[i],
});
};
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
}
}
+29 -22
View File
@@ -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]',
+37 -46
View File
@@ -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 */
+1 -1
View File
@@ -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" });
}
}
}
+21 -10
View File
@@ -29,19 +29,30 @@
const showPolarTab = !!getContext("cc-config")[`jobView_showPolarPlot`];
</script>
<Card class="overflow-auto" style="width: {width}; height: {height}">
<TabContent>
{#if showFootprintTab}
<TabPane tabId="foot" tab="Footprint" active={showFootprintTab}>
<Card style="width: {width}; height: {height}">
{#if showFootprintTab && !showPolarTab}
<JobFootprintBars {job} />
{:else if !showFootprintTab && showPolarTab}
<JobFootprintPolar {job}/>
{:else if showFootprintTab && showPolarTab}
<TabContent>
<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>
</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>
+2 -3
View File
@@ -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