mirror of
https://github.com/ClusterCockpit/cc-backend
synced 2026-07-07 17:00:36 +02:00
@@ -96,7 +96,7 @@ The backend follows a layered architecture with clear separation of concerns:
|
|||||||
- **internal/auth**: Authentication layer
|
- **internal/auth**: Authentication layer
|
||||||
- Supports local accounts, LDAP, OIDC, and JWT tokens
|
- Supports local accounts, LDAP, OIDC, and JWT tokens
|
||||||
- Implements rate limiting for login attempts
|
- 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
|
- In-memory metric storage with checkpointing
|
||||||
- Query API for loading job metric data
|
- Query API for loading job metric data
|
||||||
- **internal/archiver**: Job archiving to file-based archive
|
- **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
|
### 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
|
2. Add cluster configuration to metric store initialization
|
||||||
3. Update config.json schema documentation
|
3. Update config.json schema documentation
|
||||||
|
|
||||||
|
|||||||
@@ -163,8 +163,6 @@ ln -s <your-existing-job-archive> ./var/job-archive
|
|||||||
GraphQL schema and resolvers
|
GraphQL schema and resolvers
|
||||||
- [`importer`](https://github.com/ClusterCockpit/cc-backend/tree/master/internal/importer)
|
- [`importer`](https://github.com/ClusterCockpit/cc-backend/tree/master/internal/importer)
|
||||||
Job data import and database initialization
|
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)
|
- [`metricdispatch`](https://github.com/ClusterCockpit/cc-backend/tree/master/internal/metricdispatch)
|
||||||
Dispatches metric data loading to appropriate backends
|
Dispatches metric data loading to appropriate backends
|
||||||
- [`repository`](https://github.com/ClusterCockpit/cc-backend/tree/master/internal/repository)
|
- [`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.
|
contains Go packages that can be used by other projects.
|
||||||
- [`archive`](https://github.com/ClusterCockpit/cc-backend/tree/master/pkg/archive)
|
- [`archive`](https://github.com/ClusterCockpit/cc-backend/tree/master/pkg/archive)
|
||||||
Job archive backend implementations (filesystem, S3)
|
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`](https://github.com/ClusterCockpit/cc-backend/tree/master/pkg/nats)
|
||||||
NATS client and message handling
|
NATS client and message handling
|
||||||
- [`tools/`](https://github.com/ClusterCockpit/cc-backend/tree/master/tools)
|
- [`tools/`](https://github.com/ClusterCockpit/cc-backend/tree/master/tools)
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"os"
|
"os"
|
||||||
|
|
||||||
"github.com/ClusterCockpit/cc-backend/internal/config"
|
|
||||||
"github.com/ClusterCockpit/cc-backend/internal/repository"
|
"github.com/ClusterCockpit/cc-backend/internal/repository"
|
||||||
"github.com/ClusterCockpit/cc-backend/pkg/archive"
|
"github.com/ClusterCockpit/cc-backend/pkg/archive"
|
||||||
cclog "github.com/ClusterCockpit/cc-lib/v2/ccLogger"
|
cclog "github.com/ClusterCockpit/cc-lib/v2/ccLogger"
|
||||||
@@ -35,14 +34,14 @@ const configString = `
|
|||||||
"addr": "127.0.0.1:8080",
|
"addr": "127.0.0.1:8080",
|
||||||
"short-running-jobs-duration": 300,
|
"short-running-jobs-duration": 300,
|
||||||
"resampling": {
|
"resampling": {
|
||||||
"minimumPoints": 600,
|
"minimum-points": 600,
|
||||||
"trigger": 300,
|
"trigger": 300,
|
||||||
"resolutions": [
|
"resolutions": [
|
||||||
240,
|
240,
|
||||||
60
|
60
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"apiAllowedIPs": [
|
"api-allowed-ips": [
|
||||||
"*"
|
"*"
|
||||||
],
|
],
|
||||||
"emission-constant": 317
|
"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())
|
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\"}"
|
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())
|
cclog.Abortf("Could not initialize job-archive, exited.\nError: %s\n", err.Error())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,11 +24,11 @@ import (
|
|||||||
"github.com/ClusterCockpit/cc-backend/internal/auth"
|
"github.com/ClusterCockpit/cc-backend/internal/auth"
|
||||||
"github.com/ClusterCockpit/cc-backend/internal/config"
|
"github.com/ClusterCockpit/cc-backend/internal/config"
|
||||||
"github.com/ClusterCockpit/cc-backend/internal/importer"
|
"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/repository"
|
||||||
"github.com/ClusterCockpit/cc-backend/internal/tagger"
|
"github.com/ClusterCockpit/cc-backend/internal/tagger"
|
||||||
"github.com/ClusterCockpit/cc-backend/internal/taskmanager"
|
"github.com/ClusterCockpit/cc-backend/internal/taskmanager"
|
||||||
"github.com/ClusterCockpit/cc-backend/pkg/archive"
|
"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/pkg/nats"
|
||||||
"github.com/ClusterCockpit/cc-backend/web"
|
"github.com/ClusterCockpit/cc-backend/web"
|
||||||
ccconf "github.com/ClusterCockpit/cc-lib/v2/ccConfig"
|
ccconf "github.com/ClusterCockpit/cc-lib/v2/ccConfig"
|
||||||
@@ -107,7 +107,7 @@ func initConfiguration() error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func initDatabase() error {
|
func initDatabase() error {
|
||||||
repository.Connect(config.Keys.DBDriver, config.Keys.DB)
|
repository.Connect(config.Keys.DB)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -274,7 +274,7 @@ func initSubsystems() error {
|
|||||||
cclog.Debug("Archive configuration not found, using default archive configuration")
|
cclog.Debug("Archive configuration not found, using default archive configuration")
|
||||||
archiveCfg = json.RawMessage(defaultArchiveConfig)
|
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)
|
return fmt.Errorf("initializing archive: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -29,8 +29,8 @@ import (
|
|||||||
"github.com/ClusterCockpit/cc-backend/internal/config"
|
"github.com/ClusterCockpit/cc-backend/internal/config"
|
||||||
"github.com/ClusterCockpit/cc-backend/internal/graph"
|
"github.com/ClusterCockpit/cc-backend/internal/graph"
|
||||||
"github.com/ClusterCockpit/cc-backend/internal/graph/generated"
|
"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/internal/routerConfig"
|
||||||
|
"github.com/ClusterCockpit/cc-backend/pkg/metricstore"
|
||||||
"github.com/ClusterCockpit/cc-backend/pkg/nats"
|
"github.com/ClusterCockpit/cc-backend/pkg/nats"
|
||||||
"github.com/ClusterCockpit/cc-backend/web"
|
"github.com/ClusterCockpit/cc-backend/web"
|
||||||
cclog "github.com/ClusterCockpit/cc-lib/v2/ccLogger"
|
cclog "github.com/ClusterCockpit/cc-lib/v2/ccLogger"
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
{
|
{
|
||||||
"main": {
|
"main": {
|
||||||
"addr": "127.0.0.1:8080",
|
"addr": "127.0.0.1:8080"
|
||||||
"apiAllowedIPs": ["*"]
|
|
||||||
},
|
},
|
||||||
"cron": {
|
"cron": {
|
||||||
"commit-job-worker": "1m",
|
"commit-job-worker": "1m",
|
||||||
@@ -15,8 +14,9 @@
|
|||||||
},
|
},
|
||||||
"metric-store": {
|
"metric-store": {
|
||||||
"checkpoints": {
|
"checkpoints": {
|
||||||
"interval": "1h"
|
"interval": "12h"
|
||||||
},
|
},
|
||||||
"retention-in-memory": "12h"
|
"retention-in-memory": "48h",
|
||||||
|
"memory-cap": 100
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+23
-11
@@ -5,17 +5,22 @@
|
|||||||
"https-key-file": "/etc/letsencrypt/live/url/privkey.pem",
|
"https-key-file": "/etc/letsencrypt/live/url/privkey.pem",
|
||||||
"user": "clustercockpit",
|
"user": "clustercockpit",
|
||||||
"group": "clustercockpit",
|
"group": "clustercockpit",
|
||||||
"apiAllowedIPs": ["*"],
|
"api-allowed-ips": [
|
||||||
|
"*"
|
||||||
|
],
|
||||||
"short-running-jobs-duration": 300,
|
"short-running-jobs-duration": 300,
|
||||||
"enable-job-taggers": true,
|
"enable-job-taggers": true,
|
||||||
"resampling": {
|
"resampling": {
|
||||||
"minimumPoints": 600,
|
"minimum-points": 600,
|
||||||
"trigger": 180,
|
"trigger": 180,
|
||||||
"resolutions": [240, 60]
|
"resolutions": [
|
||||||
|
240,
|
||||||
|
60
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"apiSubjects": {
|
"api-subjects": {
|
||||||
"subjectJobEvent": "cc.job.event",
|
"subject-job-event": "cc.job.event",
|
||||||
"subjectNodeState": "cc.node.state"
|
"subject-node-state": "cc.node.state"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"nats": {
|
"nats": {
|
||||||
@@ -37,8 +42,8 @@
|
|||||||
"kind": "s3",
|
"kind": "s3",
|
||||||
"endpoint": "http://x.x.x.x",
|
"endpoint": "http://x.x.x.x",
|
||||||
"bucket": "jobarchive",
|
"bucket": "jobarchive",
|
||||||
"accessKey": "xx",
|
"access-key": "xx",
|
||||||
"secretKey": "xx",
|
"secret-key": "xx",
|
||||||
"retention": {
|
"retention": {
|
||||||
"policy": "move",
|
"policy": "move",
|
||||||
"age": 365,
|
"age": 365,
|
||||||
@@ -47,10 +52,17 @@
|
|||||||
},
|
},
|
||||||
"metric-store": {
|
"metric-store": {
|
||||||
"checkpoints": {
|
"checkpoints": {
|
||||||
"interval": "1h"
|
"interval": "12h",
|
||||||
|
"directory": "./var/checkpoints"
|
||||||
},
|
},
|
||||||
"retention-in-memory": "12h",
|
"memory-cap": 100,
|
||||||
"subscriptions": [
|
"retention-in-memory": "48h",
|
||||||
|
"cleanup": {
|
||||||
|
"mode": "archive",
|
||||||
|
"interval": "48h",
|
||||||
|
"directory": "./var/archive"
|
||||||
|
},
|
||||||
|
"nats-subscriptions": [
|
||||||
{
|
{
|
||||||
"subscribe-to": "hpc-nats",
|
"subscribe-to": "hpc-nats",
|
||||||
"cluster-tag": "fritz"
|
"cluster-tag": "fritz"
|
||||||
|
|||||||
+21
-21
@@ -1,38 +1,38 @@
|
|||||||
{
|
{
|
||||||
"jobList": {
|
"job-list": {
|
||||||
"usePaging": false,
|
"use-paging": false,
|
||||||
"showFootprint":false
|
"show-footprint":false
|
||||||
},
|
},
|
||||||
"jobView": {
|
"job-view": {
|
||||||
"showPolarPlot": true,
|
"show-polar-plot": true,
|
||||||
"showFootprint": true,
|
"show-footprint": true,
|
||||||
"showRoofline": true,
|
"show-roofline": true,
|
||||||
"showStatTable": true
|
"show-stat-table": true
|
||||||
},
|
},
|
||||||
"metricConfig": {
|
"metric-config": {
|
||||||
"jobListMetrics": ["mem_bw", "flops_dp"],
|
"job-list-metrics": ["mem_bw", "flops_dp"],
|
||||||
"jobViewPlotMetrics": ["mem_bw", "flops_dp"],
|
"job-view-plot-metrics": ["mem_bw", "flops_dp"],
|
||||||
"jobViewTableMetrics": ["mem_bw", "flops_dp"],
|
"job-view-table-metrics": ["mem_bw", "flops_dp"],
|
||||||
"clusters": [
|
"clusters": [
|
||||||
{
|
{
|
||||||
"name": "test",
|
"name": "test",
|
||||||
"subClusters": [
|
"sub-clusters": [
|
||||||
{
|
{
|
||||||
"name": "one",
|
"name": "one",
|
||||||
"jobListMetrics": ["mem_used", "flops_sp"]
|
"job-list-metrics": ["mem_used", "flops_sp"]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"nodeList": {
|
"node-list": {
|
||||||
"usePaging": true
|
"use-paging": true
|
||||||
},
|
},
|
||||||
"plotConfiguration": {
|
"plot-configuration": {
|
||||||
"plotsPerRow": 3,
|
"plots-per-row": 3,
|
||||||
"colorBackground": true,
|
"color-background": true,
|
||||||
"lineWidth": 3,
|
"line-width": 3,
|
||||||
"colorScheme": [
|
"color-scheme": [
|
||||||
"#00bfff",
|
"#00bfff",
|
||||||
"#0000ff",
|
"#0000ff",
|
||||||
"#ff00ff",
|
"#ff00ff",
|
||||||
|
|||||||
@@ -24,9 +24,9 @@ import (
|
|||||||
"github.com/ClusterCockpit/cc-backend/internal/config"
|
"github.com/ClusterCockpit/cc-backend/internal/config"
|
||||||
"github.com/ClusterCockpit/cc-backend/internal/graph"
|
"github.com/ClusterCockpit/cc-backend/internal/graph"
|
||||||
"github.com/ClusterCockpit/cc-backend/internal/metricdispatch"
|
"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/internal/repository"
|
||||||
"github.com/ClusterCockpit/cc-backend/pkg/archive"
|
"github.com/ClusterCockpit/cc-backend/pkg/archive"
|
||||||
|
"github.com/ClusterCockpit/cc-backend/pkg/metricstore"
|
||||||
ccconf "github.com/ClusterCockpit/cc-lib/v2/ccConfig"
|
ccconf "github.com/ClusterCockpit/cc-lib/v2/ccConfig"
|
||||||
cclog "github.com/ClusterCockpit/cc-lib/v2/ccLogger"
|
cclog "github.com/ClusterCockpit/cc-lib/v2/ccLogger"
|
||||||
"github.com/ClusterCockpit/cc-lib/v2/schema"
|
"github.com/ClusterCockpit/cc-lib/v2/schema"
|
||||||
@@ -42,7 +42,7 @@ func setup(t *testing.T) *api.RestAPI {
|
|||||||
"main": {
|
"main": {
|
||||||
"addr": "0.0.0.0:8080",
|
"addr": "0.0.0.0:8080",
|
||||||
"validate": false,
|
"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)
|
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)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import (
|
|||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/ClusterCockpit/cc-backend/internal/metricstore"
|
"github.com/ClusterCockpit/cc-backend/pkg/metricstore"
|
||||||
cclog "github.com/ClusterCockpit/cc-lib/v2/ccLogger"
|
cclog "github.com/ClusterCockpit/cc-lib/v2/ccLogger"
|
||||||
|
|
||||||
"github.com/influxdata/line-protocol/v2/lineprotocol"
|
"github.com/influxdata/line-protocol/v2/lineprotocol"
|
||||||
|
|||||||
@@ -18,9 +18,9 @@ import (
|
|||||||
"github.com/ClusterCockpit/cc-backend/internal/auth"
|
"github.com/ClusterCockpit/cc-backend/internal/auth"
|
||||||
"github.com/ClusterCockpit/cc-backend/internal/config"
|
"github.com/ClusterCockpit/cc-backend/internal/config"
|
||||||
"github.com/ClusterCockpit/cc-backend/internal/graph"
|
"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/internal/repository"
|
||||||
"github.com/ClusterCockpit/cc-backend/pkg/archive"
|
"github.com/ClusterCockpit/cc-backend/pkg/archive"
|
||||||
|
"github.com/ClusterCockpit/cc-backend/pkg/metricstore"
|
||||||
ccconf "github.com/ClusterCockpit/cc-lib/v2/ccConfig"
|
ccconf "github.com/ClusterCockpit/cc-lib/v2/ccConfig"
|
||||||
cclog "github.com/ClusterCockpit/cc-lib/v2/ccLogger"
|
cclog "github.com/ClusterCockpit/cc-lib/v2/ccLogger"
|
||||||
lp "github.com/ClusterCockpit/cc-lib/v2/ccMessage"
|
lp "github.com/ClusterCockpit/cc-lib/v2/ccMessage"
|
||||||
@@ -36,7 +36,7 @@ func setupNatsTest(t *testing.T) *NatsAPI {
|
|||||||
"main": {
|
"main": {
|
||||||
"addr": "0.0.0.0:8080",
|
"addr": "0.0.0.0:8080",
|
||||||
"validate": false,
|
"validate": false,
|
||||||
"apiAllowedIPs": [
|
"api-allowed-ips": [
|
||||||
"*"
|
"*"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@@ -146,9 +146,9 @@ func setupNatsTest(t *testing.T) *NatsAPI {
|
|||||||
}
|
}
|
||||||
archiveCfg := fmt.Sprintf("{\"kind\": \"file\",\"path\": \"%s\"}", jobarchive)
|
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)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"math"
|
"math"
|
||||||
|
|
||||||
"github.com/ClusterCockpit/cc-backend/internal/config"
|
|
||||||
"github.com/ClusterCockpit/cc-backend/internal/metricdispatch"
|
"github.com/ClusterCockpit/cc-backend/internal/metricdispatch"
|
||||||
"github.com/ClusterCockpit/cc-backend/pkg/archive"
|
"github.com/ClusterCockpit/cc-backend/pkg/archive"
|
||||||
cclog "github.com/ClusterCockpit/cc-lib/v2/ccLogger"
|
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)
|
return job, archive.GetHandle().ImportJob(job, &jobData)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -305,8 +305,13 @@ func (auth *Authentication) SaveSession(rw http.ResponseWriter, r *http.Request,
|
|||||||
if auth.SessionMaxAge != 0 {
|
if auth.SessionMaxAge != 0 {
|
||||||
session.Options.MaxAge = int(auth.SessionMaxAge.Seconds())
|
session.Options.MaxAge = int(auth.SessionMaxAge.Seconds())
|
||||||
}
|
}
|
||||||
if config.Keys.HTTPSCertFile == "" && config.Keys.HTTPSKeyFile == "" {
|
if r.TLS == nil && r.Header.Get("X-Forwarded-Proto") != "https" {
|
||||||
cclog.Warn("HTTPS not configured - session cookies will not have Secure flag set (insecure for production)")
|
// 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.Secure = false
|
||||||
}
|
}
|
||||||
session.Options.SameSite = http.SameSiteStrictMode
|
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 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 {
|
if len(config.Keys.APIAllowedIPs) == 0 {
|
||||||
return fmt.Errorf("missing configuration key ApiAllowedIPs")
|
return nil
|
||||||
}
|
}
|
||||||
// If wildcard declared in config: Continue
|
// If wildcard declared in config: Continue
|
||||||
if config.Keys.APIAllowedIPs[0] == "*" {
|
if config.Keys.APIAllowedIPs[0] == "*" {
|
||||||
|
|||||||
@@ -25,20 +25,20 @@ type JWTAuthConfig struct {
|
|||||||
MaxAge string `json:"max-age"`
|
MaxAge string `json:"max-age"`
|
||||||
|
|
||||||
// Specifies which cookie should be checked for a JWT token (if no authorization header is present)
|
// 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).
|
// Deny login for users not in database (but defined in JWT).
|
||||||
// Ignore user roles defined in JWTs ('roles' claim), get them from db.
|
// 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)
|
// 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
|
// 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
|
// 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 {
|
type JWTAuthenticator struct {
|
||||||
|
|||||||
@@ -20,16 +20,16 @@ import (
|
|||||||
|
|
||||||
type LdapConfig struct {
|
type LdapConfig struct {
|
||||||
URL string `json:"url"`
|
URL string `json:"url"`
|
||||||
UserBase string `json:"user_base"`
|
UserBase string `json:"user-base"`
|
||||||
SearchDN string `json:"search_dn"`
|
SearchDN string `json:"search-dn"`
|
||||||
UserBind string `json:"user_bind"`
|
UserBind string `json:"user-bind"`
|
||||||
UserFilter string `json:"user_filter"`
|
UserFilter string `json:"user-filter"`
|
||||||
UserAttr string `json:"username_attr"`
|
UserAttr string `json:"username-attr"`
|
||||||
SyncInterval string `json:"sync_interval"` // Parsed using time.ParseDuration.
|
SyncInterval string `json:"sync-interval"` // Parsed using time.ParseDuration.
|
||||||
SyncDelOldUsers bool `json:"sync_del_old_users"`
|
SyncDelOldUsers bool `json:"sync-del-old-users"`
|
||||||
|
|
||||||
// Should an non-existent user be added to the DB if user exists in ldap directory
|
// 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 {
|
type LdapAuthenticator struct {
|
||||||
|
|||||||
@@ -24,8 +24,8 @@ import (
|
|||||||
|
|
||||||
type OpenIDConfig struct {
|
type OpenIDConfig struct {
|
||||||
Provider string `json:"provider"`
|
Provider string `json:"provider"`
|
||||||
SyncUserOnLogin bool `json:"syncUserOnLogin"`
|
SyncUserOnLogin bool `json:"sync-user-on-login"`
|
||||||
UpdateUserOnLogin bool `json:"updateUserOnLogin"`
|
UpdateUserOnLogin bool `json:"update-user-on-login"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type OIDC struct {
|
type OIDC struct {
|
||||||
|
|||||||
+25
-18
@@ -15,37 +15,44 @@ var configSchema = `
|
|||||||
"description": "Configure how long a token is valid. As string parsable by time.ParseDuration()",
|
"description": "Configure how long a token is valid. As string parsable by time.ParseDuration()",
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
"cookieName": {
|
"cookie-name": {
|
||||||
"description": "Cookie that should be checked for a JWT token.",
|
"description": "Cookie that should be checked for a JWT token.",
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
"validateUser": {
|
"validate-user": {
|
||||||
"description": "Deny login for users not in database (but defined in JWT). Overwrite roles in JWT with database roles.",
|
"description": "Deny login for users not in database (but defined in JWT). Overwrite roles in JWT with database roles.",
|
||||||
"type": "boolean"
|
"type": "boolean"
|
||||||
},
|
},
|
||||||
"trustedIssuer": {
|
"trusted-issuer": {
|
||||||
"description": "Issuer that should be accepted when validating external JWTs ",
|
"description": "Issuer that should be accepted when validating external JWTs ",
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
"syncUserOnLogin": {
|
"sync-user-on-login": {
|
||||||
"description": "Add non-existent user to DB at login attempt with values provided in JWT.",
|
"description": "Add non-existent user to DB at login attempt with values provided in JWT.",
|
||||||
"type": "boolean"
|
"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"]
|
"required": ["max-age"]
|
||||||
},
|
},
|
||||||
"oidc": {
|
"oidc": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
"provider": {
|
"provider": {
|
||||||
"description": "",
|
"description": "OpenID Connect provider URL.",
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
"syncUserOnLogin": {
|
"sync-user-on-login": {
|
||||||
"description": "",
|
"description": "Add non-existent user to DB at login attempt with values provided.",
|
||||||
"type": "boolean"
|
"type": "boolean"
|
||||||
},
|
},
|
||||||
"updateUserOnLogin": {
|
"update-user-on-login": {
|
||||||
"description": "",
|
"description": "Should an existent user attributes in the DB be updated at login attempt with values provided.",
|
||||||
"type": "boolean"
|
"type": "boolean"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"required": ["provider"]
|
"required": ["provider"]
|
||||||
},
|
},
|
||||||
@@ -57,40 +64,40 @@ var configSchema = `
|
|||||||
"description": "URL of LDAP directory server.",
|
"description": "URL of LDAP directory server.",
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
"user_base": {
|
"user-base": {
|
||||||
"description": "Base DN of user tree root.",
|
"description": "Base DN of user tree root.",
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
"search_dn": {
|
"search-dn": {
|
||||||
"description": "DN for authenticating LDAP admin account with general read rights.",
|
"description": "DN for authenticating LDAP admin account with general read rights.",
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
"user_bind": {
|
"user-bind": {
|
||||||
"description": "Expression used to authenticate users via LDAP bind. Must contain uid={username}.",
|
"description": "Expression used to authenticate users via LDAP bind. Must contain uid={username}.",
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
"user_filter": {
|
"user-filter": {
|
||||||
"description": "Filter to extract users for syncing.",
|
"description": "Filter to extract users for syncing.",
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
"username_attr": {
|
"username-attr": {
|
||||||
"description": "Attribute with full username. Default: gecos",
|
"description": "Attribute with full username. Default: gecos",
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
"sync_interval": {
|
"sync-interval": {
|
||||||
"description": "Interval used for syncing local user table with LDAP directory. Parsed using time.ParseDuration.",
|
"description": "Interval used for syncing local user table with LDAP directory. Parsed using time.ParseDuration.",
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
"sync_del_old_users": {
|
"sync-del-old-users": {
|
||||||
"description": "Delete obsolete users in database.",
|
"description": "Delete obsolete users in database.",
|
||||||
"type": "boolean"
|
"type": "boolean"
|
||||||
},
|
},
|
||||||
"syncUserOnLogin": {
|
"sync-user-on-login": {
|
||||||
"description": "Add non-existent user to DB at login attempt if user exists in Ldap directory",
|
"description": "Add non-existent user to DB at login attempt if user exists in Ldap directory",
|
||||||
"type": "boolean"
|
"type": "boolean"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": ["url", "user_base", "search_dn", "user_bind", "user_filter"]
|
"required": ["url", "user-base", "search-dn", "user-bind", "user-filter"]
|
||||||
},
|
},
|
||||||
"required": ["jwts"]
|
"required": ["jwts"]
|
||||||
}`
|
}`
|
||||||
|
|||||||
@@ -20,9 +20,9 @@ type ProgramConfig struct {
|
|||||||
Addr string `json:"addr"`
|
Addr string `json:"addr"`
|
||||||
|
|
||||||
// Addresses from which secured admin API endpoints can be reached, can be wildcard "*"
|
// 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.
|
// Drop root permissions once .env was read and the port was taken.
|
||||||
User string `json:"user"`
|
User string `json:"user"`
|
||||||
@@ -37,16 +37,9 @@ type ProgramConfig struct {
|
|||||||
EmbedStaticFiles bool `json:"embed-static-files"`
|
EmbedStaticFiles bool `json:"embed-static-files"`
|
||||||
StaticFiles string `json:"static-files"`
|
StaticFiles string `json:"static-files"`
|
||||||
|
|
||||||
// Database driver - only 'sqlite3' is supported
|
|
||||||
DBDriver string `json:"db-driver"`
|
|
||||||
|
|
||||||
// Path to SQLite database file
|
// Path to SQLite database file
|
||||||
DB string `json:"db"`
|
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"`
|
EnableJobTaggers bool `json:"enable-job-taggers"`
|
||||||
|
|
||||||
// Validate json input against schema
|
// Validate json input against schema
|
||||||
@@ -82,7 +75,7 @@ type ProgramConfig struct {
|
|||||||
|
|
||||||
type ResampleConfig struct {
|
type ResampleConfig struct {
|
||||||
// Minimum number of points to trigger resampling of data
|
// 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]
|
// Array of resampling target resolutions, in seconds; Example: [600,300,60]
|
||||||
Resolutions []int `json:"resolutions"`
|
Resolutions []int `json:"resolutions"`
|
||||||
// Trigger next zoom level at less than this many visible datapoints
|
// Trigger next zoom level at less than this many visible datapoints
|
||||||
@@ -90,8 +83,8 @@ type ResampleConfig struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type NATSConfig struct {
|
type NATSConfig struct {
|
||||||
SubjectJobEvent string `json:"subjectJobEvent"`
|
SubjectJobEvent string `json:"subject-job-event"`
|
||||||
SubjectNodeState string `json:"subjectNodeState"`
|
SubjectNodeState string `json:"subject-node-state"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type IntRange struct {
|
type IntRange struct {
|
||||||
@@ -107,18 +100,14 @@ type TimeRange struct {
|
|||||||
|
|
||||||
type FilterRanges struct {
|
type FilterRanges struct {
|
||||||
Duration *IntRange `json:"duration"`
|
Duration *IntRange `json:"duration"`
|
||||||
NumNodes *IntRange `json:"numNodes"`
|
NumNodes *IntRange `json:"num-nodes"`
|
||||||
StartTime *TimeRange `json:"startTime"`
|
StartTime *TimeRange `json:"start-time"`
|
||||||
}
|
}
|
||||||
|
|
||||||
var Keys ProgramConfig = ProgramConfig{
|
var Keys ProgramConfig = ProgramConfig{
|
||||||
Addr: "localhost:8080",
|
Addr: "localhost:8080",
|
||||||
DisableAuthentication: false,
|
|
||||||
EmbedStaticFiles: true,
|
EmbedStaticFiles: true,
|
||||||
DBDriver: "sqlite3",
|
|
||||||
DB: "./var/job.db",
|
DB: "./var/job.db",
|
||||||
DisableArchive: false,
|
|
||||||
Validate: false,
|
|
||||||
SessionMaxAge: "168h",
|
SessionMaxAge: "168h",
|
||||||
StopJobsExceedingWalltime: 0,
|
StopJobsExceedingWalltime: 0,
|
||||||
ShortRunningJobsDuration: 5 * 60,
|
ShortRunningJobsDuration: 5 * 60,
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import (
|
|||||||
|
|
||||||
type DefaultMetricsCluster struct {
|
type DefaultMetricsCluster struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
DefaultMetrics string `json:"default_metrics"`
|
DefaultMetrics string `json:"default-metrics"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type DefaultMetricsConfig struct {
|
type DefaultMetricsConfig struct {
|
||||||
|
|||||||
@@ -6,14 +6,14 @@
|
|||||||
package config
|
package config
|
||||||
|
|
||||||
var configSchema = `
|
var configSchema = `
|
||||||
{
|
{
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
"addr": {
|
"addr": {
|
||||||
"description": "Address where the http (or https) server will listen on (for example: 'localhost:80').",
|
"description": "Address where the http (or https) server will listen on (for example: 'localhost:80').",
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
"apiAllowedIPs": {
|
"api-allowed-ips": {
|
||||||
"description": "Addresses from which secured API endpoints can be reached",
|
"description": "Addresses from which secured API endpoints can be reached",
|
||||||
"type": "array",
|
"type": "array",
|
||||||
"items": {
|
"items": {
|
||||||
@@ -44,10 +44,6 @@ var configSchema = `
|
|||||||
"description": "Path to SQLite database file (e.g., './var/job.db')",
|
"description": "Path to SQLite database file (e.g., './var/job.db')",
|
||||||
"type": "string"
|
"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": {
|
"enable-job-taggers": {
|
||||||
"description": "Turn on automatic application and jobclass taggers",
|
"description": "Turn on automatic application and jobclass taggers",
|
||||||
"type": "boolean"
|
"type": "boolean"
|
||||||
@@ -102,7 +98,7 @@ var configSchema = `
|
|||||||
"description": "Enable dynamic zoom in frontend metric plots.",
|
"description": "Enable dynamic zoom in frontend metric plots.",
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
"minimumPoints": {
|
"minimum-points": {
|
||||||
"description": "Minimum points to trigger resampling of time-series data.",
|
"description": "Minimum points to trigger resampling of time-series data.",
|
||||||
"type": "integer"
|
"type": "integer"
|
||||||
},
|
},
|
||||||
@@ -120,21 +116,20 @@ var configSchema = `
|
|||||||
},
|
},
|
||||||
"required": ["trigger", "resolutions"]
|
"required": ["trigger", "resolutions"]
|
||||||
},
|
},
|
||||||
"apiSubjects": {
|
"api-subjects": {
|
||||||
"description": "NATS subjects configuration for subscribing to job and node events.",
|
"description": "NATS subjects configuration for subscribing to job and node events.",
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
"subjectJobEvent": {
|
"subject-job-event": {
|
||||||
"description": "NATS subject for job events (start_job, stop_job)",
|
"description": "NATS subject for job events (start_job, stop_job)",
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
"subjectNodeState": {
|
"subject-node-state": {
|
||||||
"description": "NATS subject for node state updates",
|
"description": "NATS subject for node state updates",
|
||||||
"type": "string"
|
"type": "string"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": ["subjectJobEvent", "subjectNodeState"]
|
"required": ["subject-job-event", "subject-node-state"]
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
"required": ["apiAllowedIPs"]
|
}`
|
||||||
}`
|
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ func setup(t *testing.T) *repository.JobRepository {
|
|||||||
"main": {
|
"main": {
|
||||||
"addr": "0.0.0.0:8080",
|
"addr": "0.0.0.0:8080",
|
||||||
"validate": false,
|
"validate": false,
|
||||||
"apiAllowedIPs": [
|
"api-allowed-ips": [
|
||||||
"*"
|
"*"
|
||||||
]},
|
]},
|
||||||
"archive": {
|
"archive": {
|
||||||
@@ -100,11 +100,11 @@ func setup(t *testing.T) *repository.JobRepository {
|
|||||||
|
|
||||||
archiveCfg := fmt.Sprintf("{\"kind\": \"file\",\"path\": \"%s\"}", jobarchive)
|
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)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
repository.Connect("sqlite3", dbfilepath)
|
repository.Connect(dbfilepath)
|
||||||
return repository.GetJobRepository()
|
return repository.GetJobRepository()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -43,9 +43,8 @@ import (
|
|||||||
"math"
|
"math"
|
||||||
"time"
|
"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/archive"
|
||||||
|
"github.com/ClusterCockpit/cc-backend/pkg/metricstore"
|
||||||
cclog "github.com/ClusterCockpit/cc-lib/v2/ccLogger"
|
cclog "github.com/ClusterCockpit/cc-lib/v2/ccLogger"
|
||||||
"github.com/ClusterCockpit/cc-lib/v2/lrucache"
|
"github.com/ClusterCockpit/cc-lib/v2/lrucache"
|
||||||
"github.com/ClusterCockpit/cc-lib/v2/resampler"
|
"github.com/ClusterCockpit/cc-lib/v2/resampler"
|
||||||
@@ -95,8 +94,7 @@ func LoadData(job *schema.Job,
|
|||||||
var err error
|
var err error
|
||||||
|
|
||||||
if job.State == schema.JobStateRunning ||
|
if job.State == schema.JobStateRunning ||
|
||||||
job.MonitoringStatus == schema.MonitoringStatusRunningOrArchiving ||
|
job.MonitoringStatus == schema.MonitoringStatusRunningOrArchiving {
|
||||||
config.Keys.DisableArchive {
|
|
||||||
|
|
||||||
if scopes == nil {
|
if scopes == nil {
|
||||||
scopes = append(scopes, schema.MetricScopeNode)
|
scopes = append(scopes, schema.MetricScopeNode)
|
||||||
@@ -234,7 +232,7 @@ func LoadAverages(
|
|||||||
data [][]schema.Float,
|
data [][]schema.Float,
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
) error {
|
) error {
|
||||||
if job.State != schema.JobStateRunning && !config.Keys.DisableArchive {
|
if job.State != schema.JobStateRunning {
|
||||||
return archive.LoadAveragesFromArchive(job, metrics, data) // #166 change also here?
|
return archive.LoadAveragesFromArchive(job, metrics, data) // #166 change also here?
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -271,7 +269,7 @@ func LoadScopedJobStats(
|
|||||||
scopes []schema.MetricScope,
|
scopes []schema.MetricScope,
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
) (schema.ScopedJobStats, error) {
|
) (schema.ScopedJobStats, error) {
|
||||||
if job.State != schema.JobStateRunning && !config.Keys.DisableArchive {
|
if job.State != schema.JobStateRunning {
|
||||||
return archive.LoadScopedStatsFromArchive(job, metrics, scopes)
|
return archive.LoadScopedStatsFromArchive(job, metrics, scopes)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -293,7 +291,7 @@ func LoadJobStats(
|
|||||||
metrics []string,
|
metrics []string,
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
) (map[string]schema.MetricStatistics, error) {
|
) (map[string]schema.MetricStatistics, error) {
|
||||||
if job.State != schema.JobStateRunning && !config.Keys.DisableArchive {
|
if job.State != schema.JobStateRunning {
|
||||||
return archive.LoadStatsFromArchive(job, metrics)
|
return archive.LoadStatsFromArchive(job, metrics)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,190 +0,0 @@
|
|||||||
// Copyright (C) NHR@FAU, University Erlangen-Nuremberg.
|
|
||||||
// All rights reserved. This file is part of cc-backend.
|
|
||||||
// Use of this source code is governed by a MIT-style
|
|
||||||
// license that can be found in the LICENSE file.
|
|
||||||
|
|
||||||
package metricstore
|
|
||||||
|
|
||||||
import (
|
|
||||||
"errors"
|
|
||||||
"sync"
|
|
||||||
|
|
||||||
"github.com/ClusterCockpit/cc-lib/v2/schema"
|
|
||||||
)
|
|
||||||
|
|
||||||
// BufferCap is the default buffer capacity.
|
|
||||||
// buffer.data will only ever grow up to its capacity and a new link
|
|
||||||
// in the buffer chain will be created if needed so that no copying
|
|
||||||
// of data or reallocation needs to happen on writes.
|
|
||||||
const BufferCap int = DefaultBufferCapacity
|
|
||||||
|
|
||||||
var bufferPool sync.Pool = sync.Pool{
|
|
||||||
New: func() any {
|
|
||||||
return &buffer{
|
|
||||||
data: make([]schema.Float, 0, BufferCap),
|
|
||||||
}
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
var (
|
|
||||||
ErrNoData error = errors.New("[METRICSTORE]> no data for this metric/level")
|
|
||||||
ErrDataDoesNotAlign error = errors.New("[METRICSTORE]> data from lower granularities does not align")
|
|
||||||
)
|
|
||||||
|
|
||||||
// Each metric on each level has it's own buffer.
|
|
||||||
// This is where the actual values go.
|
|
||||||
// If `cap(data)` is reached, a new buffer is created and
|
|
||||||
// becomes the new head of a buffer list.
|
|
||||||
type buffer struct {
|
|
||||||
prev *buffer
|
|
||||||
next *buffer
|
|
||||||
data []schema.Float
|
|
||||||
frequency int64
|
|
||||||
start int64
|
|
||||||
archived bool
|
|
||||||
closed bool
|
|
||||||
}
|
|
||||||
|
|
||||||
func newBuffer(ts, freq int64) *buffer {
|
|
||||||
b := bufferPool.Get().(*buffer)
|
|
||||||
b.frequency = freq
|
|
||||||
b.start = ts - (freq / 2)
|
|
||||||
b.prev = nil
|
|
||||||
b.next = nil
|
|
||||||
b.archived = false
|
|
||||||
b.closed = false
|
|
||||||
b.data = b.data[:0]
|
|
||||||
return b
|
|
||||||
}
|
|
||||||
|
|
||||||
// If a new buffer was created, the new head is returnd.
|
|
||||||
// Otherwise, the existing buffer is returnd.
|
|
||||||
// Normaly, only "newer" data should be written, but if the value would
|
|
||||||
// end up in the same buffer anyways it is allowed.
|
|
||||||
func (b *buffer) write(ts int64, value schema.Float) (*buffer, error) {
|
|
||||||
if ts < b.start {
|
|
||||||
return nil, errors.New("[METRICSTORE]> cannot write value to buffer from past")
|
|
||||||
}
|
|
||||||
|
|
||||||
// idx := int((ts - b.start + (b.frequency / 3)) / b.frequency)
|
|
||||||
idx := int((ts - b.start) / b.frequency)
|
|
||||||
if idx >= cap(b.data) {
|
|
||||||
newbuf := newBuffer(ts, b.frequency)
|
|
||||||
newbuf.prev = b
|
|
||||||
b.next = newbuf
|
|
||||||
b = newbuf
|
|
||||||
idx = 0
|
|
||||||
}
|
|
||||||
|
|
||||||
// Overwriting value or writing value from past
|
|
||||||
if idx < len(b.data) {
|
|
||||||
b.data[idx] = value
|
|
||||||
return b, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fill up unwritten slots with NaN
|
|
||||||
for i := len(b.data); i < idx; i++ {
|
|
||||||
b.data = append(b.data, schema.NaN)
|
|
||||||
}
|
|
||||||
|
|
||||||
b.data = append(b.data, value)
|
|
||||||
return b, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (b *buffer) end() int64 {
|
|
||||||
return b.firstWrite() + int64(len(b.data))*b.frequency
|
|
||||||
}
|
|
||||||
|
|
||||||
func (b *buffer) firstWrite() int64 {
|
|
||||||
return b.start + (b.frequency / 2)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Return all known values from `from` to `to`. Gaps of information are represented as NaN.
|
|
||||||
// Simple linear interpolation is done between the two neighboring cells if possible.
|
|
||||||
// If values at the start or end are missing, instead of NaN values, the second and thrid
|
|
||||||
// return values contain the actual `from`/`to`.
|
|
||||||
// This function goes back the buffer chain if `from` is older than the currents buffer start.
|
|
||||||
// The loaded values are added to `data` and `data` is returned, possibly with a shorter length.
|
|
||||||
// If `data` is not long enough to hold all values, this function will panic!
|
|
||||||
func (b *buffer) read(from, to int64, data []schema.Float) ([]schema.Float, int64, int64, error) {
|
|
||||||
if from < b.firstWrite() {
|
|
||||||
if b.prev != nil {
|
|
||||||
return b.prev.read(from, to, data)
|
|
||||||
}
|
|
||||||
from = b.firstWrite()
|
|
||||||
}
|
|
||||||
|
|
||||||
i := 0
|
|
||||||
t := from
|
|
||||||
for ; t < to; t += b.frequency {
|
|
||||||
idx := int((t - b.start) / b.frequency)
|
|
||||||
if idx >= cap(b.data) {
|
|
||||||
if b.next == nil {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
b = b.next
|
|
||||||
idx = 0
|
|
||||||
}
|
|
||||||
|
|
||||||
if idx >= len(b.data) {
|
|
||||||
if b.next == nil || to <= b.next.start {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
data[i] += schema.NaN
|
|
||||||
} else if t < b.start {
|
|
||||||
data[i] += schema.NaN
|
|
||||||
} else {
|
|
||||||
data[i] += b.data[idx]
|
|
||||||
}
|
|
||||||
i++
|
|
||||||
}
|
|
||||||
|
|
||||||
return data[:i], from, t, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Returns true if this buffer needs to be freed.
|
|
||||||
func (b *buffer) free(t int64) (delme bool, n int) {
|
|
||||||
if b.prev != nil {
|
|
||||||
delme, m := b.prev.free(t)
|
|
||||||
n += m
|
|
||||||
if delme {
|
|
||||||
b.prev.next = nil
|
|
||||||
if cap(b.prev.data) == BufferCap {
|
|
||||||
bufferPool.Put(b.prev)
|
|
||||||
}
|
|
||||||
b.prev = nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
end := b.end()
|
|
||||||
if end < t {
|
|
||||||
return true, n + 1
|
|
||||||
}
|
|
||||||
|
|
||||||
return false, n
|
|
||||||
}
|
|
||||||
|
|
||||||
// Call `callback` on every buffer that contains data in the range from `from` to `to`.
|
|
||||||
func (b *buffer) iterFromTo(from, to int64, callback func(b *buffer) error) error {
|
|
||||||
if b == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := b.prev.iterFromTo(from, to, callback); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
if from <= b.end() && b.start <= to {
|
|
||||||
return callback(b)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (b *buffer) count() int64 {
|
|
||||||
res := int64(len(b.data))
|
|
||||||
if b.prev != nil {
|
|
||||||
res += b.prev.count()
|
|
||||||
}
|
|
||||||
return res
|
|
||||||
}
|
|
||||||
@@ -1,128 +0,0 @@
|
|||||||
// Copyright (C) NHR@FAU, University Erlangen-Nuremberg.
|
|
||||||
// All rights reserved. This file is part of cc-backend.
|
|
||||||
// Use of this source code is governed by a MIT-style
|
|
||||||
// license that can be found in the LICENSE file.
|
|
||||||
|
|
||||||
package metricstore
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
const (
|
|
||||||
DefaultMaxWorkers = 10
|
|
||||||
DefaultBufferCapacity = 512
|
|
||||||
DefaultGCTriggerInterval = 100
|
|
||||||
DefaultAvroWorkers = 4
|
|
||||||
DefaultCheckpointBufferMin = 3
|
|
||||||
DefaultAvroCheckpointInterval = time.Minute
|
|
||||||
)
|
|
||||||
|
|
||||||
type Checkpoints struct {
|
|
||||||
FileFormat string `json:"file-format"`
|
|
||||||
Interval string `json:"interval"`
|
|
||||||
RootDir string `json:"directory"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type Debug struct {
|
|
||||||
DumpToFile string `json:"dump-to-file"`
|
|
||||||
EnableGops bool `json:"gops"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type Archive struct {
|
|
||||||
ArchiveInterval string `json:"interval"`
|
|
||||||
RootDir string `json:"directory"`
|
|
||||||
DeleteInstead bool `json:"delete-instead"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type Subscriptions []struct {
|
|
||||||
// Channel name
|
|
||||||
SubscribeTo string `json:"subscribe-to"`
|
|
||||||
|
|
||||||
// Allow lines without a cluster tag, use this as default, optional
|
|
||||||
ClusterTag string `json:"cluster-tag"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type MetricStoreConfig struct {
|
|
||||||
// Number of concurrent workers for checkpoint and archive operations.
|
|
||||||
// If not set or 0, defaults to min(runtime.NumCPU()/2+1, 10)
|
|
||||||
NumWorkers int `json:"num-workers"`
|
|
||||||
RetentionInMemory string `json:"retention-in-memory"`
|
|
||||||
MemoryCap int `json:"memory-cap"`
|
|
||||||
Checkpoints Checkpoints `json:"checkpoints"`
|
|
||||||
Debug *Debug `json:"debug"`
|
|
||||||
Archive *Archive `json:"archive"`
|
|
||||||
Subscriptions *Subscriptions `json:"nats-subscriptions"`
|
|
||||||
}
|
|
||||||
|
|
||||||
var Keys MetricStoreConfig = MetricStoreConfig{
|
|
||||||
Checkpoints: Checkpoints{
|
|
||||||
FileFormat: "avro",
|
|
||||||
RootDir: "./var/checkpoints",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
// AggregationStrategy for aggregation over multiple values at different cpus/sockets/..., not time!
|
|
||||||
type AggregationStrategy int
|
|
||||||
|
|
||||||
const (
|
|
||||||
NoAggregation AggregationStrategy = iota
|
|
||||||
SumAggregation
|
|
||||||
AvgAggregation
|
|
||||||
)
|
|
||||||
|
|
||||||
func AssignAggregationStrategy(str string) (AggregationStrategy, error) {
|
|
||||||
switch str {
|
|
||||||
case "":
|
|
||||||
return NoAggregation, nil
|
|
||||||
case "sum":
|
|
||||||
return SumAggregation, nil
|
|
||||||
case "avg":
|
|
||||||
return AvgAggregation, nil
|
|
||||||
default:
|
|
||||||
return NoAggregation, fmt.Errorf("[METRICSTORE]> unknown aggregation strategy: %s", str)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
type MetricConfig struct {
|
|
||||||
// Interval in seconds at which measurements are stored
|
|
||||||
Frequency int64
|
|
||||||
|
|
||||||
// Can be 'sum', 'avg' or null. Describes how to aggregate metrics from the same timestep over the hierarchy.
|
|
||||||
Aggregation AggregationStrategy
|
|
||||||
|
|
||||||
// Private, used internally...
|
|
||||||
offset int
|
|
||||||
}
|
|
||||||
|
|
||||||
var Metrics map[string]MetricConfig
|
|
||||||
|
|
||||||
func GetMetricFrequency(metricName string) (int64, error) {
|
|
||||||
if metric, ok := Metrics[metricName]; ok {
|
|
||||||
return metric.Frequency, nil
|
|
||||||
}
|
|
||||||
return 0, fmt.Errorf("[METRICSTORE]> metric %s not found", metricName)
|
|
||||||
}
|
|
||||||
|
|
||||||
// AddMetric adds logic to add metrics. Redundant metrics should be updated with max frequency.
|
|
||||||
// use metric.Name to check if the metric already exists.
|
|
||||||
// if not, add it to the Metrics map.
|
|
||||||
func AddMetric(name string, metric MetricConfig) error {
|
|
||||||
if Metrics == nil {
|
|
||||||
Metrics = make(map[string]MetricConfig, 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
if existingMetric, ok := Metrics[name]; ok {
|
|
||||||
if existingMetric.Frequency != metric.Frequency {
|
|
||||||
if existingMetric.Frequency < metric.Frequency {
|
|
||||||
existingMetric.Frequency = metric.Frequency
|
|
||||||
Metrics[name] = existingMetric
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
Metrics[name] = metric
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
@@ -1,215 +0,0 @@
|
|||||||
// Copyright (C) NHR@FAU, University Erlangen-Nuremberg.
|
|
||||||
// All rights reserved. This file is part of cc-backend.
|
|
||||||
// Use of this source code is governed by a MIT-style
|
|
||||||
// license that can be found in the LICENSE file.
|
|
||||||
|
|
||||||
package metricstore
|
|
||||||
|
|
||||||
import (
|
|
||||||
"sync"
|
|
||||||
"unsafe"
|
|
||||||
|
|
||||||
"github.com/ClusterCockpit/cc-lib/v2/util"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Could also be called "node" as this forms a node in a tree structure.
|
|
||||||
// Called Level because "node" might be confusing here.
|
|
||||||
// Can be both a leaf or a inner node. In this tree structue, inner nodes can
|
|
||||||
// also hold data (in `metrics`).
|
|
||||||
type Level struct {
|
|
||||||
children map[string]*Level
|
|
||||||
metrics []*buffer
|
|
||||||
lock sync.RWMutex
|
|
||||||
}
|
|
||||||
|
|
||||||
// Find the correct level for the given selector, creating it if
|
|
||||||
// it does not exist. Example selector in the context of the
|
|
||||||
// ClusterCockpit could be: []string{ "emmy", "host123", "cpu0" }.
|
|
||||||
// This function would probably benefit a lot from `level.children` beeing a `sync.Map`?
|
|
||||||
func (l *Level) findLevelOrCreate(selector []string, nMetrics int) *Level {
|
|
||||||
if len(selector) == 0 {
|
|
||||||
return l
|
|
||||||
}
|
|
||||||
|
|
||||||
// Allow concurrent reads:
|
|
||||||
l.lock.RLock()
|
|
||||||
var child *Level
|
|
||||||
var ok bool
|
|
||||||
if l.children == nil {
|
|
||||||
// Children map needs to be created...
|
|
||||||
l.lock.RUnlock()
|
|
||||||
} else {
|
|
||||||
child, ok = l.children[selector[0]]
|
|
||||||
l.lock.RUnlock()
|
|
||||||
if ok {
|
|
||||||
return child.findLevelOrCreate(selector[1:], nMetrics)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// The level does not exist, take write lock for unique access:
|
|
||||||
l.lock.Lock()
|
|
||||||
// While this thread waited for the write lock, another thread
|
|
||||||
// could have created the child node.
|
|
||||||
if l.children != nil {
|
|
||||||
child, ok = l.children[selector[0]]
|
|
||||||
if ok {
|
|
||||||
l.lock.Unlock()
|
|
||||||
return child.findLevelOrCreate(selector[1:], nMetrics)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
child = &Level{
|
|
||||||
metrics: make([]*buffer, nMetrics),
|
|
||||||
children: nil,
|
|
||||||
}
|
|
||||||
|
|
||||||
if l.children != nil {
|
|
||||||
l.children[selector[0]] = child
|
|
||||||
} else {
|
|
||||||
l.children = map[string]*Level{selector[0]: child}
|
|
||||||
}
|
|
||||||
l.lock.Unlock()
|
|
||||||
return child.findLevelOrCreate(selector[1:], nMetrics)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (l *Level) collectPaths(currentDepth, targetDepth int, currentPath []string, results *[][]string) {
|
|
||||||
l.lock.RLock()
|
|
||||||
defer l.lock.RUnlock()
|
|
||||||
|
|
||||||
for key, child := range l.children {
|
|
||||||
if child == nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// We explicitly make a new slice and copy data to avoid sharing underlying arrays between siblings
|
|
||||||
newPath := make([]string, len(currentPath))
|
|
||||||
copy(newPath, currentPath)
|
|
||||||
newPath = append(newPath, key)
|
|
||||||
|
|
||||||
// Check depth, and just return if depth reached
|
|
||||||
if currentDepth+1 == targetDepth {
|
|
||||||
*results = append(*results, newPath)
|
|
||||||
} else {
|
|
||||||
child.collectPaths(currentDepth+1, targetDepth, newPath, results)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (l *Level) free(t int64) (int, error) {
|
|
||||||
l.lock.Lock()
|
|
||||||
defer l.lock.Unlock()
|
|
||||||
|
|
||||||
n := 0
|
|
||||||
for i, b := range l.metrics {
|
|
||||||
if b != nil {
|
|
||||||
delme, m := b.free(t)
|
|
||||||
n += m
|
|
||||||
if delme {
|
|
||||||
if cap(b.data) == BufferCap {
|
|
||||||
bufferPool.Put(b)
|
|
||||||
}
|
|
||||||
l.metrics[i] = nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, l := range l.children {
|
|
||||||
m, err := l.free(t)
|
|
||||||
n += m
|
|
||||||
if err != nil {
|
|
||||||
return n, err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return n, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (l *Level) sizeInBytes() int64 {
|
|
||||||
l.lock.RLock()
|
|
||||||
defer l.lock.RUnlock()
|
|
||||||
size := int64(0)
|
|
||||||
|
|
||||||
for _, b := range l.metrics {
|
|
||||||
if b != nil {
|
|
||||||
size += b.count() * int64(unsafe.Sizeof(util.Float(0)))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, child := range l.children {
|
|
||||||
size += child.sizeInBytes()
|
|
||||||
}
|
|
||||||
|
|
||||||
return size
|
|
||||||
}
|
|
||||||
|
|
||||||
func (l *Level) findLevel(selector []string) *Level {
|
|
||||||
if len(selector) == 0 {
|
|
||||||
return l
|
|
||||||
}
|
|
||||||
|
|
||||||
l.lock.RLock()
|
|
||||||
defer l.lock.RUnlock()
|
|
||||||
|
|
||||||
lvl := l.children[selector[0]]
|
|
||||||
if lvl == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
return lvl.findLevel(selector[1:])
|
|
||||||
}
|
|
||||||
|
|
||||||
func (l *Level) findBuffers(selector util.Selector, offset int, f func(b *buffer) error) error {
|
|
||||||
l.lock.RLock()
|
|
||||||
defer l.lock.RUnlock()
|
|
||||||
|
|
||||||
if len(selector) == 0 {
|
|
||||||
b := l.metrics[offset]
|
|
||||||
if b != nil {
|
|
||||||
return f(b)
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, lvl := range l.children {
|
|
||||||
err := lvl.findBuffers(nil, offset, f)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
sel := selector[0]
|
|
||||||
if len(sel.String) != 0 && l.children != nil {
|
|
||||||
lvl, ok := l.children[sel.String]
|
|
||||||
if ok {
|
|
||||||
err := lvl.findBuffers(selector[1:], offset, f)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
if sel.Group != nil && l.children != nil {
|
|
||||||
for _, key := range sel.Group {
|
|
||||||
lvl, ok := l.children[key]
|
|
||||||
if ok {
|
|
||||||
err := lvl.findBuffers(selector[1:], offset, f)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
if sel.Any && l.children != nil {
|
|
||||||
for _, lvl := range l.children {
|
|
||||||
if err := lvl.findBuffers(selector[1:], offset, f); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
@@ -51,14 +51,10 @@ func setupSqlite(db *sql.DB) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func Connect(driver string, db string) {
|
func Connect(db string) {
|
||||||
var err error
|
var err error
|
||||||
var dbHandle *sqlx.DB
|
var dbHandle *sqlx.DB
|
||||||
|
|
||||||
if driver != "sqlite3" {
|
|
||||||
cclog.Abortf("Unsupported database driver '%s'. Only 'sqlite3' is supported.\n", driver)
|
|
||||||
}
|
|
||||||
|
|
||||||
dbConnOnce.Do(func() {
|
dbConnOnce.Do(func() {
|
||||||
opts := DatabaseOptions{
|
opts := DatabaseOptions{
|
||||||
URL: db,
|
URL: db,
|
||||||
@@ -100,7 +96,7 @@ func Connect(driver string, db string) {
|
|||||||
dbHandle.SetConnMaxLifetime(opts.ConnectionMaxLifetime)
|
dbHandle.SetConnMaxLifetime(opts.ConnectionMaxLifetime)
|
||||||
dbHandle.SetConnMaxIdleTime(opts.ConnectionMaxIdleTime)
|
dbHandle.SetConnMaxIdleTime(opts.ConnectionMaxIdleTime)
|
||||||
|
|
||||||
dbConnInstance = &DBConnection{DB: dbHandle, Driver: driver}
|
dbConnInstance = &DBConnection{DB: dbHandle}
|
||||||
err = checkDBVersion(dbHandle.DB)
|
err = checkDBVersion(dbHandle.DB)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
cclog.Abortf("DB Connection: Failed DB version check.\nError: %s\n", err.Error())
|
cclog.Abortf("DB Connection: Failed DB version check.\nError: %s\n", err.Error())
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ func nodeTestSetup(t *testing.T) {
|
|||||||
"main": {
|
"main": {
|
||||||
"addr": "0.0.0.0:8080",
|
"addr": "0.0.0.0:8080",
|
||||||
"validate": false,
|
"validate": false,
|
||||||
"apiAllowedIPs": [
|
"api-allowed-ips": [
|
||||||
"*"
|
"*"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@@ -139,9 +139,9 @@ func nodeTestSetup(t *testing.T) {
|
|||||||
}
|
}
|
||||||
archiveCfg := fmt.Sprintf("{\"kind\": \"file\",\"path\": \"%s\"}", jobarchive)
|
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)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -151,7 +151,7 @@ func setup(tb testing.TB) *JobRepository {
|
|||||||
dbfile := "testdata/job.db"
|
dbfile := "testdata/job.db"
|
||||||
err := MigrateDB(dbfile)
|
err := MigrateDB(dbfile)
|
||||||
noErr(tb, err)
|
noErr(tb, err)
|
||||||
Connect("sqlite3", dbfile)
|
Connect(dbfile)
|
||||||
return GetJobRepository()
|
return GetJobRepository()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ func setupUserTest(t *testing.T) *UserCfgRepo {
|
|||||||
const testconfig = `{
|
const testconfig = `{
|
||||||
"main": {
|
"main": {
|
||||||
"addr": "0.0.0.0:8080",
|
"addr": "0.0.0.0:8080",
|
||||||
"apiAllowedIPs": [
|
"api-allowed-ips": [
|
||||||
"*"
|
"*"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@@ -36,7 +36,7 @@ func setupUserTest(t *testing.T) *UserCfgRepo {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
Connect("sqlite3", dbfilepath)
|
Connect(dbfilepath)
|
||||||
|
|
||||||
tmpdir := t.TempDir()
|
tmpdir := t.TempDir()
|
||||||
cfgFilePath := filepath.Join(tmpdir, "config.json")
|
cfgFilePath := filepath.Join(tmpdir, "config.json")
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ func setup(tb testing.TB) *repository.JobRepository {
|
|||||||
dbfile := "../repository/testdata/job.db"
|
dbfile := "../repository/testdata/job.db"
|
||||||
err := repository.MigrateDB(dbfile)
|
err := repository.MigrateDB(dbfile)
|
||||||
noErr(tb, err)
|
noErr(tb, err)
|
||||||
repository.Connect("sqlite3", dbfile)
|
repository.Connect(dbfile)
|
||||||
return repository.GetJobRepository()
|
return repository.GetJobRepository()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,8 +10,8 @@ import (
|
|||||||
"math"
|
"math"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/ClusterCockpit/cc-backend/internal/metricstore"
|
|
||||||
"github.com/ClusterCockpit/cc-backend/pkg/archive"
|
"github.com/ClusterCockpit/cc-backend/pkg/archive"
|
||||||
|
"github.com/ClusterCockpit/cc-backend/pkg/metricstore"
|
||||||
cclog "github.com/ClusterCockpit/cc-lib/v2/ccLogger"
|
cclog "github.com/ClusterCockpit/cc-lib/v2/ccLogger"
|
||||||
"github.com/ClusterCockpit/cc-lib/v2/schema"
|
"github.com/ClusterCockpit/cc-lib/v2/schema"
|
||||||
sq "github.com/Masterminds/squirrel"
|
sq "github.com/Masterminds/squirrel"
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ var configSchema = `
|
|||||||
"description": "Path to job archive for file backend",
|
"description": "Path to job archive for file backend",
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
"dbPath": {
|
"db-path": {
|
||||||
"description": "Path to SQLite database file for sqlite backend",
|
"description": "Path to SQLite database file for sqlite backend",
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
@@ -26,11 +26,11 @@ var configSchema = `
|
|||||||
"description": "S3 endpoint URL (for S3-compatible services like MinIO)",
|
"description": "S3 endpoint URL (for S3-compatible services like MinIO)",
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
"accessKey": {
|
"access-key": {
|
||||||
"description": "S3 access key ID",
|
"description": "S3 access key ID",
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
"secretKey": {
|
"secret-key": {
|
||||||
"description": "S3 secret access key",
|
"description": "S3 secret access key",
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
@@ -42,7 +42,7 @@ var configSchema = `
|
|||||||
"description": "AWS region for S3 bucket",
|
"description": "AWS region for S3 bucket",
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
"usePathStyle": {
|
"use-path-style": {
|
||||||
"description": "Use path-style S3 URLs (required for MinIO and some S3-compatible services)",
|
"description": "Use path-style S3 URLs (required for MinIO and some S3-compatible services)",
|
||||||
"type": "boolean"
|
"type": "boolean"
|
||||||
},
|
},
|
||||||
@@ -59,7 +59,7 @@ var configSchema = `
|
|||||||
"type": "string",
|
"type": "string",
|
||||||
"enum": ["none", "delete", "move"]
|
"enum": ["none", "delete", "move"]
|
||||||
},
|
},
|
||||||
"includeDB": {
|
"include-db": {
|
||||||
"description": "Also remove jobs from database",
|
"description": "Also remove jobs from database",
|
||||||
"type": "boolean"
|
"type": "boolean"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -184,7 +184,6 @@ var (
|
|||||||
initOnce sync.Once
|
initOnce sync.Once
|
||||||
cache *lrucache.Cache = lrucache.New(128 * 1024 * 1024)
|
cache *lrucache.Cache = lrucache.New(128 * 1024 * 1024)
|
||||||
ar ArchiveBackend
|
ar ArchiveBackend
|
||||||
useArchive bool
|
|
||||||
mutex sync.Mutex
|
mutex sync.Mutex
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -197,12 +196,10 @@ var (
|
|||||||
//
|
//
|
||||||
// The configuration determines which backend is used (file, s3, or sqlite).
|
// The configuration determines which backend is used (file, s3, or sqlite).
|
||||||
// Returns an error if initialization fails or version is incompatible.
|
// 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
|
var err error
|
||||||
|
|
||||||
initOnce.Do(func() {
|
initOnce.Do(func() {
|
||||||
useArchive = !disableArchive
|
|
||||||
|
|
||||||
var cfg struct {
|
var cfg struct {
|
||||||
Kind string `json:"kind"`
|
Kind string `json:"kind"`
|
||||||
}
|
}
|
||||||
@@ -378,7 +375,7 @@ func UpdateMetadata(job *schema.Job, metadata map[string]string) error {
|
|||||||
mutex.Lock()
|
mutex.Lock()
|
||||||
defer mutex.Unlock()
|
defer mutex.Unlock()
|
||||||
|
|
||||||
if job.State == schema.JobStateRunning || !useArchive {
|
if job.State == schema.JobStateRunning {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -401,7 +398,7 @@ func UpdateTags(job *schema.Job, tags []*schema.Tag) error {
|
|||||||
mutex.Lock()
|
mutex.Lock()
|
||||||
defer mutex.Unlock()
|
defer mutex.Unlock()
|
||||||
|
|
||||||
if job.State == schema.JobStateRunning || !useArchive {
|
if job.State == schema.JobStateRunning {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ func setup(t *testing.T) archive.ArchiveBackend {
|
|||||||
util.CopyDir("./testdata/archive/", jobarchive)
|
util.CopyDir("./testdata/archive/", jobarchive)
|
||||||
archiveCfg := fmt.Sprintf("{\"kind\": \"file\",\"path\": \"%s\"}", 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)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func TestClusterConfig(t *testing.T) {
|
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)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -36,11 +36,11 @@ import (
|
|||||||
// S3ArchiveConfig holds the configuration for the S3 archive backend.
|
// S3ArchiveConfig holds the configuration for the S3 archive backend.
|
||||||
type S3ArchiveConfig struct {
|
type S3ArchiveConfig struct {
|
||||||
Endpoint string `json:"endpoint"` // S3 endpoint URL (optional, for MinIO/localstack)
|
Endpoint string `json:"endpoint"` // S3 endpoint URL (optional, for MinIO/localstack)
|
||||||
AccessKey string `json:"accessKey"` // AWS access key ID
|
AccessKey string `json:"access-key"` // AWS access key ID
|
||||||
SecretKey string `json:"secretKey"` // AWS secret access key
|
SecretKey string `json:"secret-key"` // AWS secret access key
|
||||||
Bucket string `json:"bucket"` // S3 bucket name
|
Bucket string `json:"bucket"` // S3 bucket name
|
||||||
Region string `json:"region"` // AWS region
|
Region string `json:"region"` // AWS region
|
||||||
UsePathStyle bool `json:"usePathStyle"` // Use path-style URLs (required for MinIO)
|
UsePathStyle bool `json:"use-path-style"` // Use path-style URLs (required for MinIO)
|
||||||
}
|
}
|
||||||
|
|
||||||
// S3Archive implements ArchiveBackend using AWS S3 or S3-compatible object storage.
|
// S3Archive implements ArchiveBackend using AWS S3 or S3-compatible object storage.
|
||||||
|
|||||||
@@ -241,11 +241,11 @@ func TestGetS3Directory(t *testing.T) {
|
|||||||
func TestS3ArchiveConfigParsing(t *testing.T) {
|
func TestS3ArchiveConfigParsing(t *testing.T) {
|
||||||
rawConfig := json.RawMessage(`{
|
rawConfig := json.RawMessage(`{
|
||||||
"endpoint": "http://localhost:9000",
|
"endpoint": "http://localhost:9000",
|
||||||
"accessKey": "minioadmin",
|
"access-key": "minioadmin",
|
||||||
"secretKey": "minioadmin",
|
"secret-key": "minioadmin",
|
||||||
"bucket": "test-bucket",
|
"bucket": "test-bucket",
|
||||||
"region": "us-east-1",
|
"region": "us-east-1",
|
||||||
"usePathStyle": true
|
"use-path-style": true
|
||||||
}`)
|
}`)
|
||||||
|
|
||||||
var cfg S3ArchiveConfig
|
var cfg S3ArchiveConfig
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ import (
|
|||||||
|
|
||||||
// SqliteArchiveConfig holds the configuration for the SQLite archive backend.
|
// SqliteArchiveConfig holds the configuration for the SQLite archive backend.
|
||||||
type SqliteArchiveConfig struct {
|
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.
|
// SqliteArchive implements ArchiveBackend using a SQLite database with BLOB storage.
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ func TestSqliteInitEmptyPath(t *testing.T) {
|
|||||||
|
|
||||||
func TestSqliteInitInvalidConfig(t *testing.T) {
|
func TestSqliteInitInvalidConfig(t *testing.T) {
|
||||||
var sa SqliteArchive
|
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 {
|
if err == nil {
|
||||||
t.Fatal("expected error for invalid config")
|
t.Fatal("expected error for invalid config")
|
||||||
}
|
}
|
||||||
@@ -33,7 +33,7 @@ func TestSqliteInit(t *testing.T) {
|
|||||||
defer os.Remove(tmpfile)
|
defer os.Remove(tmpfile)
|
||||||
|
|
||||||
var sa SqliteArchive
|
var sa SqliteArchive
|
||||||
version, err := sa.Init(json.RawMessage(`{"dbPath":"` + tmpfile + `"}`))
|
version, err := sa.Init(json.RawMessage(`{"db-path":"` + tmpfile + `"}`))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("init failed: %v", err)
|
t.Fatalf("init failed: %v", err)
|
||||||
}
|
}
|
||||||
@@ -51,7 +51,7 @@ func TestSqliteStoreAndLoadJobMeta(t *testing.T) {
|
|||||||
defer os.Remove(tmpfile)
|
defer os.Remove(tmpfile)
|
||||||
|
|
||||||
var sa SqliteArchive
|
var sa SqliteArchive
|
||||||
_, err := sa.Init(json.RawMessage(`{"dbPath":"` + tmpfile + `"}`))
|
_, err := sa.Init(json.RawMessage(`{"db-path":"` + tmpfile + `"}`))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("init failed: %v", err)
|
t.Fatalf("init failed: %v", err)
|
||||||
}
|
}
|
||||||
@@ -97,7 +97,7 @@ func TestSqliteImportJob(t *testing.T) {
|
|||||||
defer os.Remove(tmpfile)
|
defer os.Remove(tmpfile)
|
||||||
|
|
||||||
var sa SqliteArchive
|
var sa SqliteArchive
|
||||||
_, err := sa.Init(json.RawMessage(`{"dbPath":"` + tmpfile + `"}`))
|
_, err := sa.Init(json.RawMessage(`{"db-path":"` + tmpfile + `"}`))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("init failed: %v", err)
|
t.Fatalf("init failed: %v", err)
|
||||||
}
|
}
|
||||||
@@ -114,7 +114,7 @@ func TestSqliteGetClusters(t *testing.T) {
|
|||||||
defer os.Remove(tmpfile)
|
defer os.Remove(tmpfile)
|
||||||
|
|
||||||
var sa SqliteArchive
|
var sa SqliteArchive
|
||||||
_, err := sa.Init(json.RawMessage(`{"dbPath":"` + tmpfile + `"}`))
|
_, err := sa.Init(json.RawMessage(`{"db-path":"` + tmpfile + `"}`))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("init failed: %v", err)
|
t.Fatalf("init failed: %v", err)
|
||||||
}
|
}
|
||||||
@@ -141,7 +141,7 @@ func TestSqliteGetClusters(t *testing.T) {
|
|||||||
|
|
||||||
// Reinitialize to refresh cluster list
|
// Reinitialize to refresh cluster list
|
||||||
sa.db.Close()
|
sa.db.Close()
|
||||||
_, err = sa.Init(json.RawMessage(`{"dbPath":"` + tmpfile + `"}`))
|
_, err = sa.Init(json.RawMessage(`{"db-path":"` + tmpfile + `"}`))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("reinit failed: %v", err)
|
t.Fatalf("reinit failed: %v", err)
|
||||||
}
|
}
|
||||||
@@ -158,7 +158,7 @@ func TestSqliteCleanUp(t *testing.T) {
|
|||||||
defer os.Remove(tmpfile)
|
defer os.Remove(tmpfile)
|
||||||
|
|
||||||
var sa SqliteArchive
|
var sa SqliteArchive
|
||||||
_, err := sa.Init(json.RawMessage(`{"dbPath":"` + tmpfile + `"}`))
|
_, err := sa.Init(json.RawMessage(`{"db-path":"` + tmpfile + `"}`))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("init failed: %v", err)
|
t.Fatalf("init failed: %v", err)
|
||||||
}
|
}
|
||||||
@@ -193,7 +193,7 @@ func TestSqliteClean(t *testing.T) {
|
|||||||
defer os.Remove(tmpfile)
|
defer os.Remove(tmpfile)
|
||||||
|
|
||||||
var sa SqliteArchive
|
var sa SqliteArchive
|
||||||
_, err := sa.Init(json.RawMessage(`{"dbPath":"` + tmpfile + `"}`))
|
_, err := sa.Init(json.RawMessage(`{"db-path":"` + tmpfile + `"}`))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("init failed: %v", err)
|
t.Fatalf("init failed: %v", err)
|
||||||
}
|
}
|
||||||
@@ -237,7 +237,7 @@ func TestSqliteIter(t *testing.T) {
|
|||||||
defer os.Remove(tmpfile)
|
defer os.Remove(tmpfile)
|
||||||
|
|
||||||
var sa SqliteArchive
|
var sa SqliteArchive
|
||||||
_, err := sa.Init(json.RawMessage(`{"dbPath":"` + tmpfile + `"}`))
|
_, err := sa.Init(json.RawMessage(`{"db-path":"` + tmpfile + `"}`))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("init failed: %v", err)
|
t.Fatalf("init failed: %v", err)
|
||||||
}
|
}
|
||||||
@@ -276,7 +276,7 @@ func TestSqliteCompress(t *testing.T) {
|
|||||||
defer os.Remove(tmpfile)
|
defer os.Remove(tmpfile)
|
||||||
|
|
||||||
var sa SqliteArchive
|
var sa SqliteArchive
|
||||||
_, err := sa.Init(json.RawMessage(`{"dbPath":"` + tmpfile + `"}`))
|
_, err := sa.Init(json.RawMessage(`{"db-path":"` + tmpfile + `"}`))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("init failed: %v", err)
|
t.Fatalf("init failed: %v", err)
|
||||||
}
|
}
|
||||||
@@ -299,7 +299,7 @@ func TestSqliteCompress(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestSqliteConfigParsing(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
|
var cfg SqliteArchiveConfig
|
||||||
err := json.Unmarshal(rawConfig, &cfg)
|
err := json.Unmarshal(rawConfig, &cfg)
|
||||||
@@ -317,7 +317,7 @@ func TestSqliteIterChunking(t *testing.T) {
|
|||||||
defer os.Remove(tmpfile)
|
defer os.Remove(tmpfile)
|
||||||
|
|
||||||
var sa SqliteArchive
|
var sa SqliteArchive
|
||||||
_, err := sa.Init(json.RawMessage(`{"dbPath":"` + tmpfile + `"}`))
|
_, err := sa.Init(json.RawMessage(`{"db-path":"` + tmpfile + `"}`))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("init failed: %v", err)
|
t.Fatalf("init failed: %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,9 @@
|
|||||||
// Use of this source code is governed by a MIT-style
|
// Use of this source code is governed by a MIT-style
|
||||||
// license that can be found in the LICENSE file.
|
// 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
|
package metricstore
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -15,10 +18,17 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
|
// ErrInvalidTimeRange is returned when a query has 'from' >= 'to'
|
||||||
ErrInvalidTimeRange = errors.New("[METRICSTORE]> invalid time range: 'from' must be before 'to'")
|
ErrInvalidTimeRange = errors.New("[METRICSTORE]> invalid time range: 'from' must be before 'to'")
|
||||||
|
// ErrEmptyCluster is returned when a query with ForAllNodes has no cluster specified
|
||||||
ErrEmptyCluster = errors.New("[METRICSTORE]> cluster name cannot be empty")
|
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 {
|
type APIMetricData struct {
|
||||||
Error *string `json:"error,omitempty"`
|
Error *string `json:"error,omitempty"`
|
||||||
Data schema.FloatArray `json:"data,omitempty"`
|
Data schema.FloatArray `json:"data,omitempty"`
|
||||||
@@ -30,6 +40,13 @@ type APIMetricData struct {
|
|||||||
Max schema.Float `json:"max"`
|
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 {
|
type APIQueryRequest struct {
|
||||||
Cluster string `json:"cluster"`
|
Cluster string `json:"cluster"`
|
||||||
Queries []APIQuery `json:"queries"`
|
Queries []APIQuery `json:"queries"`
|
||||||
@@ -41,11 +58,25 @@ type APIQueryRequest struct {
|
|||||||
WithPadding bool `json:"with-padding"`
|
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 {
|
type APIQueryResponse struct {
|
||||||
Queries []APIQuery `json:"queries,omitempty"`
|
Queries []APIQuery `json:"queries,omitempty"`
|
||||||
Results [][]APIMetricData `json:"results"`
|
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 APIQuery struct {
|
||||||
Type *string `json:"type,omitempty"`
|
Type *string `json:"type,omitempty"`
|
||||||
SubType *string `json:"subtype,omitempty"`
|
SubType *string `json:"subtype,omitempty"`
|
||||||
@@ -58,6 +89,11 @@ type APIQuery struct {
|
|||||||
Aggregate bool `json:"aggreg"`
|
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!
|
// TODO: Optimize this, just like the stats endpoint!
|
||||||
func (data *APIMetricData) AddStats() {
|
func (data *APIMetricData) AddStats() {
|
||||||
n := 0
|
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) {
|
func (data *APIMetricData) ScaleBy(f schema.Float) {
|
||||||
if f == 0 || f == 1 {
|
if f == 0 || f == 1 {
|
||||||
return
|
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) {
|
func (data *APIMetricData) PadDataWithNull(ms *MemoryStore, from, to int64, metric string) {
|
||||||
minfo, ok := ms.Metrics[metric]
|
minfo, ok := ms.Metrics[metric]
|
||||||
if !ok {
|
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) {
|
func FetchData(req APIQueryRequest) (*APIQueryResponse, error) {
|
||||||
if req.From > req.To {
|
if req.From > req.To {
|
||||||
return nil, ErrInvalidTimeRange
|
return nil, ErrInvalidTimeRange
|
||||||
@@ -126,7 +202,7 @@ func FetchData(req APIQueryRequest) (*APIQueryResponse, error) {
|
|||||||
req.WithData = true
|
req.WithData = true
|
||||||
ms := GetMemoryStore()
|
ms := GetMemoryStore()
|
||||||
if ms == nil {
|
if ms == nil {
|
||||||
return nil, fmt.Errorf("memorystore not initialized")
|
return nil, fmt.Errorf("[METRICSTORE]> memorystore not initialized")
|
||||||
}
|
}
|
||||||
|
|
||||||
response := APIQueryResponse{
|
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
|
var err error
|
||||||
res := make([]APIMetricData, 0, len(sels))
|
res := make([]APIMetricData, 0, len(sels))
|
||||||
for _, sel := range sels {
|
for _, sel := range sels {
|
||||||
@@ -21,12 +21,40 @@ import (
|
|||||||
cclog "github.com/ClusterCockpit/cc-lib/v2/ccLogger"
|
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() {
|
go func() {
|
||||||
defer wg.Done()
|
defer wg.Done()
|
||||||
d, err := time.ParseDuration(Keys.Archive.ArchiveInterval)
|
|
||||||
|
d, err := time.ParseDuration(interval)
|
||||||
if err != nil {
|
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 {
|
if d <= 0 {
|
||||||
return
|
return
|
||||||
@@ -41,25 +69,29 @@ func Archiving(wg *sync.WaitGroup, ctx context.Context) {
|
|||||||
return
|
return
|
||||||
case <-ticker.C:
|
case <-ticker.C:
|
||||||
t := time.Now().Add(-d)
|
t := time.Now().Add(-d)
|
||||||
cclog.Infof("[METRICSTORE]> start archiving checkpoints (older than %s)...", t.Format(time.RFC3339))
|
cclog.Infof("[METRICSTORE]> start %s checkpoints (older than %s)...", mode, t.Format(time.RFC3339))
|
||||||
n, err := ArchiveCheckpoints(Keys.Checkpoints.RootDir,
|
|
||||||
Keys.Archive.RootDir, t.Unix(), Keys.Archive.DeleteInstead)
|
n, err := CleanupCheckpoints(Keys.Checkpoints.RootDir, cleanupDir, t.Unix(), delete)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
cclog.Errorf("[METRICSTORE]> archiving failed: %s", err.Error())
|
cclog.Errorf("[METRICSTORE]> %s failed: %s", mode, err.Error())
|
||||||
|
} else {
|
||||||
|
if delete && cleanupDir == "" {
|
||||||
|
cclog.Infof("[METRICSTORE]> done: %d checkpoints deleted", n)
|
||||||
} else {
|
} else {
|
||||||
cclog.Infof("[METRICSTORE]> done: %d files zipped and moved to archive", n)
|
cclog.Infof("[METRICSTORE]> done: %d files zipped and moved to archive", n)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
|
|
||||||
var ErrNoNewArchiveData error = errors.New("all data already archived")
|
var ErrNoNewArchiveData error = errors.New("all data already archived")
|
||||||
|
|
||||||
// ZIP all checkpoint files older than `from` together and write them to the `archiveDir`,
|
// Delete or ZIP all checkpoint files older than `from` together and write them to the `cleanupDir`,
|
||||||
// deleting them from the `checkpointsDir`.
|
// deleting/moving them from the `checkpointsDir`.
|
||||||
func ArchiveCheckpoints(checkpointsDir, archiveDir string, from int64, deleteInstead bool) (int, error) {
|
func CleanupCheckpoints(checkpointsDir, cleanupDir string, from int64, deleteInstead bool) (int, error) {
|
||||||
entries1, err := os.ReadDir(checkpointsDir)
|
entries1, err := os.ReadDir(checkpointsDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, err
|
return 0, err
|
||||||
@@ -79,7 +111,7 @@ func ArchiveCheckpoints(checkpointsDir, archiveDir string, from int64, deleteIns
|
|||||||
go func() {
|
go func() {
|
||||||
defer wg.Done()
|
defer wg.Done()
|
||||||
for workItem := range work {
|
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 {
|
if err != nil {
|
||||||
cclog.Errorf("error while archiving %s/%s: %s", workItem.cluster, workItem.host, err.Error())
|
cclog.Errorf("error while archiving %s/%s: %s", workItem.cluster, workItem.host, err.Error())
|
||||||
atomic.AddInt32(&errs, 1)
|
atomic.AddInt32(&errs, 1)
|
||||||
@@ -97,7 +129,7 @@ func ArchiveCheckpoints(checkpointsDir, archiveDir string, from int64, deleteIns
|
|||||||
|
|
||||||
for _, de2 := range entries2 {
|
for _, de2 := range entries2 {
|
||||||
cdir := filepath.Join(checkpointsDir, de1.Name(), de2.Name())
|
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{
|
work <- workItem{
|
||||||
adir: adir, cdir: cdir,
|
adir: adir, cdir: cdir,
|
||||||
cluster: de1.Name(), host: de2.Name(),
|
cluster: de1.Name(), host: de2.Name(),
|
||||||
@@ -118,8 +150,8 @@ func ArchiveCheckpoints(checkpointsDir, archiveDir string, from int64, deleteIns
|
|||||||
return int(n), nil
|
return int(n), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper function for `ArchiveCheckpoints`.
|
// Helper function for `CleanupCheckpoints`.
|
||||||
func archiveCheckpoints(dir string, archiveDir string, from int64, deleteInstead bool) (int, error) {
|
func cleanupCheckpoints(dir string, cleanupDir string, from int64, deleteInstead bool) (int, error) {
|
||||||
entries, err := os.ReadDir(dir)
|
entries, err := os.ReadDir(dir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, err
|
return 0, err
|
||||||
@@ -143,10 +175,10 @@ func archiveCheckpoints(dir string, archiveDir string, from int64, deleteInstead
|
|||||||
return n, nil
|
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)
|
f, err := os.OpenFile(filename, os.O_CREATE|os.O_WRONLY, CheckpointFilePerms)
|
||||||
if err != nil && os.IsNotExist(err) {
|
if err != nil && os.IsNotExist(err) {
|
||||||
err = os.MkdirAll(archiveDir, CheckpointDirPerms)
|
err = os.MkdirAll(cleanupDir, CheckpointDirPerms)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
f, err = os.OpenFile(filename, os.O_CREATE|os.O_WRONLY, CheckpointFilePerms)
|
f, err = os.OpenFile(filename, os.O_CREATE|os.O_WRONLY, CheckpointFilePerms)
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,318 @@
|
|||||||
|
// Copyright (C) NHR@FAU, University Erlangen-Nuremberg.
|
||||||
|
// All rights reserved. This file is part of cc-backend.
|
||||||
|
// Use of this source code is governed by a MIT-style
|
||||||
|
// license that can be found in the LICENSE file.
|
||||||
|
|
||||||
|
// Package metricstore provides buffer.go: Time-series data buffer implementation.
|
||||||
|
//
|
||||||
|
// # Buffer Architecture
|
||||||
|
//
|
||||||
|
// Each metric at each hierarchical level (cluster/host/cpu/etc.) uses a linked-list
|
||||||
|
// chain of fixed-size buffers to store time-series data. This design:
|
||||||
|
//
|
||||||
|
// - Avoids reallocation/copying when growing (new links added instead)
|
||||||
|
// - Enables efficient pooling (buffers returned to sync.Pool)
|
||||||
|
// - Supports traversal back in time (via prev pointers)
|
||||||
|
// - Maintains temporal ordering (newer data in later buffers)
|
||||||
|
//
|
||||||
|
// # Buffer Chain Example
|
||||||
|
//
|
||||||
|
// [oldest buffer] <- prev -- [older] <- prev -- [newest buffer (head)]
|
||||||
|
// start=1000 start=1512 start=2024
|
||||||
|
// data=[v0...v511] data=[v0...v511] data=[v0...v42]
|
||||||
|
//
|
||||||
|
// When the head buffer reaches capacity (BufferCap = 512), a new buffer becomes
|
||||||
|
// the new head and the old head is linked via prev.
|
||||||
|
//
|
||||||
|
// # Pooling Strategy
|
||||||
|
//
|
||||||
|
// sync.Pool reduces GC pressure for the common case (BufferCap-sized allocations).
|
||||||
|
// Non-standard capacity buffers are not pooled (e.g., from checkpoint deserialization).
|
||||||
|
//
|
||||||
|
// # Time Alignment
|
||||||
|
//
|
||||||
|
// Timestamps are aligned to measurement frequency intervals:
|
||||||
|
//
|
||||||
|
// index = (timestamp - buffer.start) / buffer.frequency
|
||||||
|
// actualTime = buffer.start + (frequency / 2) + (index * frequency)
|
||||||
|
//
|
||||||
|
// Missing data points are represented as NaN values. The read() function performs
|
||||||
|
// linear interpolation where possible.
|
||||||
|
package metricstore
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"github.com/ClusterCockpit/cc-lib/v2/schema"
|
||||||
|
)
|
||||||
|
|
||||||
|
// BufferCap is the default buffer capacity.
|
||||||
|
// buffer.data will only ever grow up to its capacity and a new link
|
||||||
|
// in the buffer chain will be created if needed so that no copying
|
||||||
|
// of data or reallocation needs to happen on writes.
|
||||||
|
const BufferCap int = DefaultBufferCapacity
|
||||||
|
|
||||||
|
var bufferPool sync.Pool = sync.Pool{
|
||||||
|
New: func() any {
|
||||||
|
return &buffer{
|
||||||
|
data: make([]schema.Float, 0, BufferCap),
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
// ErrNoData indicates no time-series data exists for the requested metric/level.
|
||||||
|
ErrNoData error = errors.New("[METRICSTORE]> no data for this metric/level")
|
||||||
|
|
||||||
|
// ErrDataDoesNotAlign indicates that aggregated data from child scopes
|
||||||
|
// does not align with the parent scope's expected timestamps/intervals.
|
||||||
|
ErrDataDoesNotAlign error = errors.New("[METRICSTORE]> data from lower granularities does not align")
|
||||||
|
)
|
||||||
|
|
||||||
|
// buffer stores time-series data for a single metric at a specific hierarchical level.
|
||||||
|
//
|
||||||
|
// Buffers form doubly-linked chains ordered by time. When capacity is reached,
|
||||||
|
// a new buffer becomes the head and the old head is linked via prev/next.
|
||||||
|
//
|
||||||
|
// Fields:
|
||||||
|
// - prev: Link to older buffer in the chain (nil if this is oldest)
|
||||||
|
// - next: Link to newer buffer in the chain (nil if this is newest/head)
|
||||||
|
// - data: Time-series values (schema.Float supports NaN for missing data)
|
||||||
|
// - frequency: Measurement interval in seconds
|
||||||
|
// - start: Start timestamp (adjusted by -frequency/2 for alignment)
|
||||||
|
// - archived: True if data has been persisted to disk archive
|
||||||
|
// - closed: True if buffer is no longer accepting writes
|
||||||
|
//
|
||||||
|
// Index calculation: index = (timestamp - start) / frequency
|
||||||
|
// Actual data timestamp: start + (frequency / 2) + (index * frequency)
|
||||||
|
type buffer struct {
|
||||||
|
prev *buffer
|
||||||
|
next *buffer
|
||||||
|
data []schema.Float
|
||||||
|
frequency int64
|
||||||
|
start int64
|
||||||
|
archived bool
|
||||||
|
closed bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func newBuffer(ts, freq int64) *buffer {
|
||||||
|
b := bufferPool.Get().(*buffer)
|
||||||
|
b.frequency = freq
|
||||||
|
b.start = ts - (freq / 2)
|
||||||
|
b.prev = nil
|
||||||
|
b.next = nil
|
||||||
|
b.archived = false
|
||||||
|
b.closed = false
|
||||||
|
b.data = b.data[:0]
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|
||||||
|
// write appends a timestamped value to the buffer chain.
|
||||||
|
//
|
||||||
|
// Returns the head buffer (which may be newly created if capacity was reached).
|
||||||
|
// Timestamps older than the buffer's start are rejected. If the calculated index
|
||||||
|
// exceeds capacity, a new buffer is allocated and linked as the new head.
|
||||||
|
//
|
||||||
|
// Missing timestamps are automatically filled with NaN values to maintain alignment.
|
||||||
|
// Overwrites are allowed if the index is already within the existing data slice.
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// - ts: Unix timestamp in seconds
|
||||||
|
// - value: Metric value (can be schema.NaN for missing data)
|
||||||
|
//
|
||||||
|
// Returns:
|
||||||
|
// - *buffer: The new head buffer (same as b if no new buffer created)
|
||||||
|
// - error: Non-nil if timestamp is before buffer start
|
||||||
|
func (b *buffer) write(ts int64, value schema.Float) (*buffer, error) {
|
||||||
|
if ts < b.start {
|
||||||
|
return nil, errors.New("[METRICSTORE]> cannot write value to buffer from past")
|
||||||
|
}
|
||||||
|
|
||||||
|
// idx := int((ts - b.start + (b.frequency / 3)) / b.frequency)
|
||||||
|
idx := int((ts - b.start) / b.frequency)
|
||||||
|
if idx >= cap(b.data) {
|
||||||
|
newbuf := newBuffer(ts, b.frequency)
|
||||||
|
newbuf.prev = b
|
||||||
|
b.next = newbuf
|
||||||
|
b = newbuf
|
||||||
|
idx = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// Overwriting value or writing value from past
|
||||||
|
if idx < len(b.data) {
|
||||||
|
b.data[idx] = value
|
||||||
|
return b, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fill up unwritten slots with NaN
|
||||||
|
for i := len(b.data); i < idx; i++ {
|
||||||
|
b.data = append(b.data, schema.NaN)
|
||||||
|
}
|
||||||
|
|
||||||
|
b.data = append(b.data, value)
|
||||||
|
return b, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *buffer) end() int64 {
|
||||||
|
return b.firstWrite() + int64(len(b.data))*b.frequency
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *buffer) firstWrite() int64 {
|
||||||
|
return b.start + (b.frequency / 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
// read retrieves time-series data from the buffer chain for the specified time range.
|
||||||
|
//
|
||||||
|
// Traverses the buffer chain backwards (via prev links) if 'from' precedes the current
|
||||||
|
// buffer's start. Missing data points are represented as NaN. Values are accumulated
|
||||||
|
// into the provided 'data' slice (using +=, so caller must zero-initialize if needed).
|
||||||
|
//
|
||||||
|
// The function adjusts the actual time range returned if data is unavailable at the
|
||||||
|
// boundaries (returned via adjusted from/to timestamps).
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// - from: Start timestamp (Unix seconds)
|
||||||
|
// - to: End timestamp (Unix seconds, exclusive)
|
||||||
|
// - data: Pre-allocated slice to accumulate results (must be large enough)
|
||||||
|
//
|
||||||
|
// Returns:
|
||||||
|
// - []schema.Float: Slice of data (may be shorter than input 'data' slice)
|
||||||
|
// - int64: Actual start timestamp with available data
|
||||||
|
// - int64: Actual end timestamp (exclusive)
|
||||||
|
// - error: Non-nil on failure
|
||||||
|
//
|
||||||
|
// Panics if 'data' slice is too small to hold all values in [from, to).
|
||||||
|
func (b *buffer) read(from, to int64, data []schema.Float) ([]schema.Float, int64, int64, error) {
|
||||||
|
if from < b.firstWrite() {
|
||||||
|
if b.prev != nil {
|
||||||
|
return b.prev.read(from, to, data)
|
||||||
|
}
|
||||||
|
from = b.firstWrite()
|
||||||
|
}
|
||||||
|
|
||||||
|
i := 0
|
||||||
|
t := from
|
||||||
|
for ; t < to; t += b.frequency {
|
||||||
|
idx := int((t - b.start) / b.frequency)
|
||||||
|
if idx >= cap(b.data) {
|
||||||
|
if b.next == nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
b = b.next
|
||||||
|
idx = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
if idx >= len(b.data) {
|
||||||
|
if b.next == nil || to <= b.next.start {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
data[i] += schema.NaN
|
||||||
|
} else if t < b.start {
|
||||||
|
data[i] += schema.NaN
|
||||||
|
} else {
|
||||||
|
data[i] += b.data[idx]
|
||||||
|
}
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
|
||||||
|
return data[:i], from, t, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// free removes buffers older than the specified timestamp from the chain.
|
||||||
|
//
|
||||||
|
// Recursively traverses backwards (via prev) and unlinks buffers whose end time
|
||||||
|
// is before the retention threshold. Freed buffers are returned to the pool if
|
||||||
|
// they have the standard capacity (BufferCap).
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// - t: Retention threshold timestamp (Unix seconds)
|
||||||
|
//
|
||||||
|
// Returns:
|
||||||
|
// - delme: True if the current buffer itself should be deleted by caller
|
||||||
|
// - n: Number of buffers freed in this subtree
|
||||||
|
func (b *buffer) free(t int64) (delme bool, n int) {
|
||||||
|
if b.prev != nil {
|
||||||
|
delme, m := b.prev.free(t)
|
||||||
|
n += m
|
||||||
|
if delme {
|
||||||
|
b.prev.next = nil
|
||||||
|
if cap(b.prev.data) == BufferCap {
|
||||||
|
bufferPool.Put(b.prev)
|
||||||
|
}
|
||||||
|
b.prev = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
end := b.end()
|
||||||
|
if end < t {
|
||||||
|
return true, n + 1
|
||||||
|
}
|
||||||
|
|
||||||
|
return false, n
|
||||||
|
}
|
||||||
|
|
||||||
|
// forceFreeOldest recursively finds the end of the linked list (the oldest buffer)
|
||||||
|
// and removes it.
|
||||||
|
// Returns:
|
||||||
|
//
|
||||||
|
// delme: true if 'b' itself is the oldest and should be removed by the caller
|
||||||
|
// n: the number of buffers freed (will be 1 or 0)
|
||||||
|
func (b *buffer) forceFreeOldest() (delme bool, n int) {
|
||||||
|
// If there is a previous buffer, recurse down to find the oldest
|
||||||
|
if b.prev != nil {
|
||||||
|
delPrev, freed := b.prev.forceFreeOldest()
|
||||||
|
|
||||||
|
// If the previous buffer signals it should be deleted:
|
||||||
|
if delPrev {
|
||||||
|
// Clear links on the dying buffer to prevent leaks
|
||||||
|
b.prev.next = nil
|
||||||
|
b.prev.data = nil // Release the underlying float slice immediately
|
||||||
|
|
||||||
|
// Remove the link from the current buffer
|
||||||
|
b.prev = nil
|
||||||
|
}
|
||||||
|
return false, freed
|
||||||
|
}
|
||||||
|
|
||||||
|
// If b.prev is nil, THIS buffer is the oldest.
|
||||||
|
// We return true so the parent (or the Level loop) knows to delete reference to 'b'.
|
||||||
|
return true, 1
|
||||||
|
}
|
||||||
|
|
||||||
|
// iterFromTo invokes callback on every buffer in the chain that overlaps [from, to].
|
||||||
|
//
|
||||||
|
// Traverses backwards (via prev) first, then processes current buffer if it overlaps
|
||||||
|
// the time range. Used for checkpoint/archive operations that need to serialize buffers
|
||||||
|
// within a specific time window.
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// - from: Start timestamp (Unix seconds, inclusive)
|
||||||
|
// - to: End timestamp (Unix seconds, inclusive)
|
||||||
|
// - callback: Function to invoke on each overlapping buffer
|
||||||
|
//
|
||||||
|
// Returns:
|
||||||
|
// - error: First error returned by callback, or nil if all succeeded
|
||||||
|
func (b *buffer) iterFromTo(from, to int64, callback func(b *buffer) error) error {
|
||||||
|
if b == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := b.prev.iterFromTo(from, to, callback); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if from <= b.end() && b.start <= to {
|
||||||
|
return callback(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *buffer) count() int64 {
|
||||||
|
res := int64(len(b.data))
|
||||||
|
if b.prev != nil {
|
||||||
|
res += b.prev.count()
|
||||||
|
}
|
||||||
|
return res
|
||||||
|
}
|
||||||
@@ -3,6 +3,34 @@
|
|||||||
// Use of this source code is governed by a MIT-style
|
// Use of this source code is governed by a MIT-style
|
||||||
// license that can be found in the LICENSE file.
|
// 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
|
package metricstore
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -29,18 +57,21 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
CheckpointFilePerms = 0o644
|
CheckpointFilePerms = 0o644 // File permissions for checkpoint files
|
||||||
CheckpointDirPerms = 0o755
|
CheckpointDirPerms = 0o755 // Directory permissions for checkpoint directories
|
||||||
GCTriggerInterval = DefaultGCTriggerInterval
|
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 {
|
type CheckpointMetrics struct {
|
||||||
Data []schema.Float `json:"data"`
|
Data []schema.Float `json:"data"`
|
||||||
Frequency int64 `json:"frequency"`
|
Frequency int64 `json:"frequency"`
|
||||||
Start int64 `json:"start"`
|
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 {
|
type CheckpointFile struct {
|
||||||
Metrics map[string]*CheckpointMetrics `json:"metrics"`
|
Metrics map[string]*CheckpointMetrics `json:"metrics"`
|
||||||
Children map[string]*CheckpointFile `json:"children"`
|
Children map[string]*CheckpointFile `json:"children"`
|
||||||
@@ -48,10 +79,23 @@ type CheckpointFile struct {
|
|||||||
To int64 `json:"to"`
|
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) {
|
func Checkpointing(wg *sync.WaitGroup, ctx context.Context) {
|
||||||
|
lastCheckpointMu.Lock()
|
||||||
lastCheckpoint = time.Now()
|
lastCheckpoint = time.Now()
|
||||||
|
lastCheckpointMu.Unlock()
|
||||||
|
|
||||||
if Keys.Checkpoints.FileFormat == "json" {
|
if Keys.Checkpoints.FileFormat == "json" {
|
||||||
ms := GetMemoryStore()
|
ms := GetMemoryStore()
|
||||||
@@ -60,9 +104,10 @@ func Checkpointing(wg *sync.WaitGroup, ctx context.Context) {
|
|||||||
defer wg.Done()
|
defer wg.Done()
|
||||||
d, err := time.ParseDuration(Keys.Checkpoints.Interval)
|
d, err := time.ParseDuration(Keys.Checkpoints.Interval)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
cclog.Fatal(err)
|
cclog.Fatalf("[METRICSTORE]> invalid checkpoint interval '%s': %s", Keys.Checkpoints.Interval, err.Error())
|
||||||
}
|
}
|
||||||
if d <= 0 {
|
if d <= 0 {
|
||||||
|
cclog.Warnf("[METRICSTORE]> checkpoint interval is zero or negative (%s), checkpointing disabled", d)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -74,15 +119,21 @@ func Checkpointing(wg *sync.WaitGroup, ctx context.Context) {
|
|||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
return
|
return
|
||||||
case <-ticker.C:
|
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()
|
now := time.Now()
|
||||||
n, err := ms.ToCheckpoint(Keys.Checkpoints.RootDir,
|
n, err := ms.ToCheckpoint(Keys.Checkpoints.RootDir,
|
||||||
lastCheckpoint.Unix(), now.Unix())
|
from.Unix(), now.Unix())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
cclog.Errorf("[METRICSTORE]> checkpointing failed: %s", err.Error())
|
cclog.Errorf("[METRICSTORE]> checkpointing failed: %s", err.Error())
|
||||||
} else {
|
} else {
|
||||||
cclog.Infof("[METRICSTORE]> done: %d checkpoint files created", n)
|
cclog.Infof("[METRICSTORE]> done: %d checkpoint files created", n)
|
||||||
|
lastCheckpointMu.Lock()
|
||||||
lastCheckpoint = now
|
lastCheckpoint = now
|
||||||
|
lastCheckpointMu.Unlock()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -113,9 +164,10 @@ func Checkpointing(wg *sync.WaitGroup, ctx context.Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// As `Float` implements a custom MarshalJSON() function,
|
// MarshalJSON provides optimized JSON encoding for CheckpointMetrics.
|
||||||
// serializing an array of such types has more overhead
|
//
|
||||||
// than one would assume (because of extra allocations, interfaces and so on).
|
// 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) {
|
func (cm *CheckpointMetrics) MarshalJSON() ([]byte, error) {
|
||||||
buf := make([]byte, 0, 128+len(cm.Data)*8)
|
buf := make([]byte, 0, 128+len(cm.Data)*8)
|
||||||
buf = append(buf, `{"frequency":`...)
|
buf = append(buf, `{"frequency":`...)
|
||||||
@@ -137,13 +189,27 @@ func (cm *CheckpointMetrics) MarshalJSON() ([]byte, error) {
|
|||||||
return buf, nil
|
return buf, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Metrics stored at the lowest 2 levels are not stored away (root and cluster)!
|
// ToCheckpoint writes metric data to checkpoint files in parallel.
|
||||||
// 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
|
// Metrics at root and cluster levels are skipped. One file per host is created.
|
||||||
// in parallel to writes/reads.
|
// 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) {
|
func (m *MemoryStore) ToCheckpoint(dir string, from, to int64) (int, error) {
|
||||||
levels := make([]*Level, 0)
|
// Pre-calculate capacity by counting cluster/host pairs
|
||||||
selectors := make([][]string, 0)
|
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()
|
m.root.lock.RLock()
|
||||||
for sel1, l1 := range m.root.children {
|
for sel1, l1 := range m.root.children {
|
||||||
l1.lock.RLock()
|
l1.lock.RLock()
|
||||||
@@ -203,6 +269,8 @@ func (m *MemoryStore) ToCheckpoint(dir string, from, to int64) (int, error) {
|
|||||||
return int(n), nil
|
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) {
|
func (l *Level) toCheckpointFile(from, to int64, m *MemoryStore) (*CheckpointFile, error) {
|
||||||
l.lock.RLock()
|
l.lock.RLock()
|
||||||
defer l.lock.RUnlock()
|
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 {
|
b.iterFromTo(from, to, func(b *buffer) error {
|
||||||
if !b.archived {
|
if !b.archived {
|
||||||
allArchived = false
|
allArchived = false
|
||||||
|
return fmt.Errorf("stop") // Early termination signal
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
@@ -267,6 +336,8 @@ func (l *Level) toCheckpointFile(from, to int64, m *MemoryStore) (*CheckpointFil
|
|||||||
return retval, nil
|
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 {
|
func (l *Level) toCheckpoint(dir string, from, to int64, m *MemoryStore) error {
|
||||||
cf, err := l.toCheckpointFile(from, to, m)
|
cf, err := l.toCheckpointFile(from, to, m)
|
||||||
if err != nil {
|
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))
|
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) {
|
if err != nil && os.IsNotExist(err) {
|
||||||
err = os.MkdirAll(dir, CheckpointDirPerms)
|
err = os.MkdirAll(dir, CheckpointDirPerms)
|
||||||
if err == nil {
|
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 {
|
if err != nil {
|
||||||
@@ -298,9 +369,54 @@ func (l *Level) toCheckpoint(dir string, from, to int64, m *MemoryStore) error {
|
|||||||
return bw.Flush()
|
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) {
|
func (m *MemoryStore) FromCheckpoint(dir string, from int64, extension string) (int, error) {
|
||||||
var wg sync.WaitGroup
|
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)
|
n, errs := int32(0), int32(0)
|
||||||
|
|
||||||
wg.Add(Keys.NumWorkers)
|
wg.Add(Keys.NumWorkers)
|
||||||
@@ -319,40 +435,7 @@ func (m *MemoryStore) FromCheckpoint(dir string, from int64, extension string) (
|
|||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
|
|
||||||
i := 0
|
err := enqueueCheckpointHosts(dir, work)
|
||||||
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:
|
|
||||||
close(work)
|
close(work)
|
||||||
wg.Wait()
|
wg.Wait()
|
||||||
|
|
||||||
@@ -366,9 +449,11 @@ done:
|
|||||||
return int(n), nil
|
return int(n), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Metrics stored at the lowest 2 levels are not loaded (root and cluster)!
|
// FromCheckpointFiles is the main entry point for loading checkpoints at startup.
|
||||||
// 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.
|
// 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) {
|
func (m *MemoryStore) FromCheckpointFiles(dir string, from int64) (int, error) {
|
||||||
if _, err := os.Stat(dir); os.IsNotExist(err) {
|
if _, err := os.Stat(dir); os.IsNotExist(err) {
|
||||||
// The directory does not exist, so create it using os.MkdirAll()
|
// 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
|
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) {
|
func checkFilesWithExtension(dir string, extension string) (bool, error) {
|
||||||
found := false
|
found := false
|
||||||
|
|
||||||
@@ -730,13 +816,24 @@ func findFiles(direntries []fs.DirEntry, t int64, extension string, findMoreRece
|
|||||||
return nums[a.Name()] < nums[b.Name()]
|
return nums[a.Name()] < nums[b.Name()]
|
||||||
})
|
})
|
||||||
|
|
||||||
|
if len(nums) == 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
filenames := make([]string, 0)
|
filenames := make([]string, 0)
|
||||||
for i := range direntries {
|
|
||||||
e := direntries[i]
|
for i, e := range direntries {
|
||||||
ts1 := nums[e.Name()]
|
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 {
|
if findMoreRecentFiles && t <= ts1 {
|
||||||
filenames = append(filenames, e.Name())
|
filenames = append(filenames, e.Name())
|
||||||
|
} else if !findMoreRecentFiles && ts1 <= t && ts1 != 0 {
|
||||||
|
filenames = append(filenames, e.Name())
|
||||||
}
|
}
|
||||||
if i == len(direntries)-1 {
|
if i == len(direntries)-1 {
|
||||||
continue
|
continue
|
||||||
@@ -749,10 +846,6 @@ func findFiles(direntries []fs.DirEntry, t int64, extension string, findMoreRece
|
|||||||
if ts1 < t && t < ts2 {
|
if ts1 < t && t < ts2 {
|
||||||
filenames = append(filenames, e.Name())
|
filenames = append(filenames, e.Name())
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
if ts2 < t {
|
|
||||||
filenames = append(filenames, e.Name())
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,261 @@
|
|||||||
|
// Copyright (C) NHR@FAU, University Erlangen-Nuremberg.
|
||||||
|
// All rights reserved. This file is part of cc-backend.
|
||||||
|
// Use of this source code is governed by a MIT-style
|
||||||
|
// license that can be found in the LICENSE file.
|
||||||
|
|
||||||
|
// Package metricstore provides config.go: Configuration structures and metric management.
|
||||||
|
//
|
||||||
|
// # Configuration Hierarchy
|
||||||
|
//
|
||||||
|
// The metricstore package uses nested configuration structures:
|
||||||
|
//
|
||||||
|
// MetricStoreConfig (Keys)
|
||||||
|
// ├─ NumWorkers: Parallel checkpoint/archive workers
|
||||||
|
// ├─ RetentionInMemory: How long to keep data in RAM
|
||||||
|
// ├─ MemoryCap: Memory limit in bytes (triggers forceFree)
|
||||||
|
// ├─ Checkpoints: Persistence configuration
|
||||||
|
// │ ├─ FileFormat: "avro" or "json"
|
||||||
|
// │ ├─ Interval: How often to save (e.g., "1h")
|
||||||
|
// │ └─ RootDir: Checkpoint storage path
|
||||||
|
// ├─ Cleanup: Long-term storage configuration
|
||||||
|
// │ ├─ Interval: How often to delete/archive
|
||||||
|
// │ ├─ RootDir: Archive storage path
|
||||||
|
// │ └─ Mode: "delete" or "archive"
|
||||||
|
// ├─ Debug: Development/debugging options
|
||||||
|
// └─ Subscriptions: NATS topic subscriptions for metric ingestion
|
||||||
|
//
|
||||||
|
// # Metric Configuration
|
||||||
|
//
|
||||||
|
// Each metric (e.g., "cpu_load", "mem_used") has a MetricConfig entry in the global
|
||||||
|
// Metrics map, defining:
|
||||||
|
//
|
||||||
|
// - Frequency: Measurement interval in seconds
|
||||||
|
// - Aggregation: How to combine values (sum/avg/none) when transforming scopes
|
||||||
|
// - offset: Internal index into Level.metrics slice (assigned during Init)
|
||||||
|
//
|
||||||
|
// # AggregationStrategy
|
||||||
|
//
|
||||||
|
// Determines how to combine metric values when aggregating from finer to coarser scopes:
|
||||||
|
//
|
||||||
|
// - NoAggregation: Do not combine (incompatible scopes)
|
||||||
|
// - SumAggregation: Add values (e.g., power consumption: core→socket)
|
||||||
|
// - AvgAggregation: Average values (e.g., temperature: core→socket)
|
||||||
|
package metricstore
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
DefaultMaxWorkers = 10
|
||||||
|
DefaultBufferCapacity = 512
|
||||||
|
DefaultGCTriggerInterval = 100
|
||||||
|
DefaultAvroWorkers = 4
|
||||||
|
DefaultCheckpointBufferMin = 3
|
||||||
|
DefaultAvroCheckpointInterval = time.Minute
|
||||||
|
DefaultMemoryUsageTrackerInterval = 1 * time.Hour
|
||||||
|
)
|
||||||
|
|
||||||
|
// Checkpoints configures periodic persistence of in-memory metric data.
|
||||||
|
//
|
||||||
|
// Fields:
|
||||||
|
// - FileFormat: "avro" (default, binary, compact) or "json" (human-readable, slower)
|
||||||
|
// - Interval: Duration string (e.g., "1h", "30m") between checkpoint saves
|
||||||
|
// - RootDir: Filesystem path for checkpoint files (created if missing)
|
||||||
|
type Checkpoints struct {
|
||||||
|
FileFormat string `json:"file-format"`
|
||||||
|
Interval string `json:"interval"`
|
||||||
|
RootDir string `json:"directory"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Debug provides development and profiling options.
|
||||||
|
//
|
||||||
|
// Fields:
|
||||||
|
// - DumpToFile: Path to dump checkpoint data for inspection (empty = disabled)
|
||||||
|
// - EnableGops: Enable gops agent for live runtime debugging (https://github.com/google/gops)
|
||||||
|
type Debug struct {
|
||||||
|
DumpToFile string `json:"dump-to-file"`
|
||||||
|
EnableGops bool `json:"gops"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Archive configures long-term storage of old metric data.
|
||||||
|
//
|
||||||
|
// Data older than RetentionInMemory is archived to disk or deleted.
|
||||||
|
//
|
||||||
|
// Fields:
|
||||||
|
// - ArchiveInterval: Duration string (e.g., "24h") between archive operations
|
||||||
|
// - RootDir: Filesystem path for archived data (created if missing)
|
||||||
|
// - DeleteInstead: If true, delete old data instead of archiving (saves disk space)
|
||||||
|
type Cleanup struct {
|
||||||
|
Interval string `json:"interval"`
|
||||||
|
RootDir string `json:"directory"`
|
||||||
|
Mode string `json:"mode"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Subscriptions defines NATS topics to subscribe to for metric ingestion.
|
||||||
|
//
|
||||||
|
// Each subscription receives metrics via NATS messaging, enabling real-time
|
||||||
|
// data collection from compute nodes.
|
||||||
|
//
|
||||||
|
// Fields:
|
||||||
|
// - SubscribeTo: NATS subject/channel name (e.g., "metrics.compute.*")
|
||||||
|
// - ClusterTag: Default cluster name for metrics without cluster tag (optional)
|
||||||
|
type Subscriptions []struct {
|
||||||
|
// Channel name
|
||||||
|
SubscribeTo string `json:"subscribe-to"`
|
||||||
|
|
||||||
|
// Allow lines without a cluster tag, use this as default, optional
|
||||||
|
ClusterTag string `json:"cluster-tag"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// MetricStoreConfig defines the main configuration for the metricstore.
|
||||||
|
//
|
||||||
|
// Loaded from cc-backend's config.json "metricstore" section. Controls memory usage,
|
||||||
|
// persistence, archiving, and metric ingestion.
|
||||||
|
//
|
||||||
|
// Fields:
|
||||||
|
// - NumWorkers: Parallel workers for checkpoint/archive (0 = auto: min(NumCPU/2+1, 10))
|
||||||
|
// - RetentionInMemory: Duration string (e.g., "48h") for in-memory data retention
|
||||||
|
// - MemoryCap: Max bytes for buffer data (0 = unlimited); triggers forceFree when exceeded
|
||||||
|
// - Checkpoints: Periodic persistence configuration
|
||||||
|
// - Debug: Development/profiling options (nil = disabled)
|
||||||
|
// - Archive: Long-term storage configuration (nil = disabled)
|
||||||
|
// - Subscriptions: NATS topics for metric ingestion (nil = polling only)
|
||||||
|
type MetricStoreConfig struct {
|
||||||
|
// Number of concurrent workers for checkpoint and archive operations.
|
||||||
|
// If not set or 0, defaults to min(runtime.NumCPU()/2+1, 10)
|
||||||
|
NumWorkers int `json:"num-workers"`
|
||||||
|
RetentionInMemory string `json:"retention-in-memory"`
|
||||||
|
MemoryCap int `json:"memory-cap"`
|
||||||
|
Checkpoints Checkpoints `json:"checkpoints"`
|
||||||
|
Debug *Debug `json:"debug"`
|
||||||
|
Cleanup *Cleanup `json:"cleanup"`
|
||||||
|
Subscriptions *Subscriptions `json:"nats-subscriptions"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keys is the global metricstore configuration instance.
|
||||||
|
//
|
||||||
|
// Initialized with defaults, then overwritten by cc-backend's config.json.
|
||||||
|
// Accessed by Init(), Checkpointing(), and other lifecycle functions.
|
||||||
|
var Keys MetricStoreConfig = MetricStoreConfig{
|
||||||
|
Checkpoints: Checkpoints{
|
||||||
|
FileFormat: "avro",
|
||||||
|
RootDir: "./var/checkpoints",
|
||||||
|
},
|
||||||
|
Cleanup: &Cleanup{
|
||||||
|
Mode: "delete",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// AggregationStrategy defines how to combine metric values across hierarchy levels.
|
||||||
|
//
|
||||||
|
// Used when transforming data from finer-grained scopes (e.g., core) to coarser scopes
|
||||||
|
// (e.g., socket). This is SPATIAL aggregation, not TEMPORAL (time-based) aggregation.
|
||||||
|
//
|
||||||
|
// Values:
|
||||||
|
// - NoAggregation: Do not aggregate (incompatible scopes or non-aggregatable metrics)
|
||||||
|
// - SumAggregation: Add values (e.g., power: sum core power → socket power)
|
||||||
|
// - AvgAggregation: Average values (e.g., temperature: average core temps → socket temp)
|
||||||
|
type AggregationStrategy int
|
||||||
|
|
||||||
|
const (
|
||||||
|
NoAggregation AggregationStrategy = iota // Do not aggregate
|
||||||
|
SumAggregation // Sum values (e.g., power, energy)
|
||||||
|
AvgAggregation // Average values (e.g., temperature, utilization)
|
||||||
|
)
|
||||||
|
|
||||||
|
// AssignAggregationStrategy parses a string into an AggregationStrategy value.
|
||||||
|
//
|
||||||
|
// Used when loading metric configurations from JSON/YAML files.
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// - str: "sum", "avg", or "" (empty string for NoAggregation)
|
||||||
|
//
|
||||||
|
// Returns:
|
||||||
|
// - AggregationStrategy: Parsed value
|
||||||
|
// - error: Non-nil if str is unrecognized
|
||||||
|
func AssignAggregationStrategy(str string) (AggregationStrategy, error) {
|
||||||
|
switch str {
|
||||||
|
case "":
|
||||||
|
return NoAggregation, nil
|
||||||
|
case "sum":
|
||||||
|
return SumAggregation, nil
|
||||||
|
case "avg":
|
||||||
|
return AvgAggregation, nil
|
||||||
|
default:
|
||||||
|
return NoAggregation, fmt.Errorf("[METRICSTORE]> unknown aggregation strategy: %s", str)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MetricConfig defines configuration for a single metric type.
|
||||||
|
//
|
||||||
|
// Stored in the global Metrics map, keyed by metric name (e.g., "cpu_load").
|
||||||
|
//
|
||||||
|
// Fields:
|
||||||
|
// - Frequency: Measurement interval in seconds (e.g., 60 for 1-minute granularity)
|
||||||
|
// - Aggregation: How to combine values across hierarchy levels (sum/avg/none)
|
||||||
|
// - offset: Internal index into Level.metrics slice (assigned during Init)
|
||||||
|
type MetricConfig struct {
|
||||||
|
// Interval in seconds at which measurements are stored
|
||||||
|
Frequency int64
|
||||||
|
|
||||||
|
// Can be 'sum', 'avg' or null. Describes how to aggregate metrics from the same timestep over the hierarchy.
|
||||||
|
Aggregation AggregationStrategy
|
||||||
|
|
||||||
|
// Private, used internally...
|
||||||
|
offset int
|
||||||
|
}
|
||||||
|
|
||||||
|
// Metrics is the global map of metric configurations.
|
||||||
|
//
|
||||||
|
// Keyed by metric name (e.g., "cpu_load", "mem_used"). Populated during Init()
|
||||||
|
// from cluster configuration and checkpoint restoration. Each MetricConfig.offset
|
||||||
|
// corresponds to the buffer slice index in Level.metrics.
|
||||||
|
var Metrics map[string]MetricConfig
|
||||||
|
|
||||||
|
// GetMetricFrequency retrieves the measurement interval for a metric.
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// - metricName: Metric name (e.g., "cpu_load")
|
||||||
|
//
|
||||||
|
// Returns:
|
||||||
|
// - int64: Frequency in seconds
|
||||||
|
// - error: Non-nil if metric not found in Metrics map
|
||||||
|
func GetMetricFrequency(metricName string) (int64, error) {
|
||||||
|
if metric, ok := Metrics[metricName]; ok {
|
||||||
|
return metric.Frequency, nil
|
||||||
|
}
|
||||||
|
return 0, fmt.Errorf("[METRICSTORE]> metric %s not found", metricName)
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddMetric registers a new metric or updates an existing one.
|
||||||
|
//
|
||||||
|
// If the metric already exists with a different frequency, uses the higher frequency
|
||||||
|
// (finer granularity). This handles cases where different clusters report the same
|
||||||
|
// metric at different intervals.
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// - name: Metric name (e.g., "cpu_load")
|
||||||
|
// - metric: Configuration (frequency, aggregation strategy)
|
||||||
|
//
|
||||||
|
// Returns:
|
||||||
|
// - error: Always nil (signature for future error handling)
|
||||||
|
func AddMetric(name string, metric MetricConfig) error {
|
||||||
|
if Metrics == nil {
|
||||||
|
Metrics = make(map[string]MetricConfig, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
if existingMetric, ok := Metrics[name]; ok {
|
||||||
|
if existingMetric.Frequency != metric.Frequency {
|
||||||
|
if existingMetric.Frequency < metric.Frequency {
|
||||||
|
existingMetric.Frequency = metric.Frequency
|
||||||
|
Metrics[name] = existingMetric
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Metrics[name] = metric
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -14,11 +14,11 @@ const configSchema = `{
|
|||||||
"type": "integer"
|
"type": "integer"
|
||||||
},
|
},
|
||||||
"checkpoints": {
|
"checkpoints": {
|
||||||
"description": "Configuration for checkpointing the metrics within metric-store",
|
"description": "Configuration for checkpointing the metrics buffers",
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
"file-format": {
|
"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"
|
"type": "string"
|
||||||
},
|
},
|
||||||
"interval": {
|
"interval": {
|
||||||
@@ -26,26 +26,38 @@ const configSchema = `{
|
|||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
"directory": {
|
"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"
|
"type": "string"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": ["interval"]
|
"required": ["interval"]
|
||||||
},
|
},
|
||||||
"archive": {
|
"cleanup": {
|
||||||
"description": "Configuration for archiving the already checkpointed files.",
|
"description": "Configuration for the cleanup process.",
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
|
"mode": {
|
||||||
|
"description": "The operation mode (e.g., 'archive' or 'delete').",
|
||||||
|
"type": "string",
|
||||||
|
"enum": ["archive", "delete"]
|
||||||
|
},
|
||||||
"interval": {
|
"interval": {
|
||||||
"description": "Interval at which the checkpointed files should be archived.",
|
"description": "Interval at which the cleanup runs.",
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
"directory": {
|
"directory": {
|
||||||
"description": "Specify the directy in which the archived files should be placed.",
|
"description": "Target directory for operations.",
|
||||||
"type": "string"
|
"type": "string"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"if": {
|
||||||
|
"properties": {
|
||||||
|
"mode": { "const": "archive" }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"then": {
|
||||||
"required": ["interval", "directory"]
|
"required": ["interval", "directory"]
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"retention-in-memory": {
|
"retention-in-memory": {
|
||||||
"description": "Keep the metrics within memory for given time interval. Retention for X hours, then the metrics would be freed.",
|
"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"
|
"type": "integer"
|
||||||
},
|
},
|
||||||
"nats-subscriptions": {
|
"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",
|
"type": "array",
|
||||||
"items": {
|
"items": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
"subscribe-to": {
|
"subscribe-to": {
|
||||||
"description": "Channel name",
|
"description": "Subject name",
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
"cluster-tag": {
|
"cluster-tag": {
|
||||||
@@ -70,8 +82,9 @@ const configSchema = `{
|
|||||||
"type": "string"
|
"type": "string"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
"required": ["subscribe-to"]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": ["checkpoints", "retention-in-memory"]
|
"required": ["checkpoints", "retention-in-memory", "memory-cap"]
|
||||||
}`
|
}`
|
||||||
@@ -0,0 +1,388 @@
|
|||||||
|
// Copyright (C) NHR@FAU, University Erlangen-Nuremberg.
|
||||||
|
// All rights reserved. This file is part of cc-backend.
|
||||||
|
// Use of this source code is governed by a MIT-style
|
||||||
|
// license that can be found in the LICENSE file.
|
||||||
|
|
||||||
|
// Package metricstore provides level.go: Hierarchical tree structure for metric storage.
|
||||||
|
//
|
||||||
|
// # Level Architecture
|
||||||
|
//
|
||||||
|
// The Level type forms a tree structure where each node represents a level in the
|
||||||
|
// ClusterCockpit hierarchy: cluster → host → socket → core → hwthread, with special
|
||||||
|
// nodes for memory domains and accelerators.
|
||||||
|
//
|
||||||
|
// Structure:
|
||||||
|
//
|
||||||
|
// Root Level (cluster="emmy")
|
||||||
|
// ├─ Level (host="node001")
|
||||||
|
// │ ├─ Level (socket="0")
|
||||||
|
// │ │ ├─ Level (core="0") [stores cpu0 metrics]
|
||||||
|
// │ │ └─ Level (core="1") [stores cpu1 metrics]
|
||||||
|
// │ └─ Level (socket="1")
|
||||||
|
// │ └─ ...
|
||||||
|
// └─ Level (host="node002")
|
||||||
|
// └─ ...
|
||||||
|
//
|
||||||
|
// Each Level can:
|
||||||
|
// - Hold data (metrics slice of buffer pointers)
|
||||||
|
// - Have child nodes (children map[string]*Level)
|
||||||
|
// - Both simultaneously (inner nodes can store aggregated metrics)
|
||||||
|
//
|
||||||
|
// # Selector Paths
|
||||||
|
//
|
||||||
|
// Selectors are hierarchical paths: []string{"cluster", "host", "component"}.
|
||||||
|
// Example: []string{"emmy", "node001", "cpu0"} navigates to the cpu0 core level.
|
||||||
|
//
|
||||||
|
// # Concurrency
|
||||||
|
//
|
||||||
|
// RWMutex protects children map and metrics slice. Read-heavy workload (metric reads)
|
||||||
|
// uses RLock. Writes (new levels, buffer updates) use Lock. Double-checked locking
|
||||||
|
// prevents races during level creation.
|
||||||
|
package metricstore
|
||||||
|
|
||||||
|
import (
|
||||||
|
"sync"
|
||||||
|
"unsafe"
|
||||||
|
|
||||||
|
"github.com/ClusterCockpit/cc-lib/v2/util"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Level represents a node in the hierarchical metric storage tree.
|
||||||
|
//
|
||||||
|
// Can be both a leaf or inner node. Inner nodes hold data in 'metrics' for aggregated
|
||||||
|
// values (e.g., socket-level metrics derived from core-level data). Named "Level"
|
||||||
|
// instead of "node" to avoid confusion with cluster nodes (hosts).
|
||||||
|
//
|
||||||
|
// Fields:
|
||||||
|
// - children: Map of child level names to Level pointers (e.g., "cpu0" → Level)
|
||||||
|
// - metrics: Slice of buffer pointers (one per metric, indexed by MetricConfig.offset)
|
||||||
|
// - lock: RWMutex for concurrent access (read-heavy, write-rare)
|
||||||
|
type Level struct {
|
||||||
|
children map[string]*Level
|
||||||
|
metrics []*buffer
|
||||||
|
lock sync.RWMutex
|
||||||
|
}
|
||||||
|
|
||||||
|
// findLevelOrCreate navigates to or creates the level specified by selector.
|
||||||
|
//
|
||||||
|
// Recursively descends the tree, creating missing levels as needed. Uses double-checked
|
||||||
|
// locking: RLock first (fast path), then Lock if creation needed (slow path), then
|
||||||
|
// re-check after acquiring Lock to handle races.
|
||||||
|
//
|
||||||
|
// Example selector: []string{"emmy", "node001", "cpu0"}
|
||||||
|
// Navigates: root → emmy → node001 → cpu0, creating levels as needed.
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// - selector: Hierarchical path (consumed recursively, decreasing depth)
|
||||||
|
// - nMetrics: Number of metric slots to allocate in new levels
|
||||||
|
//
|
||||||
|
// Returns:
|
||||||
|
// - *Level: The target level (existing or newly created)
|
||||||
|
//
|
||||||
|
// Note: sync.Map may improve performance for high-concurrency writes, but current
|
||||||
|
// approach suffices for read-heavy workload.
|
||||||
|
func (l *Level) findLevelOrCreate(selector []string, nMetrics int) *Level {
|
||||||
|
if len(selector) == 0 {
|
||||||
|
return l
|
||||||
|
}
|
||||||
|
|
||||||
|
// Allow concurrent reads:
|
||||||
|
l.lock.RLock()
|
||||||
|
var child *Level
|
||||||
|
var ok bool
|
||||||
|
if l.children == nil {
|
||||||
|
// Children map needs to be created...
|
||||||
|
l.lock.RUnlock()
|
||||||
|
} else {
|
||||||
|
child, ok = l.children[selector[0]]
|
||||||
|
l.lock.RUnlock()
|
||||||
|
if ok {
|
||||||
|
return child.findLevelOrCreate(selector[1:], nMetrics)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The level does not exist, take write lock for unique access:
|
||||||
|
l.lock.Lock()
|
||||||
|
// While this thread waited for the write lock, another thread
|
||||||
|
// could have created the child node.
|
||||||
|
if l.children != nil {
|
||||||
|
child, ok = l.children[selector[0]]
|
||||||
|
if ok {
|
||||||
|
l.lock.Unlock()
|
||||||
|
return child.findLevelOrCreate(selector[1:], nMetrics)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
child = &Level{
|
||||||
|
metrics: make([]*buffer, nMetrics),
|
||||||
|
children: nil,
|
||||||
|
}
|
||||||
|
|
||||||
|
if l.children != nil {
|
||||||
|
l.children[selector[0]] = child
|
||||||
|
} else {
|
||||||
|
l.children = map[string]*Level{selector[0]: child}
|
||||||
|
}
|
||||||
|
l.lock.Unlock()
|
||||||
|
return child.findLevelOrCreate(selector[1:], nMetrics)
|
||||||
|
}
|
||||||
|
|
||||||
|
// collectPaths gathers all selector paths at the specified depth in the tree.
|
||||||
|
//
|
||||||
|
// Recursively traverses children, collecting paths when currentDepth+1 == targetDepth.
|
||||||
|
// Each path is a selector that can be used with findLevel() or findBuffers().
|
||||||
|
//
|
||||||
|
// Explicitly copies slices to avoid shared underlying arrays between siblings, preventing
|
||||||
|
// unintended mutations.
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// - currentDepth: Depth of current level (0 = root)
|
||||||
|
// - targetDepth: Depth to collect paths from
|
||||||
|
// - currentPath: Path accumulated so far
|
||||||
|
// - results: Output slice (appended to)
|
||||||
|
//
|
||||||
|
// Example: collectPaths(0, 2, []string{}, &results) collects all 2-level paths
|
||||||
|
// like []string{"emmy", "node001"}, []string{"emmy", "node002"}, etc.
|
||||||
|
func (l *Level) collectPaths(currentDepth, targetDepth int, currentPath []string, results *[][]string) {
|
||||||
|
l.lock.RLock()
|
||||||
|
defer l.lock.RUnlock()
|
||||||
|
|
||||||
|
for key, child := range l.children {
|
||||||
|
if child == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// We explicitly make a new slice and copy data to avoid sharing underlying arrays between siblings
|
||||||
|
newPath := make([]string, len(currentPath))
|
||||||
|
copy(newPath, currentPath)
|
||||||
|
newPath = append(newPath, key)
|
||||||
|
|
||||||
|
// Check depth, and just return if depth reached
|
||||||
|
if currentDepth+1 == targetDepth {
|
||||||
|
*results = append(*results, newPath)
|
||||||
|
} else {
|
||||||
|
child.collectPaths(currentDepth+1, targetDepth, newPath, results)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// free removes buffers older than the retention threshold from the entire subtree.
|
||||||
|
//
|
||||||
|
// Recursively frees buffers in this level's metrics and all child levels. Buffers
|
||||||
|
// with standard capacity (BufferCap) are returned to the pool. Called by the
|
||||||
|
// retention worker to enforce retention policies.
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// - t: Retention threshold timestamp (Unix seconds)
|
||||||
|
//
|
||||||
|
// Returns:
|
||||||
|
// - int: Total number of buffers freed in this subtree
|
||||||
|
// - error: Non-nil on failure (propagated from children)
|
||||||
|
func (l *Level) free(t int64) (int, error) {
|
||||||
|
l.lock.Lock()
|
||||||
|
defer l.lock.Unlock()
|
||||||
|
|
||||||
|
n := 0
|
||||||
|
for i, b := range l.metrics {
|
||||||
|
if b != nil {
|
||||||
|
delme, m := b.free(t)
|
||||||
|
n += m
|
||||||
|
if delme {
|
||||||
|
if cap(b.data) == BufferCap {
|
||||||
|
bufferPool.Put(b)
|
||||||
|
}
|
||||||
|
l.metrics[i] = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, l := range l.children {
|
||||||
|
m, err := l.free(t)
|
||||||
|
n += m
|
||||||
|
if err != nil {
|
||||||
|
return n, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return n, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// forceFree removes the oldest buffer from each metric chain in the subtree.
|
||||||
|
//
|
||||||
|
// Unlike free(), which removes based on time threshold, this unconditionally removes
|
||||||
|
// the oldest buffer in each chain. Used by MemoryUsageTracker when memory cap is
|
||||||
|
// exceeded and time-based retention is insufficient.
|
||||||
|
//
|
||||||
|
// Recursively processes current level's metrics and all child levels.
|
||||||
|
//
|
||||||
|
// Returns:
|
||||||
|
// - int: Total number of buffers freed in this subtree
|
||||||
|
// - error: Non-nil on failure (propagated from children)
|
||||||
|
func (l *Level) forceFree() (int, error) {
|
||||||
|
l.lock.Lock()
|
||||||
|
defer l.lock.Unlock()
|
||||||
|
|
||||||
|
n := 0
|
||||||
|
|
||||||
|
// Iterate over metrics in the current level
|
||||||
|
for i, b := range l.metrics {
|
||||||
|
if b != nil {
|
||||||
|
// Attempt to free the oldest buffer in this chain
|
||||||
|
delme, freedCount := b.forceFreeOldest()
|
||||||
|
n += freedCount
|
||||||
|
|
||||||
|
// If delme is true, it means 'b' itself (the head) was the oldest
|
||||||
|
// and needs to be removed from the slice.
|
||||||
|
if delme {
|
||||||
|
// Nil out fields to ensure no hanging references
|
||||||
|
|
||||||
|
b.next = nil
|
||||||
|
b.prev = nil
|
||||||
|
b.data = nil
|
||||||
|
|
||||||
|
l.metrics[i] = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recursively traverse children
|
||||||
|
for _, child := range l.children {
|
||||||
|
m, err := child.forceFree()
|
||||||
|
n += m
|
||||||
|
if err != nil {
|
||||||
|
return n, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return n, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// sizeInBytes calculates the total memory usage of all buffers in the subtree.
|
||||||
|
//
|
||||||
|
// Recursively sums buffer data sizes (count of Float values × sizeof(Float)) across
|
||||||
|
// this level's metrics and all child levels. Used by MemoryUsageTracker to enforce
|
||||||
|
// memory cap limits.
|
||||||
|
//
|
||||||
|
// Returns:
|
||||||
|
// - int64: Total bytes used by buffer data in this subtree
|
||||||
|
func (l *Level) sizeInBytes() int64 {
|
||||||
|
l.lock.RLock()
|
||||||
|
defer l.lock.RUnlock()
|
||||||
|
size := int64(0)
|
||||||
|
|
||||||
|
for _, b := range l.metrics {
|
||||||
|
if b != nil {
|
||||||
|
size += b.count() * int64(unsafe.Sizeof(util.Float(0)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, child := range l.children {
|
||||||
|
size += child.sizeInBytes()
|
||||||
|
}
|
||||||
|
|
||||||
|
return size
|
||||||
|
}
|
||||||
|
|
||||||
|
// findLevel navigates to the level specified by selector, returning nil if not found.
|
||||||
|
//
|
||||||
|
// Read-only variant of findLevelOrCreate. Does not create missing levels.
|
||||||
|
// Recursively descends the tree following the selector path.
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// - selector: Hierarchical path (e.g., []string{"emmy", "node001", "cpu0"})
|
||||||
|
//
|
||||||
|
// Returns:
|
||||||
|
// - *Level: The target level, or nil if any component in the path does not exist
|
||||||
|
func (l *Level) findLevel(selector []string) *Level {
|
||||||
|
if len(selector) == 0 {
|
||||||
|
return l
|
||||||
|
}
|
||||||
|
|
||||||
|
l.lock.RLock()
|
||||||
|
defer l.lock.RUnlock()
|
||||||
|
|
||||||
|
lvl := l.children[selector[0]]
|
||||||
|
if lvl == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return lvl.findLevel(selector[1:])
|
||||||
|
}
|
||||||
|
|
||||||
|
// findBuffers invokes callback on all buffers matching the selector pattern.
|
||||||
|
//
|
||||||
|
// Supports flexible selector patterns (from cc-lib/util.Selector):
|
||||||
|
// - Exact match: Selector element with String set (e.g., "node001")
|
||||||
|
// - Group match: Selector element with Group set (e.g., ["cpu0", "cpu2", "cpu4"])
|
||||||
|
// - Wildcard: Selector element with Any=true (matches all children)
|
||||||
|
//
|
||||||
|
// Empty selector (len==0) matches current level's buffer at 'offset' and recursively
|
||||||
|
// all descendant buffers at the same offset (used for aggregation queries).
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// - selector: Pattern to match (consumed recursively)
|
||||||
|
// - offset: Metric index in metrics slice (from MetricConfig.offset)
|
||||||
|
// - f: Callback invoked on each matching buffer
|
||||||
|
//
|
||||||
|
// Returns:
|
||||||
|
// - error: First error returned by callback, or nil if all succeeded
|
||||||
|
//
|
||||||
|
// Example:
|
||||||
|
//
|
||||||
|
// // Find all cpu0 buffers across all hosts:
|
||||||
|
// findBuffers([]Selector{{Any: true}, {String: "cpu0"}}, metricOffset, callback)
|
||||||
|
func (l *Level) findBuffers(selector util.Selector, offset int, f func(b *buffer) error) error {
|
||||||
|
l.lock.RLock()
|
||||||
|
defer l.lock.RUnlock()
|
||||||
|
|
||||||
|
if len(selector) == 0 {
|
||||||
|
b := l.metrics[offset]
|
||||||
|
if b != nil {
|
||||||
|
return f(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, lvl := range l.children {
|
||||||
|
err := lvl.findBuffers(nil, offset, f)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
sel := selector[0]
|
||||||
|
if len(sel.String) != 0 && l.children != nil {
|
||||||
|
lvl, ok := l.children[sel.String]
|
||||||
|
if ok {
|
||||||
|
err := lvl.findBuffers(selector[1:], offset, f)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if sel.Group != nil && l.children != nil {
|
||||||
|
for _, key := range sel.Group {
|
||||||
|
lvl, ok := l.children[key]
|
||||||
|
if ok {
|
||||||
|
err := lvl.findBuffers(selector[1:], offset, f)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if sel.Any && l.children != nil {
|
||||||
|
for _, lvl := range l.children {
|
||||||
|
if err := lvl.findBuffers(selector[1:], offset, f); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -37,35 +37,89 @@ import (
|
|||||||
"github.com/ClusterCockpit/cc-lib/v2/util"
|
"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 (
|
var (
|
||||||
singleton sync.Once
|
singleton sync.Once
|
||||||
msInstance *MemoryStore
|
msInstance *MemoryStore
|
||||||
// shutdownFunc stores the context cancellation function created in Init
|
// shutdownFunc stores the context cancellation function created in Init
|
||||||
// and is called during Shutdown to cancel all background goroutines
|
// 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.
|
// NodeProvider provides information about nodes currently in use by running jobs.
|
||||||
|
//
|
||||||
// This interface allows metricstore to query job information without directly
|
// This interface allows metricstore to query job information without directly
|
||||||
// depending on the repository package, breaking the import cycle.
|
// 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 {
|
type NodeProvider interface {
|
||||||
// GetUsedNodes returns a map of cluster names to sorted lists of unique hostnames
|
// 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.
|
// 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)
|
GetUsedNodes(ts int64) (map[string][]string, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Metric represents a single metric data point to be written to the store.
|
||||||
type Metric struct {
|
type Metric struct {
|
||||||
Name string
|
Name string
|
||||||
Value schema.Float
|
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
|
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 {
|
type MemoryStore struct {
|
||||||
Metrics map[string]MetricConfig
|
Metrics map[string]MetricConfig
|
||||||
root Level
|
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) {
|
func Init(rawConfig json.RawMessage, wg *sync.WaitGroup) {
|
||||||
startupTime := time.Now()
|
startupTime := time.Now()
|
||||||
|
|
||||||
@@ -142,25 +196,30 @@ func Init(rawConfig json.RawMessage, wg *sync.WaitGroup) {
|
|||||||
retentionGoroutines := 1
|
retentionGoroutines := 1
|
||||||
checkpointingGoroutines := 1
|
checkpointingGoroutines := 1
|
||||||
dataStagingGoroutines := 1
|
dataStagingGoroutines := 1
|
||||||
archivingGoroutines := 0
|
archivingGoroutines := 1
|
||||||
if Keys.Archive != nil {
|
memoryUsageTracker := 1
|
||||||
archivingGoroutines = 1
|
|
||||||
}
|
totalGoroutines := retentionGoroutines +
|
||||||
totalGoroutines := retentionGoroutines + checkpointingGoroutines + dataStagingGoroutines + archivingGoroutines
|
checkpointingGoroutines +
|
||||||
|
dataStagingGoroutines +
|
||||||
|
archivingGoroutines +
|
||||||
|
memoryUsageTracker
|
||||||
|
|
||||||
wg.Add(totalGoroutines)
|
wg.Add(totalGoroutines)
|
||||||
|
|
||||||
Retention(wg, ctx)
|
Retention(wg, ctx)
|
||||||
Checkpointing(wg, ctx)
|
Checkpointing(wg, ctx)
|
||||||
if Keys.Archive != nil {
|
CleanUp(wg, ctx)
|
||||||
Archiving(wg, ctx)
|
|
||||||
}
|
|
||||||
DataStaging(wg, ctx)
|
DataStaging(wg, ctx)
|
||||||
|
MemoryUsageTracker(wg, ctx)
|
||||||
|
|
||||||
// Note: Signal handling has been removed from this function.
|
// Note: Signal handling has been removed from this function.
|
||||||
// The caller is responsible for handling shutdown signals and calling
|
// The caller is responsible for handling shutdown signals and calling
|
||||||
// the shutdown() function when appropriate.
|
// the shutdown() function when appropriate.
|
||||||
// Store the shutdown function for later use by Shutdown()
|
// Store the shutdown function for later use by Shutdown()
|
||||||
|
shutdownFuncMu.Lock()
|
||||||
shutdownFunc = shutdown
|
shutdownFunc = shutdown
|
||||||
|
shutdownFuncMu.Unlock()
|
||||||
|
|
||||||
if Keys.Subscriptions != nil {
|
if Keys.Subscriptions != nil {
|
||||||
err = ReceiveNats(ms, 1, ctx)
|
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.
|
// InitMetrics initializes the singleton MemoryStore instance with the given metric configurations.
|
||||||
// Will panic if values in the metric configurations are invalid.
|
//
|
||||||
|
// 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) {
|
func InitMetrics(metrics map[string]MetricConfig) {
|
||||||
singleton.Do(func() {
|
singleton.Do(func() {
|
||||||
offset := 0
|
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 {
|
func GetMemoryStore() *MemoryStore {
|
||||||
if msInstance == nil {
|
if msInstance == nil {
|
||||||
cclog.Fatalf("[METRICSTORE]> MemoryStore not initialized!")
|
cclog.Fatalf("[METRICSTORE]> MemoryStore not initialized!")
|
||||||
@@ -214,7 +287,22 @@ func (ms *MemoryStore) SetNodeProvider(provider NodeProvider) {
|
|||||||
ms.nodeProvider = provider
|
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() {
|
func Shutdown() {
|
||||||
|
shutdownFuncMu.Lock()
|
||||||
|
defer shutdownFuncMu.Unlock()
|
||||||
if shutdownFunc != nil {
|
if shutdownFunc != nil {
|
||||||
shutdownFunc()
|
shutdownFunc()
|
||||||
}
|
}
|
||||||
@@ -241,6 +329,17 @@ func Shutdown() {
|
|||||||
cclog.Infof("[METRICSTORE]> Done! (%d files written)\n", files)
|
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) {
|
func Retention(wg *sync.WaitGroup, ctx context.Context) {
|
||||||
ms := GetMemoryStore()
|
ms := GetMemoryStore()
|
||||||
|
|
||||||
@@ -266,7 +365,12 @@ func Retention(wg *sync.WaitGroup, ctx context.Context) {
|
|||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
return
|
return
|
||||||
case <-ticker.C:
|
case <-ticker.C:
|
||||||
|
state.mu.Lock()
|
||||||
|
|
||||||
t := time.Now().Add(-d)
|
t := time.Now().Add(-d)
|
||||||
|
|
||||||
|
state.lastRetentionTime = t.Unix()
|
||||||
|
|
||||||
cclog.Infof("[METRICSTORE]> start freeing buffers (older than %s)...\n", t.Format(time.RFC3339))
|
cclog.Infof("[METRICSTORE]> start freeing buffers (older than %s)...\n", t.Format(time.RFC3339))
|
||||||
|
|
||||||
freed, err := Free(ms, t)
|
freed, err := Free(ms, t)
|
||||||
@@ -275,11 +379,125 @@ func Retention(wg *sync.WaitGroup, ctx context.Context) {
|
|||||||
} else {
|
} else {
|
||||||
cclog.Infof("[METRICSTORE]> done: %d buffers freed\n", freed)
|
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) {
|
func Free(ms *MemoryStore, t time.Time) (int, error) {
|
||||||
// If no NodeProvider is configured, free all buffers older than t
|
// If no NodeProvider is configured, free all buffers older than t
|
||||||
if ms.nodeProvider == nil {
|
if ms.nodeProvider == nil {
|
||||||
@@ -291,30 +509,34 @@ func Free(ms *MemoryStore, t time.Time) (int, error) {
|
|||||||
return 0, err
|
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 {
|
switch lenMap := len(excludeSelectors); lenMap {
|
||||||
|
|
||||||
// If the length of the map returned by GetUsedNodes() is 0,
|
// If the length of the map returned by GetUsedNodes() is 0,
|
||||||
// then use default Free method with nil selector
|
// then use default Free method with nil selector
|
||||||
case 0:
|
case 0:
|
||||||
|
state.selectorsExcluded = false
|
||||||
return ms.Free(nil, t.Unix())
|
return ms.Free(nil, t.Unix())
|
||||||
|
|
||||||
// Else formulate selectors, exclude those from the map
|
// Else formulate selectors, exclude those from the map
|
||||||
// and free the rest of the selectors
|
// and free the rest of the selectors
|
||||||
default:
|
default:
|
||||||
|
state.selectorsExcluded = true
|
||||||
selectors := GetSelectors(ms, excludeSelectors)
|
selectors := GetSelectors(ms, excludeSelectors)
|
||||||
return FreeSelected(ms, selectors, t)
|
return FreeSelected(ms, selectors, t)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// A function to free specific selectors. Used when we want to retain some specific nodes
|
// FreeSelected frees buffers for specific selectors while preserving others.
|
||||||
// beyond the retention time.
|
//
|
||||||
|
// 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) {
|
func FreeSelected(ms *MemoryStore, selectors [][]string, t time.Time) (int, error) {
|
||||||
freed := 0
|
freed := 0
|
||||||
|
|
||||||
@@ -331,8 +553,22 @@ func FreeSelected(ms *MemoryStore, selectors [][]string, t time.Time) (int, erro
|
|||||||
return freed, nil
|
return freed, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// This function will populate all the second last levels - meaning nodes
|
// GetSelectors returns all selectors at depth 2 (cluster/node level) that are NOT in the exclusion map.
|
||||||
// From that we can exclude the specific selectosr/node we want to retain.
|
//
|
||||||
|
// 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 {
|
func GetSelectors(ms *MemoryStore, excludeSelectors map[string][]string) [][]string {
|
||||||
allSelectors := ms.GetPaths(2)
|
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
|
return filteredSelectors
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -386,6 +619,7 @@ func (m *MemoryStore) Write(selector []string, ts int64, metrics []Metric) error
|
|||||||
if metric.MetricConfig.Frequency == 0 {
|
if metric.MetricConfig.Frequency == 0 {
|
||||||
metric.MetricConfig, ok = m.Metrics[metric.Name]
|
metric.MetricConfig, ok = m.Metrics[metric.Name]
|
||||||
if !ok {
|
if !ok {
|
||||||
|
cclog.Debugf("[METRICSTORE]> Unknown metric '%s' in Write() - skipping", metric.Name)
|
||||||
metric.MetricConfig.Frequency = 0
|
metric.MetricConfig.Frequency = 0
|
||||||
}
|
}
|
||||||
metrics[i] = metric
|
metrics[i] = metric
|
||||||
@@ -506,6 +740,12 @@ func (m *MemoryStore) Free(selector []string, t int64) (int, error) {
|
|||||||
return m.GetLevel(selector).free(t)
|
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 {
|
func (m *MemoryStore) FreeAll() error {
|
||||||
for k := range m.root.children {
|
for k := range m.root.children {
|
||||||
delete(m.root.children, k)
|
delete(m.root.children, k)
|
||||||
@@ -518,6 +758,10 @@ func (m *MemoryStore) SizeInBytes() int64 {
|
|||||||
return m.root.sizeInBytes()
|
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
|
// ListChildren , given a selector, returns a list of all children of the level
|
||||||
// selected.
|
// selected.
|
||||||
func (m *MemoryStore) ListChildren(selector []string) []string {
|
func (m *MemoryStore) ListChildren(selector []string) []string {
|
||||||
@@ -3,6 +3,27 @@
|
|||||||
// Use of this source code is governed by a MIT-style
|
// Use of this source code is governed by a MIT-style
|
||||||
// license that can be found in the LICENSE file.
|
// 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
|
package metricstore
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -17,9 +38,33 @@ import (
|
|||||||
"github.com/ClusterCockpit/cc-lib/v2/schema"
|
"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)
|
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(
|
func LoadData(
|
||||||
job *schema.Job,
|
job *schema.Job,
|
||||||
metrics []string,
|
metrics []string,
|
||||||
@@ -91,12 +136,7 @@ func LoadData(
|
|||||||
*id = query.TypeIds[ndx]
|
*id = query.TypeIds[ndx]
|
||||||
}
|
}
|
||||||
|
|
||||||
if res.Avg.IsNaN() || res.Min.IsNaN() || res.Max.IsNaN() {
|
sanitizeStats(&res)
|
||||||
// "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)
|
|
||||||
}
|
|
||||||
|
|
||||||
jobMetric.Series = append(jobMetric.Series, schema.Series{
|
jobMetric.Series = append(jobMetric.Series, schema.Series{
|
||||||
Hostname: query.Hostname,
|
Hostname: query.Hostname,
|
||||||
@@ -126,6 +166,10 @@ func LoadData(
|
|||||||
return jobData, nil
|
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 (
|
var (
|
||||||
hwthreadString = string(schema.MetricScopeHWThread)
|
hwthreadString = string(schema.MetricScopeHWThread)
|
||||||
coreString = string(schema.MetricScopeCore)
|
coreString = string(schema.MetricScopeCore)
|
||||||
@@ -134,12 +178,41 @@ var (
|
|||||||
acceleratorString = string(schema.MetricScopeAccelerator)
|
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(
|
func buildQueries(
|
||||||
job *schema.Job,
|
job *schema.Job,
|
||||||
metrics []string,
|
metrics []string,
|
||||||
scopes []schema.MetricScope,
|
scopes []schema.MetricScope,
|
||||||
resolution int64,
|
resolution int64,
|
||||||
) ([]APIQuery, []schema.MetricScope, error) {
|
) ([]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))
|
queries := make([]APIQuery, 0, len(metrics)*len(scopes)*len(job.Resources))
|
||||||
assignedScope := []schema.MetricScope{}
|
assignedScope := []schema.MetricScope{}
|
||||||
|
|
||||||
@@ -152,7 +225,6 @@ func buildQueries(
|
|||||||
for _, metric := range metrics {
|
for _, metric := range metrics {
|
||||||
mc := archive.GetMetricConfig(job.Cluster, metric)
|
mc := archive.GetMetricConfig(job.Cluster, metric)
|
||||||
if mc == nil {
|
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)
|
cclog.Infof("metric '%s' is not specified for cluster '%s'", metric, job.Cluster)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -171,10 +243,9 @@ func buildQueries(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Avoid duplicates...
|
// Avoid duplicates using map for O(1) lookup
|
||||||
handledScopes := make([]schema.MetricScope, 0, 3)
|
handledScopes := make(map[schema.MetricScope]bool, 3)
|
||||||
|
|
||||||
scopesLoop:
|
|
||||||
for _, requestedScope := range scopes {
|
for _, requestedScope := range scopes {
|
||||||
nativeScope := mc.Scope
|
nativeScope := mc.Scope
|
||||||
if nativeScope == schema.MetricScopeAccelerator && job.NumAcc == 0 {
|
if nativeScope == schema.MetricScopeAccelerator && job.NumAcc == 0 {
|
||||||
@@ -182,12 +253,10 @@ func buildQueries(
|
|||||||
}
|
}
|
||||||
|
|
||||||
scope := nativeScope.Max(requestedScope)
|
scope := nativeScope.Max(requestedScope)
|
||||||
for _, s := range handledScopes {
|
if handledScopes[scope] {
|
||||||
if scope == s {
|
continue
|
||||||
continue scopesLoop
|
|
||||||
}
|
}
|
||||||
}
|
handledScopes[scope] = true
|
||||||
handledScopes = append(handledScopes, scope)
|
|
||||||
|
|
||||||
for _, host := range job.Resources {
|
for _, host := range job.Resources {
|
||||||
hwthreads := host.HWThreads
|
hwthreads := host.HWThreads
|
||||||
@@ -232,7 +301,7 @@ func buildQueries(
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// HWThread -> HWThead
|
// HWThread -> HWThread
|
||||||
if nativeScope == schema.MetricScopeHWThread && scope == schema.MetricScopeHWThread {
|
if nativeScope == schema.MetricScopeHWThread && scope == schema.MetricScopeHWThread {
|
||||||
queries = append(queries, APIQuery{
|
queries = append(queries, APIQuery{
|
||||||
Metric: metric,
|
Metric: metric,
|
||||||
@@ -356,7 +425,7 @@ func buildQueries(
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// MemoryDoman -> Node
|
// MemoryDomain -> Node
|
||||||
if nativeScope == schema.MetricScopeMemoryDomain && scope == schema.MetricScopeNode {
|
if nativeScope == schema.MetricScopeMemoryDomain && scope == schema.MetricScopeNode {
|
||||||
sockets, _ := topology.GetMemoryDomainsFromHWThreads(hwthreads)
|
sockets, _ := topology.GetMemoryDomainsFromHWThreads(hwthreads)
|
||||||
queries = append(queries, APIQuery{
|
queries = append(queries, APIQuery{
|
||||||
@@ -420,12 +489,26 @@ func buildQueries(
|
|||||||
return queries, assignedScope, nil
|
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(
|
func LoadStats(
|
||||||
job *schema.Job,
|
job *schema.Job,
|
||||||
metrics []string,
|
metrics []string,
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
) (map[string]map[string]schema.MetricStatistics, error) {
|
) (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 {
|
if err != nil {
|
||||||
cclog.Errorf("Error while building queries for jobId %d, Metrics %v: %s", job.JobID, metrics, err.Error())
|
cclog.Errorf("Error while building queries for jobId %d, Metrics %v: %s", job.JobID, metrics, err.Error())
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -477,6 +560,20 @@ func LoadStats(
|
|||||||
return stats, nil
|
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(
|
func LoadScopedStats(
|
||||||
job *schema.Job,
|
job *schema.Job,
|
||||||
metrics []string,
|
metrics []string,
|
||||||
@@ -533,12 +630,7 @@ func LoadScopedStats(
|
|||||||
*id = query.TypeIds[ndx]
|
*id = query.TypeIds[ndx]
|
||||||
}
|
}
|
||||||
|
|
||||||
if res.Avg.IsNaN() || res.Min.IsNaN() || res.Max.IsNaN() {
|
sanitizeStats(&res)
|
||||||
// "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)
|
|
||||||
}
|
|
||||||
|
|
||||||
scopedJobStats[metric][scope] = append(scopedJobStats[metric][scope], &schema.ScopedStats{
|
scopedJobStats[metric][scope] = append(scopedJobStats[metric][scope], &schema.ScopedStats{
|
||||||
Hostname: query.Hostname,
|
Hostname: query.Hostname,
|
||||||
@@ -567,6 +659,22 @@ func LoadScopedStats(
|
|||||||
return scopedJobStats, nil
|
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(
|
func LoadNodeData(
|
||||||
cluster string,
|
cluster string,
|
||||||
metrics, nodes []string,
|
metrics, nodes []string,
|
||||||
@@ -615,14 +723,10 @@ func LoadNodeData(
|
|||||||
metric := query.Metric
|
metric := query.Metric
|
||||||
qdata := res[0]
|
qdata := res[0]
|
||||||
if qdata.Error != nil {
|
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))
|
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() {
|
sanitizeStats(&qdata)
|
||||||
// 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.
|
|
||||||
}
|
|
||||||
|
|
||||||
hostdata, ok := data[query.Hostname]
|
hostdata, ok := data[query.Hostname]
|
||||||
if !ok {
|
if !ok {
|
||||||
@@ -656,6 +760,24 @@ func LoadNodeData(
|
|||||||
return data, nil
|
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(
|
func LoadNodeListData(
|
||||||
cluster, subCluster string,
|
cluster, subCluster string,
|
||||||
nodes []string,
|
nodes []string,
|
||||||
@@ -696,7 +818,7 @@ func LoadNodeListData(
|
|||||||
} else {
|
} else {
|
||||||
query = req.Queries[i]
|
query = req.Queries[i]
|
||||||
}
|
}
|
||||||
// qdata := res[0]
|
|
||||||
metric := query.Metric
|
metric := query.Metric
|
||||||
scope := assignedScope[i]
|
scope := assignedScope[i]
|
||||||
mc := archive.GetMetricConfig(cluster, metric)
|
mc := archive.GetMetricConfig(cluster, metric)
|
||||||
@@ -742,12 +864,7 @@ func LoadNodeListData(
|
|||||||
*id = query.TypeIds[ndx]
|
*id = query.TypeIds[ndx]
|
||||||
}
|
}
|
||||||
|
|
||||||
if res.Avg.IsNaN() || res.Min.IsNaN() || res.Max.IsNaN() {
|
sanitizeStats(&res)
|
||||||
// "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)
|
|
||||||
}
|
|
||||||
|
|
||||||
scopeData.Series = append(scopeData.Series, schema.Series{
|
scopeData.Series = append(scopeData.Series, schema.Series{
|
||||||
Hostname: query.Hostname,
|
Hostname: query.Hostname,
|
||||||
@@ -770,6 +887,23 @@ func LoadNodeListData(
|
|||||||
return data, nil
|
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(
|
func buildNodeQueries(
|
||||||
cluster string,
|
cluster string,
|
||||||
subCluster string,
|
subCluster string,
|
||||||
@@ -778,6 +912,10 @@ func buildNodeQueries(
|
|||||||
scopes []schema.MetricScope,
|
scopes []schema.MetricScope,
|
||||||
resolution int64,
|
resolution int64,
|
||||||
) ([]APIQuery, []schema.MetricScope, error) {
|
) ([]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))
|
queries := make([]APIQuery, 0, len(metrics)*len(scopes)*len(nodes))
|
||||||
assignedScope := []schema.MetricScope{}
|
assignedScope := []schema.MetricScope{}
|
||||||
|
|
||||||
@@ -795,7 +933,6 @@ func buildNodeQueries(
|
|||||||
for _, metric := range metrics {
|
for _, metric := range metrics {
|
||||||
mc := archive.GetMetricConfig(cluster, metric)
|
mc := archive.GetMetricConfig(cluster, metric)
|
||||||
if mc == nil {
|
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)
|
cclog.Warnf("metric '%s' is not specified for cluster '%s'", metric, cluster)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -814,20 +951,17 @@ func buildNodeQueries(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Avoid duplicates...
|
// Avoid duplicates using map for O(1) lookup
|
||||||
handledScopes := make([]schema.MetricScope, 0, 3)
|
handledScopes := make(map[schema.MetricScope]bool, 3)
|
||||||
|
|
||||||
scopesLoop:
|
|
||||||
for _, requestedScope := range scopes {
|
for _, requestedScope := range scopes {
|
||||||
nativeScope := mc.Scope
|
nativeScope := mc.Scope
|
||||||
|
|
||||||
scope := nativeScope.Max(requestedScope)
|
scope := nativeScope.Max(requestedScope)
|
||||||
for _, s := range handledScopes {
|
if handledScopes[scope] {
|
||||||
if scope == s {
|
continue
|
||||||
continue scopesLoop
|
|
||||||
}
|
}
|
||||||
}
|
handledScopes[scope] = true
|
||||||
handledScopes = append(handledScopes, scope)
|
|
||||||
|
|
||||||
for _, hostname := range nodes {
|
for _, hostname := range nodes {
|
||||||
|
|
||||||
@@ -850,7 +984,7 @@ func buildNodeQueries(
|
|||||||
|
|
||||||
// Moved check here if metric matches hardware specs
|
// Moved check here if metric matches hardware specs
|
||||||
if nativeScope == schema.MetricScopeAccelerator && len(acceleratorIds) == 0 {
|
if nativeScope == schema.MetricScopeAccelerator && len(acceleratorIds) == 0 {
|
||||||
continue scopesLoop
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// Accelerator -> Accelerator (Use "accelerator" scope if requested scope is lower than node)
|
// Accelerator -> Accelerator (Use "accelerator" scope if requested scope is lower than node)
|
||||||
@@ -890,7 +1024,7 @@ func buildNodeQueries(
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// HWThread -> HWThead
|
// HWThread -> HWThread
|
||||||
if nativeScope == schema.MetricScopeHWThread && scope == schema.MetricScopeHWThread {
|
if nativeScope == schema.MetricScopeHWThread && scope == schema.MetricScopeHWThread {
|
||||||
queries = append(queries, APIQuery{
|
queries = append(queries, APIQuery{
|
||||||
Metric: metric,
|
Metric: metric,
|
||||||
@@ -1014,7 +1148,7 @@ func buildNodeQueries(
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// MemoryDoman -> Node
|
// MemoryDomain -> Node
|
||||||
if nativeScope == schema.MetricScopeMemoryDomain && scope == schema.MetricScopeNode {
|
if nativeScope == schema.MetricScopeMemoryDomain && scope == schema.MetricScopeNode {
|
||||||
sockets, _ := topology.GetMemoryDomainsFromHWThreads(topology.Node)
|
sockets, _ := topology.GetMemoryDomainsFromHWThreads(topology.Node)
|
||||||
queries = append(queries, APIQuery{
|
queries = append(queries, APIQuery{
|
||||||
@@ -1078,10 +1212,37 @@ func buildNodeQueries(
|
|||||||
return queries, assignedScope, nil
|
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 {
|
func intToStringSlice(is []int) []string {
|
||||||
|
if len(is) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
ss := make([]string, len(is))
|
ss := make([]string, len(is))
|
||||||
|
buf := make([]byte, 0, 16) // Reusable buffer for integer conversion
|
||||||
for i, x := range is {
|
for i, x := range is {
|
||||||
ss[i] = strconv.Itoa(x)
|
buf = strconv.AppendInt(buf[:0], int64(x), 10)
|
||||||
|
ss[i] = string(buf)
|
||||||
}
|
}
|
||||||
return ss
|
return ss
|
||||||
}
|
}
|
||||||
@@ -41,7 +41,7 @@ func TestImportFileToSqlite(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Initialize destination backend (sqlite)
|
// 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))
|
dstBackend, err := archive.InitBackend(json.RawMessage(dstConfig))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Failed to initialize destination backend: %s", err.Error())
|
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())
|
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))
|
dstBackend, err := archive.InitBackend(json.RawMessage(dstConfig))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Failed to initialize destination backend: %s", err.Error())
|
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())
|
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))
|
dstBackend, err := archive.InitBackend(json.RawMessage(dstConfig))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Failed to initialize destination backend: %s", err.Error())
|
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())
|
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))
|
dstBackend, err := archive.InitBackend(json.RawMessage(dstConfig))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Failed to initialize destination backend: %s", err.Error())
|
t.Fatalf("Failed to initialize destination backend: %s", err.Error())
|
||||||
|
|||||||
@@ -438,7 +438,7 @@ func main() {
|
|||||||
cclog.Abort("Main configuration must be present")
|
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)
|
cclog.Fatal(err)
|
||||||
}
|
}
|
||||||
ar := archive.GetHandle()
|
ar := archive.GetHandle()
|
||||||
|
|||||||
+27
-27
@@ -8,57 +8,57 @@ package web
|
|||||||
const configSchema = `{
|
const configSchema = `{
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
"jobList": {
|
"job-list": {
|
||||||
"description": "Job list defaults. Applies to user- and jobs views.",
|
"description": "Job list defaults. Applies to user- and jobs views.",
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
"usePaging": {
|
"use-paging": {
|
||||||
"description": "If classic paging is used instead of continuous scrolling by default.",
|
"description": "If classic paging is used instead of continuous scrolling by default.",
|
||||||
"type": "boolean"
|
"type": "boolean"
|
||||||
},
|
},
|
||||||
"showFootprint": {
|
"show-footprint": {
|
||||||
"description": "If footprint bars are shown as first column by default.",
|
"description": "If footprint bars are shown as first column by default.",
|
||||||
"type": "boolean"
|
"type": "boolean"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"nodeList": {
|
"node-list": {
|
||||||
"description": "Node list defaults. Applies to node list view.",
|
"description": "Node list defaults. Applies to node list view.",
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
"usePaging": {
|
"use-paging": {
|
||||||
"description": "If classic paging is used instead of continuous scrolling by default.",
|
"description": "If classic paging is used instead of continuous scrolling by default.",
|
||||||
"type": "boolean"
|
"type": "boolean"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"jobView": {
|
"job-view": {
|
||||||
"description": "Job view defaults.",
|
"description": "Job view defaults.",
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
"showPolarPlot": {
|
"show-polar-plot": {
|
||||||
"description": "If the job metric footprints polar plot is shown by default.",
|
"description": "If the job metric footprints polar plot is shown by default.",
|
||||||
"type": "boolean"
|
"type": "boolean"
|
||||||
},
|
},
|
||||||
"showFootprint": {
|
"show-footprint": {
|
||||||
"description": "If the annotated job metric footprint bars are shown by default.",
|
"description": "If the annotated job metric footprint bars are shown by default.",
|
||||||
"type": "boolean"
|
"type": "boolean"
|
||||||
},
|
},
|
||||||
"showRoofline": {
|
"show-roofline": {
|
||||||
"description": "If the job roofline plot is shown by default.",
|
"description": "If the job roofline plot is shown by default.",
|
||||||
"type": "boolean"
|
"type": "boolean"
|
||||||
},
|
},
|
||||||
"showStatTable": {
|
"show-stat-table": {
|
||||||
"description": "If the job metric statistics table is shown by default.",
|
"description": "If the job metric statistics table is shown by default.",
|
||||||
"type": "boolean"
|
"type": "boolean"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"metricConfig": {
|
"metric-config": {
|
||||||
"description": "Global initial metric selections for primary views of all clusters.",
|
"description": "Global initial metric selections for primary views of all clusters.",
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
"jobListMetrics": {
|
"job-list-metrics": {
|
||||||
"description": "Initial metrics shown for new users in job lists (User and jobs view).",
|
"description": "Initial metrics shown for new users in job lists (User and jobs view).",
|
||||||
"type": "array",
|
"type": "array",
|
||||||
"items": {
|
"items": {
|
||||||
@@ -66,7 +66,7 @@ const configSchema = `{
|
|||||||
"minItems": 1
|
"minItems": 1
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"jobViewPlotMetrics": {
|
"job-view-plot-metrics": {
|
||||||
"description": "Initial metrics shown for new users as job view metric plots.",
|
"description": "Initial metrics shown for new users as job view metric plots.",
|
||||||
"type": "array",
|
"type": "array",
|
||||||
"items": {
|
"items": {
|
||||||
@@ -74,7 +74,7 @@ const configSchema = `{
|
|||||||
"minItems": 1
|
"minItems": 1
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"jobViewTableMetrics": {
|
"job-view-table-metrics": {
|
||||||
"description": "Initial metrics shown for new users in job view statistics table.",
|
"description": "Initial metrics shown for new users in job view statistics table.",
|
||||||
"type": "array",
|
"type": "array",
|
||||||
"items": {
|
"items": {
|
||||||
@@ -91,7 +91,7 @@ const configSchema = `{
|
|||||||
"name": {
|
"name": {
|
||||||
"description": "The name of the cluster."
|
"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.",
|
"description": "Initial metrics shown for new users in job lists (User and jobs view) for subcluster.",
|
||||||
"type": "array",
|
"type": "array",
|
||||||
"items": {
|
"items": {
|
||||||
@@ -99,7 +99,7 @@ const configSchema = `{
|
|||||||
"minItems": 1
|
"minItems": 1
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"jobViewPlotMetrics": {
|
"job-view-plot-metrics": {
|
||||||
"description": "Initial metrics shown for new users as job view timeplots for subcluster.",
|
"description": "Initial metrics shown for new users as job view timeplots for subcluster.",
|
||||||
"type": "array",
|
"type": "array",
|
||||||
"items": {
|
"items": {
|
||||||
@@ -107,7 +107,7 @@ const configSchema = `{
|
|||||||
"minItems": 1
|
"minItems": 1
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"jobViewTableMetrics": {
|
"job-view-table-metrics": {
|
||||||
"description": "Initial metrics shown for new users in job view statistics table for subcluster.",
|
"description": "Initial metrics shown for new users in job view statistics table for subcluster.",
|
||||||
"type": "array",
|
"type": "array",
|
||||||
"items": {
|
"items": {
|
||||||
@@ -115,7 +115,7 @@ const configSchema = `{
|
|||||||
"minItems": 1
|
"minItems": 1
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"subClusters": {
|
"sub-clusters": {
|
||||||
"description": "The array of overrides per subcluster.",
|
"description": "The array of overrides per subcluster.",
|
||||||
"type": "array",
|
"type": "array",
|
||||||
"items": {
|
"items": {
|
||||||
@@ -125,7 +125,7 @@ const configSchema = `{
|
|||||||
"description": "The name of the subcluster.",
|
"description": "The name of the subcluster.",
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
"jobListMetrics": {
|
"job-list-metrics": {
|
||||||
"description": "Initial metrics shown for new users in job lists (User and jobs view) for subcluster.",
|
"description": "Initial metrics shown for new users in job lists (User and jobs view) for subcluster.",
|
||||||
"type": "array",
|
"type": "array",
|
||||||
"items": {
|
"items": {
|
||||||
@@ -133,7 +133,7 @@ const configSchema = `{
|
|||||||
"minItems": 1
|
"minItems": 1
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"jobViewPlotMetrics": {
|
"job-view-plot-metrics": {
|
||||||
"description": "Initial metrics shown for new users as job view timeplots for subcluster.",
|
"description": "Initial metrics shown for new users as job view timeplots for subcluster.",
|
||||||
"type": "array",
|
"type": "array",
|
||||||
"items": {
|
"items": {
|
||||||
@@ -141,7 +141,7 @@ const configSchema = `{
|
|||||||
"minItems": 1
|
"minItems": 1
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"jobViewTableMetrics": {
|
"job-view-table-metrics": {
|
||||||
"description": "Initial metrics shown for new users in job view statistics table for subcluster.",
|
"description": "Initial metrics shown for new users in job view statistics table for subcluster.",
|
||||||
"type": "array",
|
"type": "array",
|
||||||
"items": {
|
"items": {
|
||||||
@@ -155,29 +155,29 @@ const configSchema = `{
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": ["name", "subClusters"],
|
"required": ["name", "sub-clusters"],
|
||||||
"minItems": 1
|
"minItems": 1
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"plotConfiguration": {
|
"plot-configuration": {
|
||||||
"description": "Initial settings for plot render options.",
|
"description": "Initial settings for plot render options.",
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
"colorBackground": {
|
"color-background": {
|
||||||
"description": "If the metric plot backgrounds are initially colored by threshold limits.",
|
"description": "If the metric plot backgrounds are initially colored by threshold limits.",
|
||||||
"type": "boolean"
|
"type": "boolean"
|
||||||
},
|
},
|
||||||
"plotsPerRow": {
|
"plots-per-row": {
|
||||||
"description": "How many plots are initially rendered in per row. Applies to job, single node, and analysis views.",
|
"description": "How many plots are initially rendered in per row. Applies to job, single node, and analysis views.",
|
||||||
"type": "integer"
|
"type": "integer"
|
||||||
},
|
},
|
||||||
"lineWidth": {
|
"line-width": {
|
||||||
"description": "Initial thickness of rendered plotlines. Applies to metric plot, job compare plot and roofline.",
|
"description": "Initial thickness of rendered plotlines. Applies to metric plot, job compare plot and roofline.",
|
||||||
"type": "integer"
|
"type": "integer"
|
||||||
},
|
},
|
||||||
"colorScheme": {
|
"color-scheme": {
|
||||||
"description": "Initial colorScheme to be used for metric plots.",
|
"description": "Initial colorScheme to be used for metric plots.",
|
||||||
"type": "array",
|
"type": "array",
|
||||||
"items": {
|
"items": {
|
||||||
|
|||||||
Generated
+158
-139
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "cc-frontend",
|
"name": "cc-frontend",
|
||||||
"version": "1.0.0",
|
"version": "1.5.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "cc-frontend",
|
"name": "cc-frontend",
|
||||||
"version": "1.0.0",
|
"version": "1.5.0",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@rollup/plugin-replace": "^6.0.3",
|
"@rollup/plugin-replace": "^6.0.3",
|
||||||
@@ -15,19 +15,19 @@
|
|||||||
"chart.js": "^4.5.1",
|
"chart.js": "^4.5.1",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
"graphql": "^16.12.0",
|
"graphql": "^16.12.0",
|
||||||
"mathjs": "^15.0.0",
|
"mathjs": "^15.1.0",
|
||||||
"uplot": "^1.6.32",
|
"uplot": "^1.6.32",
|
||||||
"wonka": "^6.3.5"
|
"wonka": "^6.3.5"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@rollup/plugin-commonjs": "^29.0.0",
|
"@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",
|
"@rollup/plugin-terser": "^0.4.4",
|
||||||
"@timohausmann/quadtree-js": "^1.2.6",
|
"@timohausmann/quadtree-js": "^1.2.6",
|
||||||
"rollup": "^4.54.0",
|
"rollup": "^4.55.1",
|
||||||
"rollup-plugin-css-only": "^4.5.5",
|
"rollup-plugin-css-only": "^4.5.5",
|
||||||
"rollup-plugin-svelte": "^7.2.3",
|
"rollup-plugin-svelte": "^7.2.3",
|
||||||
"svelte": "^5.46.1"
|
"svelte": "^5.46.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@0no-co/graphql.web": {
|
"node_modules/@0no-co/graphql.web": {
|
||||||
@@ -45,9 +45,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@babel/runtime": {
|
"node_modules/@babel/runtime": {
|
||||||
"version": "7.28.4",
|
"version": "7.28.6",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.4.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.6.tgz",
|
||||||
"integrity": "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==",
|
"integrity": "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=6.9.0"
|
"node": ">=6.9.0"
|
||||||
@@ -244,13 +244,12 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-android-arm-eabi": {
|
"node_modules/@rollup/rollup-android-arm-eabi": {
|
||||||
"version": "4.54.0",
|
"version": "4.55.1",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.54.0.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.55.1.tgz",
|
||||||
"integrity": "sha512-OywsdRHrFvCdvsewAInDKCNyR3laPA2mc9bRYJ6LBp5IyvF3fvXbbNR0bSzHlZVFtn6E0xw2oZlyjg4rKCVcng==",
|
"integrity": "sha512-9R0DM/ykwfGIlNu6+2U09ga0WXeZ9MRC2Ter8jnz8415VbuIykVuc6bhdrbORFZANDmTDvq26mJrEVTl8TdnDg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -258,13 +257,12 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-android-arm64": {
|
"node_modules/@rollup/rollup-android-arm64": {
|
||||||
"version": "4.54.0",
|
"version": "4.55.1",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.54.0.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.55.1.tgz",
|
||||||
"integrity": "sha512-Skx39Uv+u7H224Af+bDgNinitlmHyQX1K/atIA32JP3JQw6hVODX5tkbi2zof/E69M1qH2UoN3Xdxgs90mmNYw==",
|
"integrity": "sha512-eFZCb1YUqhTysgW3sj/55du5cG57S7UTNtdMjCW7LwVcj3dTTcowCsC8p7uBdzKsZYa8J7IDE8lhMI+HX1vQvg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -272,13 +270,12 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-darwin-arm64": {
|
"node_modules/@rollup/rollup-darwin-arm64": {
|
||||||
"version": "4.54.0",
|
"version": "4.55.1",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.54.0.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.55.1.tgz",
|
||||||
"integrity": "sha512-k43D4qta/+6Fq+nCDhhv9yP2HdeKeP56QrUUTW7E6PhZP1US6NDqpJj4MY0jBHlJivVJD5P8NxrjuobZBJTCRw==",
|
"integrity": "sha512-p3grE2PHcQm2e8PSGZdzIhCKbMCw/xi9XvMPErPhwO17vxtvCN5FEA2mSLgmKlCjHGMQTP6phuQTYWUnKewwGg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -286,13 +283,12 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-darwin-x64": {
|
"node_modules/@rollup/rollup-darwin-x64": {
|
||||||
"version": "4.54.0",
|
"version": "4.55.1",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.54.0.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.55.1.tgz",
|
||||||
"integrity": "sha512-cOo7biqwkpawslEfox5Vs8/qj83M/aZCSSNIWpVzfU2CYHa2G3P1UN5WF01RdTHSgCkri7XOlTdtk17BezlV3A==",
|
"integrity": "sha512-rDUjG25C9qoTm+e02Esi+aqTKSBYwVTaoS1wxcN47/Luqef57Vgp96xNANwt5npq9GDxsH7kXxNkJVEsWEOEaQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -300,13 +296,12 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-freebsd-arm64": {
|
"node_modules/@rollup/rollup-freebsd-arm64": {
|
||||||
"version": "4.54.0",
|
"version": "4.55.1",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.54.0.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.55.1.tgz",
|
||||||
"integrity": "sha512-miSvuFkmvFbgJ1BevMa4CPCFt5MPGw094knM64W9I0giUIMMmRYcGW/JWZDriaw/k1kOBtsWh1z6nIFV1vPNtA==",
|
"integrity": "sha512-+JiU7Jbp5cdxekIgdte0jfcu5oqw4GCKr6i3PJTlXTCU5H5Fvtkpbs4XJHRmWNXF+hKmn4v7ogI5OQPaupJgOg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -314,13 +309,12 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-freebsd-x64": {
|
"node_modules/@rollup/rollup-freebsd-x64": {
|
||||||
"version": "4.54.0",
|
"version": "4.55.1",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.54.0.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.55.1.tgz",
|
||||||
"integrity": "sha512-KGXIs55+b/ZfZsq9aR026tmr/+7tq6VG6MsnrvF4H8VhwflTIuYh+LFUlIsRdQSgrgmtM3fVATzEAj4hBQlaqQ==",
|
"integrity": "sha512-V5xC1tOVWtLLmr3YUk2f6EJK4qksksOYiz/TCsFHu/R+woubcLWdC9nZQmwjOAbmExBIVKsm1/wKmEy4z4u4Bw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -328,13 +322,12 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-linux-arm-gnueabihf": {
|
"node_modules/@rollup/rollup-linux-arm-gnueabihf": {
|
||||||
"version": "4.54.0",
|
"version": "4.55.1",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.54.0.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.55.1.tgz",
|
||||||
"integrity": "sha512-EHMUcDwhtdRGlXZsGSIuXSYwD5kOT9NVnx9sqzYiwAc91wfYOE1g1djOEDseZJKKqtHAHGwnGPQu3kytmfaXLQ==",
|
"integrity": "sha512-Rn3n+FUk2J5VWx+ywrG/HGPTD9jXNbicRtTM11e/uorplArnXZYsVifnPPqNNP5BsO3roI4n8332ukpY/zN7rQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -342,13 +335,12 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-linux-arm-musleabihf": {
|
"node_modules/@rollup/rollup-linux-arm-musleabihf": {
|
||||||
"version": "4.54.0",
|
"version": "4.55.1",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.54.0.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.55.1.tgz",
|
||||||
"integrity": "sha512-+pBrqEjaakN2ySv5RVrj/qLytYhPKEUwk+e3SFU5jTLHIcAtqh2rLrd/OkbNuHJpsBgxsD8ccJt5ga/SeG0JmA==",
|
"integrity": "sha512-grPNWydeKtc1aEdrJDWk4opD7nFtQbMmV7769hiAaYyUKCT1faPRm2av8CX1YJsZ4TLAZcg9gTR1KvEzoLjXkg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -356,13 +348,12 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-linux-arm64-gnu": {
|
"node_modules/@rollup/rollup-linux-arm64-gnu": {
|
||||||
"version": "4.54.0",
|
"version": "4.55.1",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.54.0.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.55.1.tgz",
|
||||||
"integrity": "sha512-NSqc7rE9wuUaRBsBp5ckQ5CVz5aIRKCwsoa6WMF7G01sX3/qHUw/z4pv+D+ahL1EIKy6Enpcnz1RY8pf7bjwng==",
|
"integrity": "sha512-a59mwd1k6x8tXKcUxSyISiquLwB5pX+fJW9TkWU46lCqD/GRDe9uDN31jrMmVP3feI3mhAdvcCClhV8V5MhJFQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -370,13 +361,12 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-linux-arm64-musl": {
|
"node_modules/@rollup/rollup-linux-arm64-musl": {
|
||||||
"version": "4.54.0",
|
"version": "4.55.1",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.54.0.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.55.1.tgz",
|
||||||
"integrity": "sha512-gr5vDbg3Bakga5kbdpqx81m2n9IX8M6gIMlQQIXiLTNeQW6CucvuInJ91EuCJ/JYvc+rcLLsDFcfAD1K7fMofg==",
|
"integrity": "sha512-puS1MEgWX5GsHSoiAsF0TYrpomdvkaXm0CofIMG5uVkP6IBV+ZO9xhC5YEN49nsgYo1DuuMquF9+7EDBVYu4uA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -384,13 +374,25 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-linux-loong64-gnu": {
|
"node_modules/@rollup/rollup-linux-loong64-gnu": {
|
||||||
"version": "4.54.0",
|
"version": "4.55.1",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.54.0.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.55.1.tgz",
|
||||||
"integrity": "sha512-gsrtB1NA3ZYj2vq0Rzkylo9ylCtW/PhpLEivlgWe0bpgtX5+9j9EZa0wtZiCjgu6zmSeZWyI/e2YRX1URozpIw==",
|
"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": [
|
"cpu": [
|
||||||
"loong64"
|
"loong64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -398,13 +400,25 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-linux-ppc64-gnu": {
|
"node_modules/@rollup/rollup-linux-ppc64-gnu": {
|
||||||
"version": "4.54.0",
|
"version": "4.55.1",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.54.0.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.55.1.tgz",
|
||||||
"integrity": "sha512-y3qNOfTBStmFNq+t4s7Tmc9hW2ENtPg8FeUD/VShI7rKxNW7O4fFeaYbMsd3tpFlIg1Q8IapFgy7Q9i2BqeBvA==",
|
"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": [
|
"cpu": [
|
||||||
"ppc64"
|
"ppc64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -412,13 +426,12 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-linux-riscv64-gnu": {
|
"node_modules/@rollup/rollup-linux-riscv64-gnu": {
|
||||||
"version": "4.54.0",
|
"version": "4.55.1",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.54.0.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.55.1.tgz",
|
||||||
"integrity": "sha512-89sepv7h2lIVPsFma8iwmccN7Yjjtgz0Rj/Ou6fEqg3HDhpCa+Et+YSufy27i6b0Wav69Qv4WBNl3Rs6pwhebQ==",
|
"integrity": "sha512-uW0Y12ih2XJRERZ4jAfKamTyIHVMPQnTZcQjme2HMVDAHY4amf5u414OqNYC+x+LzRdRcnIG1YodLrrtA8xsxw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"riscv64"
|
"riscv64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -426,13 +439,12 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-linux-riscv64-musl": {
|
"node_modules/@rollup/rollup-linux-riscv64-musl": {
|
||||||
"version": "4.54.0",
|
"version": "4.55.1",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.54.0.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.55.1.tgz",
|
||||||
"integrity": "sha512-ZcU77ieh0M2Q8Ur7D5X7KvK+UxbXeDHwiOt/CPSBTI1fBmeDMivW0dPkdqkT4rOgDjrDDBUed9x4EgraIKoR2A==",
|
"integrity": "sha512-u9yZ0jUkOED1BFrqu3BwMQoixvGHGZ+JhJNkNKY/hyoEgOwlqKb62qu+7UjbPSHYjiVy8kKJHvXKv5coH4wDeg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"riscv64"
|
"riscv64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -440,13 +452,12 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-linux-s390x-gnu": {
|
"node_modules/@rollup/rollup-linux-s390x-gnu": {
|
||||||
"version": "4.54.0",
|
"version": "4.55.1",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.54.0.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.55.1.tgz",
|
||||||
"integrity": "sha512-2AdWy5RdDF5+4YfG/YesGDDtbyJlC9LHmL6rZw6FurBJ5n4vFGupsOBGfwMRjBYH7qRQowT8D/U4LoSvVwOhSQ==",
|
"integrity": "sha512-/0PenBCmqM4ZUd0190j7J0UsQ/1nsi735iPRakO8iPciE7BQ495Y6msPzaOmvx0/pn+eJVVlZrNrSh4WSYLxNg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"s390x"
|
"s390x"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -454,13 +465,12 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-linux-x64-gnu": {
|
"node_modules/@rollup/rollup-linux-x64-gnu": {
|
||||||
"version": "4.54.0",
|
"version": "4.55.1",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.54.0.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.55.1.tgz",
|
||||||
"integrity": "sha512-WGt5J8Ij/rvyqpFexxk3ffKqqbLf9AqrTBbWDk7ApGUzaIs6V+s2s84kAxklFwmMF/vBNGrVdYgbblCOFFezMQ==",
|
"integrity": "sha512-a8G4wiQxQG2BAvo+gU6XrReRRqj+pLS2NGXKm8io19goR+K8lw269eTrPkSdDTALwMmJp4th2Uh0D8J9bEV1vg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -468,27 +478,38 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-linux-x64-musl": {
|
"node_modules/@rollup/rollup-linux-x64-musl": {
|
||||||
"version": "4.54.0",
|
"version": "4.55.1",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.54.0.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.55.1.tgz",
|
||||||
"integrity": "sha512-JzQmb38ATzHjxlPHuTH6tE7ojnMKM2kYNzt44LO/jJi8BpceEC8QuXYA908n8r3CNuG/B3BV8VR3Hi1rYtmPiw==",
|
"integrity": "sha512-bD+zjpFrMpP/hqkfEcnjXWHMw5BIghGisOKPj+2NaNDuVT+8Ds4mPf3XcPHuat1tz89WRL+1wbcxKY3WSbiT7w==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"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": {
|
"node_modules/@rollup/rollup-openharmony-arm64": {
|
||||||
"version": "4.54.0",
|
"version": "4.55.1",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.54.0.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.55.1.tgz",
|
||||||
"integrity": "sha512-huT3fd0iC7jigGh7n3q/+lfPcXxBi+om/Rs3yiFxjvSxbSB6aohDFXbWvlspaqjeOh+hx7DDHS+5Es5qRkWkZg==",
|
"integrity": "sha512-xzm44KgEP11te3S2HCSyYf5zIzWmx3n8HDCc7EE59+lTcswEWNpvMLfd9uJvVX8LCg9QWG67Xt75AuHn4vgsXw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -496,13 +517,12 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-win32-arm64-msvc": {
|
"node_modules/@rollup/rollup-win32-arm64-msvc": {
|
||||||
"version": "4.54.0",
|
"version": "4.55.1",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.54.0.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.55.1.tgz",
|
||||||
"integrity": "sha512-c2V0W1bsKIKfbLMBu/WGBz6Yci8nJ/ZJdheE0EwB73N3MvHYKiKGs3mVilX4Gs70eGeDaMqEob25Tw2Gb9Nqyw==",
|
"integrity": "sha512-yR6Bl3tMC/gBok5cz/Qi0xYnVbIxGx5Fcf/ca0eB6/6JwOY+SRUcJfI0OpeTpPls7f194as62thCt/2BjxYN8g==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -510,13 +530,12 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-win32-ia32-msvc": {
|
"node_modules/@rollup/rollup-win32-ia32-msvc": {
|
||||||
"version": "4.54.0",
|
"version": "4.55.1",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.54.0.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.55.1.tgz",
|
||||||
"integrity": "sha512-woEHgqQqDCkAzrDhvDipnSirm5vxUXtSKDYTVpZG3nUdW/VVB5VdCYA2iReSj/u3yCZzXID4kuKG7OynPnB3WQ==",
|
"integrity": "sha512-3fZBidchE0eY0oFZBnekYCfg+5wAB0mbpCBuofh5mZuzIU/4jIVkbESmd2dOsFNS78b53CYv3OAtwqkZZmU5nA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"ia32"
|
"ia32"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -524,13 +543,12 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-win32-x64-gnu": {
|
"node_modules/@rollup/rollup-win32-x64-gnu": {
|
||||||
"version": "4.54.0",
|
"version": "4.55.1",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.54.0.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.55.1.tgz",
|
||||||
"integrity": "sha512-dzAc53LOuFvHwbCEOS0rPbXp6SIhAf2txMP5p6mGyOXXw5mWY8NGGbPMPrs4P1WItkfApDathBj/NzMLUZ9rtQ==",
|
"integrity": "sha512-xGGY5pXj69IxKb4yv/POoocPy/qmEGhimy/FoTpTSVju3FYXUQQMFCaZZXJVidsmGxRioZAwpThl/4zX41gRKg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -538,13 +556,12 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-win32-x64-msvc": {
|
"node_modules/@rollup/rollup-win32-x64-msvc": {
|
||||||
"version": "4.54.0",
|
"version": "4.55.1",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.54.0.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.55.1.tgz",
|
||||||
"integrity": "sha512-hYT5d3YNdSh3mbCU1gwQyPgQd3T2ne0A3KG8KSBdav5TiBg6eInVmV+TeR5uHufiIgSFg0XsOWGW5/RhNcSvPg==",
|
"integrity": "sha512-SPEpaL6DX4rmcXtnhdrQYgzQ5W2uW3SCJch88lB2zImhJRhIIK44fkUrgIV/Q8yUNfw5oyZ5vkeQsZLhCb06lw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -552,9 +569,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@sveltejs/acorn-typescript": {
|
"node_modules/@sveltejs/acorn-typescript": {
|
||||||
"version": "1.0.7",
|
"version": "1.0.8",
|
||||||
"resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.7.tgz",
|
"resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.8.tgz",
|
||||||
"integrity": "sha512-znp1A/Y1Jj4l/Zy7PX5DZKBE0ZNY+5QBngiE21NJkfSTyzzC5iKNWOtwFXKtIrn7MXEFBck4jD95iBNkGjK92Q==",
|
"integrity": "sha512-esgN+54+q0NjB0Y/4BomT9samII7jGwNy/2a3wNZbT2A2RpmXsXwUt24LvLhx6jUq2gVk4cWEvcRO6MFQbOfNA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"acorn": "^8.9.0"
|
"acorn": "^8.9.0"
|
||||||
@@ -728,9 +745,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/devalue": {
|
"node_modules/devalue": {
|
||||||
"version": "5.5.0",
|
"version": "5.6.2",
|
||||||
"resolved": "https://registry.npmjs.org/devalue/-/devalue-5.5.0.tgz",
|
"resolved": "https://registry.npmjs.org/devalue/-/devalue-5.6.2.tgz",
|
||||||
"integrity": "sha512-69sM5yrHfFLJt0AZ9QqZXGCPfJ7fQjvpln3Rq5+PS03LD32Ost1Q9N+eEnaQwGRIriKkMImXD56ocjQmfjbV3w==",
|
"integrity": "sha512-nPRkjWzzDQlsejL1WVifk5rvcFi/y1onBRxjaFMjZeR9mFpqu2gmAZ9xUB9/IEanEP/vBtGeGganC/GO1fmufg==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/escape-latex": {
|
"node_modules/escape-latex": {
|
||||||
@@ -795,7 +812,6 @@
|
|||||||
"version": "2.3.3",
|
"version": "2.3.3",
|
||||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||||
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
|
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
|
||||||
"dev": true,
|
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
@@ -976,9 +992,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/rollup": {
|
"node_modules/rollup": {
|
||||||
"version": "4.54.0",
|
"version": "4.55.1",
|
||||||
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.54.0.tgz",
|
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.55.1.tgz",
|
||||||
"integrity": "sha512-3nk8Y3a9Ea8szgKhinMlGMhGMw89mqule3KWczxhIzqudyHdCIOHw8WJlj/r329fACjKLEh13ZSk7oE22kyeIw==",
|
"integrity": "sha512-wDv/Ht1BNHB4upNbK74s9usvl7hObDnvVzknxqY/E/O3X6rW1U1rV1aENEfJ54eFZDTNo7zv1f5N4edCluH7+A==",
|
||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -992,28 +1008,31 @@
|
|||||||
"npm": ">=8.0.0"
|
"npm": ">=8.0.0"
|
||||||
},
|
},
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
"@rollup/rollup-android-arm-eabi": "4.54.0",
|
"@rollup/rollup-android-arm-eabi": "4.55.1",
|
||||||
"@rollup/rollup-android-arm64": "4.54.0",
|
"@rollup/rollup-android-arm64": "4.55.1",
|
||||||
"@rollup/rollup-darwin-arm64": "4.54.0",
|
"@rollup/rollup-darwin-arm64": "4.55.1",
|
||||||
"@rollup/rollup-darwin-x64": "4.54.0",
|
"@rollup/rollup-darwin-x64": "4.55.1",
|
||||||
"@rollup/rollup-freebsd-arm64": "4.54.0",
|
"@rollup/rollup-freebsd-arm64": "4.55.1",
|
||||||
"@rollup/rollup-freebsd-x64": "4.54.0",
|
"@rollup/rollup-freebsd-x64": "4.55.1",
|
||||||
"@rollup/rollup-linux-arm-gnueabihf": "4.54.0",
|
"@rollup/rollup-linux-arm-gnueabihf": "4.55.1",
|
||||||
"@rollup/rollup-linux-arm-musleabihf": "4.54.0",
|
"@rollup/rollup-linux-arm-musleabihf": "4.55.1",
|
||||||
"@rollup/rollup-linux-arm64-gnu": "4.54.0",
|
"@rollup/rollup-linux-arm64-gnu": "4.55.1",
|
||||||
"@rollup/rollup-linux-arm64-musl": "4.54.0",
|
"@rollup/rollup-linux-arm64-musl": "4.55.1",
|
||||||
"@rollup/rollup-linux-loong64-gnu": "4.54.0",
|
"@rollup/rollup-linux-loong64-gnu": "4.55.1",
|
||||||
"@rollup/rollup-linux-ppc64-gnu": "4.54.0",
|
"@rollup/rollup-linux-loong64-musl": "4.55.1",
|
||||||
"@rollup/rollup-linux-riscv64-gnu": "4.54.0",
|
"@rollup/rollup-linux-ppc64-gnu": "4.55.1",
|
||||||
"@rollup/rollup-linux-riscv64-musl": "4.54.0",
|
"@rollup/rollup-linux-ppc64-musl": "4.55.1",
|
||||||
"@rollup/rollup-linux-s390x-gnu": "4.54.0",
|
"@rollup/rollup-linux-riscv64-gnu": "4.55.1",
|
||||||
"@rollup/rollup-linux-x64-gnu": "4.54.0",
|
"@rollup/rollup-linux-riscv64-musl": "4.55.1",
|
||||||
"@rollup/rollup-linux-x64-musl": "4.54.0",
|
"@rollup/rollup-linux-s390x-gnu": "4.55.1",
|
||||||
"@rollup/rollup-openharmony-arm64": "4.54.0",
|
"@rollup/rollup-linux-x64-gnu": "4.55.1",
|
||||||
"@rollup/rollup-win32-arm64-msvc": "4.54.0",
|
"@rollup/rollup-linux-x64-musl": "4.55.1",
|
||||||
"@rollup/rollup-win32-ia32-msvc": "4.54.0",
|
"@rollup/rollup-openbsd-x64": "4.55.1",
|
||||||
"@rollup/rollup-win32-x64-gnu": "4.54.0",
|
"@rollup/rollup-openharmony-arm64": "4.55.1",
|
||||||
"@rollup/rollup-win32-x64-msvc": "4.54.0",
|
"@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"
|
"fsevents": "~2.3.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -1157,9 +1176,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/svelte": {
|
"node_modules/svelte": {
|
||||||
"version": "5.46.1",
|
"version": "5.46.4",
|
||||||
"resolved": "https://registry.npmjs.org/svelte/-/svelte-5.46.1.tgz",
|
"resolved": "https://registry.npmjs.org/svelte/-/svelte-5.46.4.tgz",
|
||||||
"integrity": "sha512-ynjfCHD3nP2el70kN5Pmg37sSi0EjOm9FgHYQdC4giWG/hzO3AatzXXJJgP305uIhGQxSufJLuYWtkY8uK/8RA==",
|
"integrity": "sha512-VJwdXrmv9L8L7ZasJeWcCjoIuMRVbhuxbss0fpVnR8yorMmjNDwcjIH08vS6wmSzzzgAG5CADQ1JuXPS2nwt9w==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@jridgewell/remapping": "^2.3.4",
|
"@jridgewell/remapping": "^2.3.4",
|
||||||
@@ -1170,7 +1189,7 @@
|
|||||||
"aria-query": "^5.3.1",
|
"aria-query": "^5.3.1",
|
||||||
"axobject-query": "^4.1.0",
|
"axobject-query": "^4.1.0",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"devalue": "^5.5.0",
|
"devalue": "^5.6.2",
|
||||||
"esm-env": "^1.2.1",
|
"esm-env": "^1.2.1",
|
||||||
"esrap": "^2.2.1",
|
"esrap": "^2.2.1",
|
||||||
"is-reference": "^3.0.3",
|
"is-reference": "^3.0.3",
|
||||||
@@ -1192,9 +1211,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/terser": {
|
"node_modules/terser": {
|
||||||
"version": "5.44.1",
|
"version": "5.46.0",
|
||||||
"resolved": "https://registry.npmjs.org/terser/-/terser-5.44.1.tgz",
|
"resolved": "https://registry.npmjs.org/terser/-/terser-5.46.0.tgz",
|
||||||
"integrity": "sha512-t/R3R/n0MSwnnazuPpPNVO60LX0SKL45pyl9YlvxIdkH0Of7D5qM2EVe+yASRIlY5pZ73nclYJfNANGWPwFDZw==",
|
"integrity": "sha512-jTwoImyr/QbOWFFso3YoU3ik0jBBDJ6JTOQiy/J2YxVJdZCc+5u7skhNwiOR3FQIygFqVUPHl7qbbxtjW2K3Qg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "BSD-2-Clause",
|
"license": "BSD-2-Clause",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -1217,9 +1236,9 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/typed-function": {
|
"node_modules/typed-function": {
|
||||||
"version": "4.2.1",
|
"version": "4.2.2",
|
||||||
"resolved": "https://registry.npmjs.org/typed-function/-/typed-function-4.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/typed-function/-/typed-function-4.2.2.tgz",
|
||||||
"integrity": "sha512-EGjWssW7Tsk4DGfE+5yluuljS1OGYWiI1J6e8puZz9nTMM51Oug8CD5Zo4gWMsOhq5BI+1bF+rWTm4Vbj3ivRA==",
|
"integrity": "sha512-VwaXim9Gp1bngi/q3do8hgttYn2uC3MoT/gfuMWylnj1IeZBUAyPddHZlo1K05BDoj8DYPpMdiHqH1dDYdJf2A==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 18"
|
"node": ">= 18"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "cc-frontend",
|
"name": "cc-frontend",
|
||||||
"version": "1.0.0",
|
"version": "1.5.0",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "rollup -c",
|
"build": "rollup -c",
|
||||||
@@ -8,13 +8,14 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@rollup/plugin-commonjs": "^29.0.0",
|
"@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",
|
"@rollup/plugin-terser": "^0.4.4",
|
||||||
"@timohausmann/quadtree-js": "^1.2.6",
|
"@timohausmann/quadtree-js": "^1.2.6",
|
||||||
"rollup": "^4.54.0",
|
"rollup": "^4.55.1",
|
||||||
"rollup-plugin-css-only": "^4.5.5",
|
"rollup-plugin-css-only": "^4.5.5",
|
||||||
"rollup-plugin-svelte": "^7.2.3",
|
"rollup-plugin-svelte": "^7.2.3",
|
||||||
"svelte": "^5.46.1"
|
"svelte": "^5.46.4"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@rollup/plugin-replace": "^6.0.3",
|
"@rollup/plugin-replace": "^6.0.3",
|
||||||
@@ -23,7 +24,7 @@
|
|||||||
"chart.js": "^4.5.1",
|
"chart.js": "^4.5.1",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
"graphql": "^16.12.0",
|
"graphql": "^16.12.0",
|
||||||
"mathjs": "^15.0.0",
|
"mathjs": "^15.1.0",
|
||||||
"uplot": "^1.6.32",
|
"uplot": "^1.6.32",
|
||||||
"wonka": "^6.3.5"
|
"wonka": "^6.3.5"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,11 +43,14 @@
|
|||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
// By default, look at the jobs of the last 6 hours:
|
// By default, look at the jobs of the last 6 hours:
|
||||||
|
// svelte-ignore state_referenced_locally
|
||||||
if (filterPresets?.startTime == null) {
|
if (filterPresets?.startTime == null) {
|
||||||
|
// svelte-ignore state_referenced_locally
|
||||||
if (filterPresets == null) filterPresets = {};
|
if (filterPresets == null) filterPresets = {};
|
||||||
let now = new Date(Date.now());
|
let now = new Date(Date.now());
|
||||||
let hourAgo = new Date(now);
|
let hourAgo = new Date(now);
|
||||||
hourAgo.setHours(hourAgo.getHours() - 6);
|
hourAgo.setHours(hourAgo.getHours() - 6);
|
||||||
|
// svelte-ignore state_referenced_locally
|
||||||
filterPresets.startTime = {
|
filterPresets.startTime = {
|
||||||
from: hourAgo.toISOString(),
|
from: hourAgo.toISOString(),
|
||||||
to: now.toISOString(),
|
to: now.toISOString(),
|
||||||
|
|||||||
@@ -6,9 +6,6 @@
|
|||||||
-->
|
-->
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
// import {
|
|
||||||
// getContext
|
|
||||||
// } from "svelte"
|
|
||||||
import {
|
import {
|
||||||
queryStore,
|
queryStore,
|
||||||
gql,
|
gql,
|
||||||
@@ -55,9 +52,6 @@
|
|||||||
let to = $state(new Date(Date.now()));
|
let to = $state(new Date(Date.now()));
|
||||||
let stackedFrom = $state(Math.floor(Date.now() / 1000) - 14400);
|
let stackedFrom = $state(Math.floor(Date.now() / 1000) - 14400);
|
||||||
let colWidthStates = $state(0);
|
let colWidthStates = $state(0);
|
||||||
let colWidthRoof = $state(0);
|
|
||||||
let colWidthTotals = $state(0);
|
|
||||||
let colWidthStacked = $state(0);
|
|
||||||
|
|
||||||
/* Derived */
|
/* Derived */
|
||||||
// States for Stacked charts
|
// States for Stacked charts
|
||||||
@@ -354,9 +348,7 @@
|
|||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Card style="height: 98vh;">
|
<Row>
|
||||||
<CardBody class="align-content-center p-2">
|
|
||||||
<Row>
|
|
||||||
<Col>
|
<Col>
|
||||||
<Refresher
|
<Refresher
|
||||||
hideSelector
|
hideSelector
|
||||||
@@ -371,15 +363,16 @@
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</Col>
|
</Col>
|
||||||
</Row>
|
</Row>
|
||||||
{#if $statusQuery.fetching || $statesTimed.fetching}
|
|
||||||
|
{#if $statusQuery.fetching || $statesTimed.fetching}
|
||||||
<Row class="justify-content-center">
|
<Row class="justify-content-center">
|
||||||
<Col xs="auto">
|
<Col xs="auto">
|
||||||
<Spinner />
|
<Spinner />
|
||||||
</Col>
|
</Col>
|
||||||
</Row>
|
</Row>
|
||||||
|
|
||||||
{:else if $statusQuery.error || $statesTimed.error}
|
{:else if $statusQuery.error || $statesTimed.error}
|
||||||
<Row class="mb-2">
|
<Row class="mb-2">
|
||||||
<Col class="d-flex justify-content-end">
|
<Col class="d-flex justify-content-end">
|
||||||
<Button color="secondary" href="/">
|
<Button color="secondary" href="/">
|
||||||
@@ -400,8 +393,10 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</Row>
|
</Row>
|
||||||
|
|
||||||
{:else}
|
{:else}
|
||||||
<Row cols={{xs:1, md:2}}>
|
<!-- 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 -->
|
<Col> <!-- General Cluster Info Card -->
|
||||||
<Card class="h-100">
|
<Card class="h-100">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
@@ -493,9 +488,9 @@
|
|||||||
Active Cores
|
Active Cores
|
||||||
</Col>
|
</Col>
|
||||||
<Col xs={8}>
|
<Col xs={8}>
|
||||||
<Progress multi style="height:2.5rem;font-size:x-large;">
|
<Progress multi max={clusterInfo?.totalCores} style="height:2.5rem;font-size:x-large;">
|
||||||
<Progress bar color="success" value={clusterInfo?.allocatedCores}>{formatNumber(clusterInfo?.allocatedCores)}</Progress>
|
<Progress bar color="success" value={clusterInfo?.allocatedCores} title={`${clusterInfo?.allocatedCores} active`}>{formatNumber(clusterInfo?.allocatedCores)}</Progress>
|
||||||
<Progress bar color="light" value={clusterInfo?.idleCores}>{formatNumber(clusterInfo?.idleCores)}</Progress>
|
<Progress bar color="light" value={clusterInfo?.idleCores} title={`${clusterInfo?.idleCores} idle`}>{formatNumber(clusterInfo?.idleCores)}</Progress>
|
||||||
</Progress>
|
</Progress>
|
||||||
</Col>
|
</Col>
|
||||||
<Col xs={2} style="font-size:large;">
|
<Col xs={2} style="font-size:large;">
|
||||||
@@ -508,9 +503,9 @@
|
|||||||
Active GPU
|
Active GPU
|
||||||
</Col>
|
</Col>
|
||||||
<Col xs={8}>
|
<Col xs={8}>
|
||||||
<Progress multi style="height:2.5rem;font-size:x-large;">
|
<Progress multi max={clusterInfo?.totalAccs} style="height:2.5rem;font-size:x-large;">
|
||||||
<Progress bar color="success" value={clusterInfo?.allocatedAccs}>{formatNumber(clusterInfo?.allocatedAccs)}</Progress>
|
<Progress bar color="success" value={clusterInfo?.allocatedAccs} title={`${clusterInfo?.allocatedAccs} active`}>{formatNumber(clusterInfo?.allocatedAccs)}</Progress>
|
||||||
<Progress bar color="light" value={clusterInfo?.idleAccs}>{formatNumber(clusterInfo?.idleAccs)}</Progress>
|
<Progress bar color="light" value={clusterInfo?.idleAccs} title={`${clusterInfo?.idleAccs} idle`}>{formatNumber(clusterInfo?.idleAccs)}</Progress>
|
||||||
</Progress>
|
</Progress>
|
||||||
</Col>
|
</Col>
|
||||||
<Col xs={2} style="font-size:large;">
|
<Col xs={2} style="font-size:large;">
|
||||||
@@ -521,40 +516,55 @@
|
|||||||
</CardBody>
|
</CardBody>
|
||||||
</Card>
|
</Card>
|
||||||
</Col>
|
</Col>
|
||||||
|
</Row>
|
||||||
|
|
||||||
<Col> <!-- Total Cluster Metric in Time SUMS-->
|
<Row cols={{xs:1, md:2}} style="height: 35vh; margin-bottom: 1rem;">
|
||||||
<div bind:clientWidth={colWidthTotals}>
|
<!-- 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
|
<DoubleMetric
|
||||||
width={colWidthTotals}
|
|
||||||
timestep={$statusQuery?.data?.clusterMetrics[0]?.timestep || 60}
|
timestep={$statusQuery?.data?.clusterMetrics[0]?.timestep || 60}
|
||||||
numNodes={$statusQuery?.data?.clusterMetrics?.nodeCount || 0}
|
numNodes={$statusQuery?.data?.clusterMetrics?.nodeCount || 0}
|
||||||
metricData={$statusQuery?.data?.clusterMetrics?.metrics || []}
|
metricData={$statusQuery?.data?.clusterMetrics?.metrics || []}
|
||||||
cluster={presetCluster}
|
publicMode
|
||||||
fixLinewidth={2}
|
|
||||||
/>
|
/>
|
||||||
|
{/key}
|
||||||
</div>
|
</div>
|
||||||
</Col>
|
</Col>
|
||||||
|
|
||||||
<Col> <!-- Nodes Roofline -->
|
<Col> <!-- Nodes Roofline -->
|
||||||
<div bind:clientWidth={colWidthRoof}>
|
<div>
|
||||||
{#key $statusQuery?.data?.nodeMetrics}
|
{#key $statusQuery?.data?.nodeMetrics}
|
||||||
<Roofline
|
<Roofline
|
||||||
colorBackground
|
colorBackground
|
||||||
useColors={false}
|
useColors={false}
|
||||||
useLegend={false}
|
useLegend={false}
|
||||||
allowSizeChange
|
allowSizeChange
|
||||||
width={colWidthRoof}
|
|
||||||
height={300}
|
|
||||||
cluster={presetCluster}
|
cluster={presetCluster}
|
||||||
subCluster={clusterInfo?.roofData ? clusterInfo.roofData : null}
|
subCluster={clusterInfo?.roofData ? clusterInfo.roofData : null}
|
||||||
roofData={transformNodesStatsToData($statusQuery?.data?.nodeMetrics)}
|
roofData={transformNodesStatsToData($statusQuery?.data?.nodeMetrics)}
|
||||||
nodesData={transformNodesStatsToInfo($statusQuery?.data?.nodeMetrics)}
|
nodesData={transformNodesStatsToInfo($statusQuery?.data?.nodeMetrics)}
|
||||||
fixTitle="Node Utilization"
|
fixTitle="Node Utilization"
|
||||||
yMinimum={1.0}
|
yMinimum={1.0}
|
||||||
|
height={330}
|
||||||
/>
|
/>
|
||||||
{/key}
|
{/key}
|
||||||
</div>
|
</div>
|
||||||
</Col>
|
</Col>
|
||||||
|
</Row>
|
||||||
|
|
||||||
|
<Row cols={{xs:1, md:2}} style="height: 35vh;">
|
||||||
<Col> <!-- Pie Last States -->
|
<Col> <!-- Pie Last States -->
|
||||||
<Row>
|
<Row>
|
||||||
{#if refinedStateData.length > 0}
|
{#if refinedStateData.length > 0}
|
||||||
@@ -607,12 +617,11 @@
|
|||||||
</Col>
|
</Col>
|
||||||
|
|
||||||
<Col> <!-- Stacked SchedState -->
|
<Col> <!-- Stacked SchedState -->
|
||||||
<div bind:clientWidth={colWidthStacked}>
|
<div>
|
||||||
{#key $statesTimed?.data?.nodeStatesTimed}
|
{#key $statesTimed?.data?.nodeStatesTimed}
|
||||||
<Stacked
|
<Stacked
|
||||||
data={$statesTimed?.data?.nodeStatesTimed}
|
data={$statesTimed?.data?.nodeStatesTimed}
|
||||||
width={colWidthStacked}
|
height={300}
|
||||||
height={260}
|
|
||||||
ylabel="Nodes"
|
ylabel="Nodes"
|
||||||
yunit = "#Count"
|
yunit = "#Count"
|
||||||
title = "Cluster Status"
|
title = "Cluster Status"
|
||||||
@@ -622,6 +631,5 @@
|
|||||||
</div>
|
</div>
|
||||||
</Col>
|
</Col>
|
||||||
</Row>
|
</Row>
|
||||||
{/if}
|
</div>
|
||||||
</CardBody>
|
{/if}
|
||||||
</Card>
|
|
||||||
|
|||||||
@@ -53,7 +53,9 @@
|
|||||||
const views = [
|
const views = [
|
||||||
{
|
{
|
||||||
title: "My Jobs",
|
title: "My Jobs",
|
||||||
|
// svelte-ignore state_referenced_locally
|
||||||
requiredRole: roles.user,
|
requiredRole: roles.user,
|
||||||
|
// svelte-ignore state_referenced_locally
|
||||||
href: `/monitoring/user/${username}`,
|
href: `/monitoring/user/${username}`,
|
||||||
icon: "bar-chart-line",
|
icon: "bar-chart-line",
|
||||||
perCluster: false,
|
perCluster: false,
|
||||||
@@ -61,7 +63,9 @@
|
|||||||
menu: "none",
|
menu: "none",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
// svelte-ignore state_referenced_locally
|
||||||
title: jobsTitle.get(authlevel),
|
title: jobsTitle.get(authlevel),
|
||||||
|
// svelte-ignore state_referenced_locally
|
||||||
requiredRole: roles.user,
|
requiredRole: roles.user,
|
||||||
href: `/monitoring/jobs/`,
|
href: `/monitoring/jobs/`,
|
||||||
icon: "card-list",
|
icon: "card-list",
|
||||||
@@ -71,6 +75,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Tags",
|
title: "Tags",
|
||||||
|
// svelte-ignore state_referenced_locally
|
||||||
requiredRole: roles.user,
|
requiredRole: roles.user,
|
||||||
href: "/monitoring/tags/",
|
href: "/monitoring/tags/",
|
||||||
icon: "tags",
|
icon: "tags",
|
||||||
@@ -79,7 +84,9 @@
|
|||||||
menu: "Jobs",
|
menu: "Jobs",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
// svelte-ignore state_referenced_locally
|
||||||
title: usersTitle.get(authlevel),
|
title: usersTitle.get(authlevel),
|
||||||
|
// svelte-ignore state_referenced_locally
|
||||||
requiredRole: roles.manager,
|
requiredRole: roles.manager,
|
||||||
href: "/monitoring/users/",
|
href: "/monitoring/users/",
|
||||||
icon: "people",
|
icon: "people",
|
||||||
@@ -88,7 +95,9 @@
|
|||||||
menu: "Groups",
|
menu: "Groups",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
// svelte-ignore state_referenced_locally
|
||||||
title: projectsTitle.get(authlevel),
|
title: projectsTitle.get(authlevel),
|
||||||
|
// svelte-ignore state_referenced_locally
|
||||||
requiredRole: roles.manager,
|
requiredRole: roles.manager,
|
||||||
href: "/monitoring/projects/",
|
href: "/monitoring/projects/",
|
||||||
icon: "journals",
|
icon: "journals",
|
||||||
@@ -98,6 +107,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Nodes",
|
title: "Nodes",
|
||||||
|
// svelte-ignore state_referenced_locally
|
||||||
requiredRole: roles.support,
|
requiredRole: roles.support,
|
||||||
href: "/monitoring/systems/",
|
href: "/monitoring/systems/",
|
||||||
icon: "hdd-rack",
|
icon: "hdd-rack",
|
||||||
@@ -107,6 +117,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Analysis",
|
title: "Analysis",
|
||||||
|
// svelte-ignore state_referenced_locally
|
||||||
requiredRole: roles.support,
|
requiredRole: roles.support,
|
||||||
href: "/monitoring/analysis/",
|
href: "/monitoring/analysis/",
|
||||||
icon: "graph-up",
|
icon: "graph-up",
|
||||||
@@ -116,6 +127,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Status",
|
title: "Status",
|
||||||
|
// svelte-ignore state_referenced_locally
|
||||||
requiredRole: roles.admin,
|
requiredRole: roles.admin,
|
||||||
href: "/monitoring/status/",
|
href: "/monitoring/status/",
|
||||||
icon: "clipboard-data",
|
icon: "clipboard-data",
|
||||||
@@ -217,7 +229,7 @@
|
|||||||
</DropdownToggle>
|
</DropdownToggle>
|
||||||
<DropdownMenu class="dropdown-menu-lg-end">
|
<DropdownMenu class="dropdown-menu-lg-end">
|
||||||
<NavbarLinks
|
<NavbarLinks
|
||||||
{clustersNames}
|
{clusterNames}
|
||||||
{subclusterMap}
|
{subclusterMap}
|
||||||
direction="right"
|
direction="right"
|
||||||
links={views.filter(
|
links={views.filter(
|
||||||
|
|||||||
@@ -52,6 +52,7 @@
|
|||||||
|
|
||||||
/* Const Init */
|
/* Const Init */
|
||||||
// Important: init() needs to be first const declaration or contextclient will not be initialized before "const client = ..."
|
// 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(`
|
const { query: initq } = init(`
|
||||||
job(id: "${dbid}") {
|
job(id: "${dbid}") {
|
||||||
id, jobId, user, project, cluster, startTime,
|
id, jobId, user, project, cluster, startTime,
|
||||||
@@ -93,6 +94,7 @@
|
|||||||
let totalMetrics = $state(0);
|
let totalMetrics = $state(0);
|
||||||
|
|
||||||
/* Derived */
|
/* Derived */
|
||||||
|
const showSummary = $derived((!!ccconfig[`jobView_showFootprint`] || !!ccconfig[`jobView_showPolarPlot`]))
|
||||||
const jobMetrics = $derived(queryStore({
|
const jobMetrics = $derived(queryStore({
|
||||||
client: client,
|
client: client,
|
||||||
query: query,
|
query: query,
|
||||||
@@ -260,7 +262,9 @@
|
|||||||
{#if $initq.error}
|
{#if $initq.error}
|
||||||
<Card body color="danger">{$initq.error.message}</Card>
|
<Card body color="danger">{$initq.error.message}</Card>
|
||||||
{:else if $initq?.data}
|
{:else if $initq?.data}
|
||||||
|
{#if showSummary}
|
||||||
<JobSummary job={$initq.data.job}/>
|
<JobSummary job={$initq.data.job}/>
|
||||||
|
{/if}
|
||||||
{:else}
|
{:else}
|
||||||
<Spinner secondary />
|
<Spinner secondary />
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -37,7 +37,6 @@
|
|||||||
/* Const Init */
|
/* Const Init */
|
||||||
const { query: initq } = init();
|
const { query: initq } = init();
|
||||||
const ccconfig = getContext("cc-config");
|
const ccconfig = getContext("cc-config");
|
||||||
const presetProject = filterPresets?.project ? filterPresets.project : ""
|
|
||||||
|
|
||||||
/* State Init */
|
/* 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
|
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 showCompare = $state(false);
|
||||||
let isMetricsSelectionOpen = $state(false);
|
let isMetricsSelectionOpen = $state(false);
|
||||||
let sorting = $state({ field: "startTime", type: "col", order: "DESC" });
|
let sorting = $state({ field: "startTime", type: "col", order: "DESC" });
|
||||||
let selectedCluster = $state(filterPresets?.cluster ? filterPresets.cluster : null);
|
|
||||||
let metrics = $state(filterPresets.cluster
|
/* Derived */
|
||||||
? ccconfig[`metricConfig_jobListMetrics:${filterPresets.cluster}`] ||
|
const presetProject = $derived(filterPresets?.project ? filterPresets.project : "");
|
||||||
ccconfig.metricConfig_jobListMetrics
|
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
|
: ccconfig.metricConfig_jobListMetrics
|
||||||
);
|
);
|
||||||
let showFootprint = $state(filterPresets.cluster
|
let showFootprint = $derived(filterPresets.cluster
|
||||||
? !!ccconfig[`jobList_showFootprint:${filterPresets.cluster}`]
|
? !!ccconfig[`jobList_showFootprint:${filterPresets.cluster}`]
|
||||||
: !!ccconfig.jobList_showFootprint
|
: !!ccconfig.jobList_showFootprint
|
||||||
);
|
);
|
||||||
@@ -84,6 +88,11 @@
|
|||||||
metrics = selectedCluster ? ccconfig[`metricConfig_jobListMetrics:${selectedCluster}`] : ccconfig.metricConfig_jobListMetrics
|
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 */
|
/* On Mount */
|
||||||
// The filterPresets are handled by the Filters component,
|
// The filterPresets are handled by the Filters component,
|
||||||
// so we need to wait for it to be ready before we can start a query.
|
// 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
|
selectedCluster = detail.filters[0]?.cluster
|
||||||
? detail.filters[0].cluster.eq
|
? detail.filters[0].cluster.eq
|
||||||
: null;
|
: null;
|
||||||
|
selectedSubCluster = detail.filters[1]?.partition
|
||||||
|
? detail.filters[1].partition.eq
|
||||||
|
: null;
|
||||||
filterBuffer = [...detail.filters]
|
filterBuffer = [...detail.filters]
|
||||||
if (showCompare) {
|
if (showCompare) {
|
||||||
jobCompare.queryJobs(detail.filters);
|
jobCompare.queryJobs(detail.filters);
|
||||||
@@ -218,6 +230,7 @@
|
|||||||
bind:showFootprint
|
bind:showFootprint
|
||||||
presetMetrics={metrics}
|
presetMetrics={metrics}
|
||||||
cluster={selectedCluster}
|
cluster={selectedCluster}
|
||||||
|
subCluster={selectedSubCluster}
|
||||||
configName="metricConfig_jobListMetrics"
|
configName="metricConfig_jobListMetrics"
|
||||||
footprintSelect
|
footprintSelect
|
||||||
applyMetrics={(newMetrics) =>
|
applyMetrics={(newMetrics) =>
|
||||||
|
|||||||
@@ -37,12 +37,6 @@
|
|||||||
filterPresets
|
filterPresets
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
/* Validate Type */
|
|
||||||
console.assert(
|
|
||||||
type == "USER" || type == "PROJECT",
|
|
||||||
"Invalid list type provided!",
|
|
||||||
);
|
|
||||||
|
|
||||||
/* Const Init */
|
/* Const Init */
|
||||||
const {} = init();
|
const {} = init();
|
||||||
const client = getContextClient();
|
const client = getContextClient();
|
||||||
|
|||||||
@@ -54,11 +54,6 @@
|
|||||||
const nowEpoch = Date.now();
|
const nowEpoch = Date.now();
|
||||||
const paging = { itemsPerPage: 50, page: 1 };
|
const paging = { itemsPerPage: 50, page: 1 };
|
||||||
const sorting = { field: "startTime", type: "col", order: "DESC" };
|
const sorting = { field: "startTime", type: "col", order: "DESC" };
|
||||||
const filter = [
|
|
||||||
{ cluster: { eq: cluster } },
|
|
||||||
{ node: { contains: hostname } },
|
|
||||||
{ state: ["running"] },
|
|
||||||
];
|
|
||||||
const client = getContextClient();
|
const client = getContextClient();
|
||||||
const nodeMetricsQuery = gql`
|
const nodeMetricsQuery = gql`
|
||||||
query ($cluster: String!, $nodes: [String!], $from: Time!, $to: Time!) {
|
query ($cluster: String!, $nodes: [String!], $from: Time!, $to: Time!) {
|
||||||
@@ -111,11 +106,18 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* State Init */
|
/* State Init */
|
||||||
|
// svelte-ignore state_referenced_locally
|
||||||
let from = $state(presetFrom ? presetFrom : new Date(nowEpoch - (4 * 3600 * 1000)));
|
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 to = $state(presetTo ? presetTo : new Date(nowEpoch));
|
||||||
let systemUnits = $state({});
|
let systemUnits = $state({});
|
||||||
|
|
||||||
/* Derived */
|
/* Derived */
|
||||||
|
const filter = $derived([
|
||||||
|
{ cluster: { eq: cluster } },
|
||||||
|
{ node: { contains: hostname } },
|
||||||
|
{ state: ["running"] },
|
||||||
|
]);
|
||||||
const nodeMetricsData = $derived(queryStore({
|
const nodeMetricsData = $derived(queryStore({
|
||||||
client: client,
|
client: client,
|
||||||
query: nodeMetricsQuery,
|
query: nodeMetricsQuery,
|
||||||
|
|||||||
@@ -51,7 +51,6 @@
|
|||||||
/* Const Init */
|
/* Const Init */
|
||||||
const { query: initq } = init();
|
const { query: initq } = init();
|
||||||
const client = getContextClient();
|
const client = getContextClient();
|
||||||
const displayNodeOverview = (displayType === 'OVERVIEW');
|
|
||||||
const ccconfig = getContext("cc-config");
|
const ccconfig = getContext("cc-config");
|
||||||
const initialized = getContext("initialized");
|
const initialized = getContext("initialized");
|
||||||
const globalMetrics = getContext("globalMetrics");
|
const globalMetrics = getContext("globalMetrics");
|
||||||
@@ -66,7 +65,9 @@
|
|||||||
let timeoutId = null;
|
let timeoutId = null;
|
||||||
|
|
||||||
/* State Init */
|
/* State Init */
|
||||||
|
// svelte-ignore state_referenced_locally
|
||||||
let to = $state(presetTo || new Date(Date.now()));
|
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 from = $state(presetFrom || new Date(nowDate.setHours(nowDate.getHours() - 4)));
|
||||||
let selectedResolution = $state(resampleConfig ? resampleDefault : 0);
|
let selectedResolution = $state(resampleConfig ? resampleDefault : 0);
|
||||||
let hostnameFilter = $state("");
|
let hostnameFilter = $state("");
|
||||||
@@ -75,6 +76,7 @@
|
|||||||
let isMetricsSelectionOpen = $state(false);
|
let isMetricsSelectionOpen = $state(false);
|
||||||
|
|
||||||
/* Derived States */
|
/* Derived States */
|
||||||
|
const displayNodeOverview = $derived((displayType === 'OVERVIEW'));
|
||||||
const systemMetrics = $derived($initialized ? [...globalMetrics.filter((gm) => gm?.availability.find((av) => av.cluster == cluster))] : []);
|
const systemMetrics = $derived($initialized ? [...globalMetrics.filter((gm) => gm?.availability.find((av) => av.cluster == cluster))] : []);
|
||||||
const presetSystemUnits = $derived(loadUnits(systemMetrics));
|
const presetSystemUnits = $derived(loadUnits(systemMetrics));
|
||||||
let selectedMetric = $derived.by(() => {
|
let selectedMetric = $derived.by(() => {
|
||||||
|
|||||||
@@ -37,6 +37,7 @@
|
|||||||
|
|
||||||
/* State Init */
|
/* State Init */
|
||||||
let pendingChange = $state("none");
|
let pendingChange = $state("none");
|
||||||
|
// svelte-ignore state_referenced_locally
|
||||||
let tagmap = $state(presetTagmap);
|
let tagmap = $state(presetTagmap);
|
||||||
|
|
||||||
/* Functions */
|
/* Functions */
|
||||||
|
|||||||
@@ -64,17 +64,7 @@
|
|||||||
let isSortingOpen = $state(false);
|
let isSortingOpen = $state(false);
|
||||||
let isMetricsSelectionOpen = $state(false);
|
let isMetricsSelectionOpen = $state(false);
|
||||||
let sorting = $state({ field: "startTime", type: "col", order: "DESC" });
|
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 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
|
// Histogram Vars
|
||||||
let isHistogramSelectionOpen = $state(false);
|
let isHistogramSelectionOpen = $state(false);
|
||||||
@@ -88,7 +78,17 @@
|
|||||||
// let filterBuffer = $state([]);
|
// let filterBuffer = $state([]);
|
||||||
// let matchedCompareJobs = $state(0);
|
// 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 selectedHistograms = $derived(selectedCluster ? selectedHistogramsBuffer[selectedCluster] : selectedHistogramsBuffer['all']);
|
||||||
let stats = $derived(
|
let stats = $derived(
|
||||||
queryStore({
|
queryStore({
|
||||||
|
|||||||
@@ -49,11 +49,13 @@
|
|||||||
/* State Init */
|
/* State Init */
|
||||||
let isHistogramConfigOpen = $state(false);
|
let isHistogramConfigOpen = $state(false);
|
||||||
let isScatterPlotConfigOpen = $state(false);
|
let isScatterPlotConfigOpen = $state(false);
|
||||||
let metricsInHistograms = $state(presetMetricsInHistograms);
|
|
||||||
let metricsInScatterplots = $state(presetMetricsInScatterplots);
|
|
||||||
let selectedMetric1 = $state(null);
|
let selectedMetric1 = $state(null);
|
||||||
let selectedMetric2 = $state(null);
|
let selectedMetric2 = $state(null);
|
||||||
|
|
||||||
|
/* Derived */
|
||||||
|
let metricsInHistograms = $derived(presetMetricsInHistograms);
|
||||||
|
let metricsInScatterplots = $derived(presetMetricsInScatterplots);
|
||||||
|
|
||||||
/* Functions */
|
/* Functions */
|
||||||
function updateConfiguration(data) {
|
function updateConfiguration(data) {
|
||||||
updateConfigurationMutation({
|
updateConfigurationMutation({
|
||||||
|
|||||||
@@ -28,8 +28,8 @@
|
|||||||
updateSetting
|
updateSetting
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
/* State Init */
|
/* Derived */
|
||||||
let activeRow = $state(JSON.stringify(config?.plotConfiguration_colorScheme));
|
let activeRow = $derived(JSON.stringify(config?.plotConfiguration_colorScheme));
|
||||||
|
|
||||||
/* Const Init */
|
/* Const Init */
|
||||||
const colorSchemes = {
|
const colorSchemes = {
|
||||||
|
|||||||
@@ -89,6 +89,7 @@
|
|||||||
};
|
};
|
||||||
|
|
||||||
/* State Init */
|
/* State Init */
|
||||||
|
// svelte-ignore state_referenced_locally
|
||||||
let filters = $state({
|
let filters = $state({
|
||||||
dbId: filterPresets.dbId || [],
|
dbId: filterPresets.dbId || [],
|
||||||
jobId: filterPresets.jobId || "",
|
jobId: filterPresets.jobId || "",
|
||||||
|
|||||||
@@ -64,10 +64,10 @@
|
|||||||
let plotSync = uPlot.sync("compareJobsView");
|
let plotSync = uPlot.sync("compareJobsView");
|
||||||
|
|
||||||
/* State Init */
|
/* State Init */
|
||||||
let filter = $state([...filterBuffer] || []);
|
|
||||||
let tableJobIDFilter = $state("");
|
let tableJobIDFilter = $state("");
|
||||||
|
|
||||||
/* Derived*/
|
/* Derived*/
|
||||||
|
let filter = $derived([...filterBuffer] || []);
|
||||||
const compareData = $derived(queryStore({
|
const compareData = $derived(queryStore({
|
||||||
client: client,
|
client: client,
|
||||||
query: compareQuery,
|
query: compareQuery,
|
||||||
|
|||||||
@@ -99,13 +99,13 @@
|
|||||||
/* State Init */
|
/* State Init */
|
||||||
let headerPaddingTop = $state(0);
|
let headerPaddingTop = $state(0);
|
||||||
let jobs = $state([]);
|
let jobs = $state([]);
|
||||||
let filter = $state([...filterBuffer]);
|
|
||||||
let page = $state(1);
|
let page = $state(1);
|
||||||
let itemsPerPage = $state(usePaging ? (ccconfig?.jobList_jobsPerPage || 10) : 10);
|
let itemsPerPage = $state(usePaging ? (ccconfig?.jobList_jobsPerPage || 10) : 10);
|
||||||
let triggerMetricRefresh = $state(false);
|
let triggerMetricRefresh = $state(false);
|
||||||
let tableWidth = $state(0);
|
let tableWidth = $state(0);
|
||||||
|
|
||||||
/* Derived */
|
/* Derived */
|
||||||
|
let filter = $derived([...filterBuffer]);
|
||||||
let paging = $derived({ itemsPerPage, page });
|
let paging = $derived({ itemsPerPage, page });
|
||||||
const plotWidth = $derived.by(() => {
|
const plotWidth = $derived.by(() => {
|
||||||
return Math.floor(
|
return Math.floor(
|
||||||
|
|||||||
@@ -34,9 +34,9 @@
|
|||||||
const clusters = getContext("clusters");
|
const clusters = getContext("clusters");
|
||||||
const initialized = getContext("initialized");
|
const initialized = getContext("initialized");
|
||||||
|
|
||||||
/* State Init */
|
/* Derived */
|
||||||
let pendingCluster = $state(presetCluster);
|
let pendingCluster = $derived(presetCluster);
|
||||||
let pendingPartition = $state(presetPartition);
|
let pendingPartition = $derived(presetPartition);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Modal {isOpen} toggle={() => (isOpen = !isOpen)}>
|
<Modal {isOpen} toggle={() => (isOpen = !isOpen)}>
|
||||||
|
|||||||
@@ -31,14 +31,13 @@
|
|||||||
setFilter
|
setFilter
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
/* State Init */
|
/* Derived */
|
||||||
let pendingDuration = $state(presetDuration);
|
let pendingDuration = $derived(presetDuration);
|
||||||
let lessState = $state(secsToHoursAndMins(presetDuration?.lessThan));
|
let lessState = $derived(secsToHoursAndMins(presetDuration?.lessThan));
|
||||||
let moreState = $state(secsToHoursAndMins(presetDuration?.moreThan));
|
let moreState = $derived(secsToHoursAndMins(presetDuration?.moreThan));
|
||||||
let fromState = $state(secsToHoursAndMins(presetDuration?.from));
|
let fromState = $derived(secsToHoursAndMins(presetDuration?.from));
|
||||||
let toState = $state(secsToHoursAndMins(presetDuration?.to));
|
let toState = $derived(secsToHoursAndMins(presetDuration?.to));
|
||||||
|
|
||||||
/* Derived Init */
|
|
||||||
const lessDisabled = $derived(
|
const lessDisabled = $derived(
|
||||||
moreState.hours !== 0 ||
|
moreState.hours !== 0 ||
|
||||||
moreState.mins !== 0 ||
|
moreState.mins !== 0 ||
|
||||||
|
|||||||
@@ -28,8 +28,8 @@
|
|||||||
setFilter,
|
setFilter,
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
/* State Init */
|
/* Derived */
|
||||||
let energyState = $state(presetEnergy);
|
let energyState = $derived(presetEnergy);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Modal {isOpen} toggle={() => (isOpen = !isOpen)}>
|
<Modal {isOpen} toggle={() => (isOpen = !isOpen)}>
|
||||||
|
|||||||
@@ -67,10 +67,10 @@
|
|||||||
"single_user",
|
"single_user",
|
||||||
];
|
];
|
||||||
|
|
||||||
/* State Init */
|
/* Derived */
|
||||||
let pendingStates = $state([...presetStates]);
|
let pendingStates = $derived([...presetStates]);
|
||||||
let pendingShared = $state(presetShared);
|
let pendingShared = $derived(presetShared);
|
||||||
let pendingSchedule = $state(presetSchedule);
|
let pendingSchedule = $derived(presetSchedule);
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -79,18 +79,19 @@
|
|||||||
let maxNumNodes = $state(128);
|
let maxNumNodes = $state(128);
|
||||||
let maxNumHWThreads = $state(0);
|
let maxNumHWThreads = $state(0);
|
||||||
let maxNumAccelerators = $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 */
|
/* 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 clusters = $derived(getContext("clusters"));
|
||||||
const initialized = $derived(getContext("initialized"));
|
const initialized = $derived(getContext("initialized"));
|
||||||
// Is Selection Active
|
// Is Selection Active
|
||||||
|
|||||||
@@ -48,12 +48,11 @@
|
|||||||
const resetFrom = { date: format(ago, "yyyy-MM-dd"), time: format(ago, "HH:mm")};
|
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")};
|
const resetTo = { date: format(now, "yyyy-MM-dd"), time: format(now, "HH:mm")};
|
||||||
|
|
||||||
/* State Init */
|
/* Derived */
|
||||||
let pendingStartTime = $state(presetStartTime);
|
let pendingStartTime = $derived(presetStartTime);
|
||||||
let fromState = $state(fromRFC3339(presetStartTime?.from, resetFrom));
|
let fromState = $derived(fromRFC3339(presetStartTime?.from, resetFrom));
|
||||||
let toState = $state(fromRFC3339(presetStartTime?.to, resetTo));
|
let toState = $derived(fromRFC3339(presetStartTime?.to, resetTo));
|
||||||
|
|
||||||
/* Derived Init*/
|
|
||||||
const rangeSelect = $derived(pendingStartTime?.range ? pendingStartTime.range : "")
|
const rangeSelect = $derived(pendingStartTime?.range ? pendingStartTime.range : "")
|
||||||
|
|
||||||
/* Functions */
|
/* Functions */
|
||||||
|
|||||||
@@ -35,8 +35,11 @@
|
|||||||
const initialized = $derived(getContext("initialized"))
|
const initialized = $derived(getContext("initialized"))
|
||||||
|
|
||||||
/* State Init */
|
/* State Init */
|
||||||
let pendingTags = $state(presetTags);
|
|
||||||
let searchTerm = $state("");
|
let searchTerm = $state("");
|
||||||
|
|
||||||
|
/* Derived */
|
||||||
|
let pendingTags = $derived(presetTags);
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Modal {isOpen} toggle={() => (isOpen = !isOpen)}>
|
<Modal {isOpen} toggle={() => (isOpen = !isOpen)}>
|
||||||
|
|||||||
@@ -18,12 +18,12 @@
|
|||||||
onRefresh
|
onRefresh
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
/* State Init */
|
|
||||||
let refreshInterval = $state(initially ? initially * 1000 : null);
|
|
||||||
|
|
||||||
/* Var Init */
|
/* Var Init */
|
||||||
let refreshIntervalId = null;
|
let refreshIntervalId = null;
|
||||||
|
|
||||||
|
/* Derived */
|
||||||
|
let refreshInterval = $derived(initially ? initially * 1000 : null);
|
||||||
|
|
||||||
/* Functions */
|
/* Functions */
|
||||||
function refreshIntervalChanged() {
|
function refreshIntervalChanged() {
|
||||||
if (refreshIntervalId != null) clearInterval(refreshIntervalId);
|
if (refreshIntervalId != null) clearInterval(refreshIntervalId);
|
||||||
|
|||||||
@@ -45,8 +45,6 @@
|
|||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
/* Const Init */
|
/* Const Init */
|
||||||
const isAdmin = (roles && authlevel == roles.admin);
|
|
||||||
const isSupport = (roles && authlevel == roles.support);
|
|
||||||
const client = getContextClient();
|
const client = getContextClient();
|
||||||
|
|
||||||
/* State Init */
|
/* State Init */
|
||||||
@@ -54,12 +52,14 @@
|
|||||||
let allTags = getContext("tags")
|
let allTags = getContext("tags")
|
||||||
let newTagType = $state("");
|
let newTagType = $state("");
|
||||||
let newTagName = $state("");
|
let newTagName = $state("");
|
||||||
let newTagScope = $state(username);
|
|
||||||
let filterTerm = $state("");
|
let filterTerm = $state("");
|
||||||
let pendingChange = $state(false);
|
let pendingChange = $state(false);
|
||||||
let isOpen = $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 allTagsFiltered = $derived(($initialized, jobTags, fuzzySearchTags(filterTerm, allTags))); // $init und JobTags only for triggering react
|
||||||
const usedTagsFiltered = $derived(matchJobTags(jobTags, allTagsFiltered, 'used', isAdmin, isSupport));
|
const usedTagsFiltered = $derived(matchJobTags(jobTags, allTagsFiltered, 'used', isAdmin, isSupport));
|
||||||
const unusedTagsFiltered = $derived(matchJobTags(jobTags, allTagsFiltered, 'unused', isAdmin, isSupport));
|
const unusedTagsFiltered = $derived(matchJobTags(jobTags, allTagsFiltered, 'unused', isAdmin, isSupport));
|
||||||
|
|||||||
@@ -26,14 +26,16 @@
|
|||||||
|
|
||||||
/* Var Init */
|
/* Var Init */
|
||||||
let user = "";
|
let user = "";
|
||||||
let project = presetProject ? presetProject : "";
|
|
||||||
let jobName = "";
|
let jobName = "";
|
||||||
let timeoutId = null;
|
let timeoutId = null;
|
||||||
|
|
||||||
/* State Init */
|
/* State Init */
|
||||||
let mode = $state(presetProject ? "jobName" : "project");
|
|
||||||
let term = $state("");
|
let term = $state("");
|
||||||
|
|
||||||
|
/* Derived */
|
||||||
|
let project = $derived(presetProject ? presetProject : "");
|
||||||
|
let mode = $derived(presetProject ? "jobName" : "project");
|
||||||
|
|
||||||
/* Functions */
|
/* Functions */
|
||||||
function modeChanged() {
|
function modeChanged() {
|
||||||
if (mode == "user") {
|
if (mode == "user") {
|
||||||
|
|||||||
@@ -37,13 +37,7 @@
|
|||||||
|
|
||||||
/* Const Init */
|
/* Const Init */
|
||||||
const client = getContextClient();
|
const client = getContextClient();
|
||||||
const jobId = job.id;
|
|
||||||
const cluster = getContext("clusters");
|
const cluster = getContext("clusters");
|
||||||
const scopes = (job.numNodes == 1)
|
|
||||||
? (job.numAcc >= 1)
|
|
||||||
? ["core", "accelerator"]
|
|
||||||
: ["core"]
|
|
||||||
: ["node"];
|
|
||||||
const resampleConfig = getContext("resampling") || null;
|
const resampleConfig = getContext("resampling") || null;
|
||||||
const resampleDefault = resampleConfig ? Math.max(...resampleConfig.resolutions) : 0;
|
const resampleDefault = resampleConfig ? Math.max(...resampleConfig.resolutions) : 0;
|
||||||
const query = gql`
|
const query = gql`
|
||||||
@@ -84,6 +78,15 @@
|
|||||||
let thresholdStates = $state({});
|
let thresholdStates = $state({});
|
||||||
|
|
||||||
/* Derived */
|
/* 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 isSelected = $derived(previousSelect);
|
||||||
let metricsQuery = $derived(queryStore({
|
let metricsQuery = $derived(queryStore({
|
||||||
client: client,
|
client: client,
|
||||||
|
|||||||
@@ -73,6 +73,7 @@
|
|||||||
]
|
]
|
||||||
|
|
||||||
// UPLOT SCALES INIT //
|
// UPLOT SCALES INIT //
|
||||||
|
// svelte-ignore state_referenced_locally
|
||||||
if (forResources) {
|
if (forResources) {
|
||||||
const resSeries = [
|
const resSeries = [
|
||||||
{
|
{
|
||||||
@@ -136,8 +137,11 @@
|
|||||||
|
|
||||||
// UPLOT OPTIONS //
|
// UPLOT OPTIONS //
|
||||||
const opts = {
|
const opts = {
|
||||||
|
// svelte-ignore state_referenced_locally
|
||||||
width,
|
width,
|
||||||
|
// svelte-ignore state_referenced_locally
|
||||||
height,
|
height,
|
||||||
|
// svelte-ignore state_referenced_locally
|
||||||
title,
|
title,
|
||||||
plugins: [legendAsTooltipPlugin()],
|
plugins: [legendAsTooltipPlugin()],
|
||||||
series: plotSeries,
|
series: plotSeries,
|
||||||
@@ -147,6 +151,7 @@
|
|||||||
space: 25, // Tick Spacing
|
space: 25, // Tick Spacing
|
||||||
rotate: 30,
|
rotate: 30,
|
||||||
show: true,
|
show: true,
|
||||||
|
// svelte-ignore state_referenced_locally
|
||||||
label: xlabel,
|
label: xlabel,
|
||||||
values(self, splits) {
|
values(self, splits) {
|
||||||
return splits.map(s => xticks[s]);
|
return splits.map(s => xticks[s]);
|
||||||
@@ -164,16 +169,19 @@
|
|||||||
scale: "y",
|
scale: "y",
|
||||||
grid: { show: true },
|
grid: { show: true },
|
||||||
labelFont: "sans-serif",
|
labelFont: "sans-serif",
|
||||||
|
// svelte-ignore state_referenced_locally
|
||||||
label: ylabel + (yunit ? ` (${yunit})` : ''),
|
label: ylabel + (yunit ? ` (${yunit})` : ''),
|
||||||
values: (u, vals) => vals.map((v) => formatNumber(v)),
|
values: (u, vals) => vals.map((v) => formatNumber(v)),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
// svelte-ignore state_referenced_locally
|
||||||
bands: forResources ? [] : plotBands,
|
bands: forResources ? [] : plotBands,
|
||||||
padding: [5, 10, 0, 0],
|
padding: [5, 10, 0, 0],
|
||||||
hooks: {
|
hooks: {
|
||||||
draw: [
|
draw: [
|
||||||
(u) => {
|
(u) => {
|
||||||
// Draw plot type label:
|
// Draw plot type label:
|
||||||
|
// svelte-ignore state_referenced_locally
|
||||||
let textl = forResources ? "Job Resources by Type" : "Metric Min/Avg/Max for Job Duration";
|
let textl = forResources ? "Job Resources by Type" : "Metric Min/Avg/Max for Job Duration";
|
||||||
let textr = "Earlier <- StartTime -> Later";
|
let textr = "Earlier <- StartTime -> Later";
|
||||||
u.ctx.save();
|
u.ctx.save();
|
||||||
@@ -196,6 +204,7 @@
|
|||||||
x: { time: false },
|
x: { time: false },
|
||||||
xst: { time: false },
|
xst: { time: false },
|
||||||
xrt: { time: false },
|
xrt: { time: false },
|
||||||
|
// svelte-ignore state_referenced_locally
|
||||||
y: {auto: true, distr: forResources ? 3 : 1},
|
y: {auto: true, distr: forResources ? 3 : 1},
|
||||||
},
|
},
|
||||||
legend: {
|
legend: {
|
||||||
|
|||||||
@@ -4,123 +4,56 @@
|
|||||||
Only width/height should change reactively.
|
Only width/height should change reactively.
|
||||||
|
|
||||||
Properties:
|
Properties:
|
||||||
- `metric String`: The metric name
|
- `metricData [Data]`: Two series of metric data including unit info
|
||||||
- `scope String?`: Scope of the displayed data [Default: node]
|
- `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]
|
- `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>
|
<script>
|
||||||
import uPlot from "uplot";
|
import uPlot from "uplot";
|
||||||
import { formatNumber, formatDurationTime } from "../units.js";
|
import { formatNumber, formatDurationTime } from "../units.js";
|
||||||
import { getContext, onMount, onDestroy } from "svelte";
|
import { getContext, onDestroy } from "svelte";
|
||||||
import { Card } from "@sveltestrap/sveltestrap";
|
import { Card } from "@sveltestrap/sveltestrap";
|
||||||
|
|
||||||
/* Svelte 5 Props */
|
/* Svelte 5 Props */
|
||||||
let {
|
let {
|
||||||
// metric,
|
metricData,
|
||||||
width = 0,
|
|
||||||
height = 300,
|
|
||||||
fixLinewidth = null,
|
|
||||||
timestep,
|
timestep,
|
||||||
numNodes,
|
numNodes,
|
||||||
metricData,
|
cluster,
|
||||||
// useStatsSeries = false,
|
|
||||||
// statisticsSeries = null,
|
|
||||||
cluster = "",
|
|
||||||
forNode = true,
|
forNode = true,
|
||||||
// zoomState = null,
|
|
||||||
// thresholdState = null,
|
|
||||||
enableFlip = false,
|
enableFlip = false,
|
||||||
// onZoom
|
publicMode = false,
|
||||||
|
height = 300,
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
/* Const Init */
|
/* Const Init */
|
||||||
const clusterCockpitConfig = getContext("cc-config");
|
const clusterCockpitConfig = getContext("cc-config");
|
||||||
// const resampleConfig = getContext("resampling");
|
const fixedLineColors = ["#0000ff", "#ff0000"]; // Plot only uses 2 Datasets: High Contrast
|
||||||
// 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 renderSleepTime = 200;
|
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 */
|
/* Var Init */
|
||||||
let timeoutId = null;
|
let timeoutId = null;
|
||||||
|
|
||||||
/* State Init */
|
/* State Init */
|
||||||
let plotWrapper = $state(null);
|
let plotWrapper = $state(null);
|
||||||
|
let width = $state(0); // Wrapper Width
|
||||||
let uplot = $state(null);
|
let uplot = $state(null);
|
||||||
|
|
||||||
/* Derived */
|
/* 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 maxX = $derived(longestSeries * timestep);
|
||||||
// const maxY = $derived.by(() => {
|
const lineWidth = $derived(publicMode ? 2 : clusterCockpitConfig.plotConfiguration_lineWidth / window.devicePixelRatio);
|
||||||
// let pendingY = 0;
|
const longestSeries = $derived.by(() => {
|
||||||
// // if (useStatsSeries) {
|
return metricData.reduce((n, m) => Math.max(n, m.data.length), 0);
|
||||||
// // 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;
|
|
||||||
// // }
|
|
||||||
|
|
||||||
// if (pendingY >= 10 * thresholds.peak) {
|
// Derive Plot Params
|
||||||
// // Hard y-range render limit if outliers in series data
|
let plotData = $derived.by(() => {
|
||||||
// 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(() => {
|
|
||||||
let pendingData = [new Array(longestSeries)];
|
let pendingData = [new Array(longestSeries)];
|
||||||
// X
|
// X
|
||||||
if (forNode === true) {
|
if (forNode === true) {
|
||||||
@@ -135,25 +68,15 @@
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
// Y
|
// 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++) {
|
for (let i = 0; i < metricData.length; i++) {
|
||||||
pendingData.push(metricData[i]?.data);
|
pendingData.push(metricData[i]?.data);
|
||||||
};
|
};
|
||||||
// };
|
|
||||||
return pendingData;
|
return pendingData;
|
||||||
})
|
})
|
||||||
const plotSeries = $derived.by(() => {
|
|
||||||
|
let plotSeries = $derived.by(() => {
|
||||||
|
// X
|
||||||
let pendingSeries = [
|
let pendingSeries = [
|
||||||
// Note: X-Legend Will not be shown as soon as Y-Axis are in extendedMode
|
|
||||||
{
|
{
|
||||||
label: "Runtime",
|
label: "Runtime",
|
||||||
value: (u, ts, sidx, didx) =>
|
value: (u, ts, sidx, didx) =>
|
||||||
@@ -161,87 +84,108 @@
|
|||||||
}
|
}
|
||||||
];
|
];
|
||||||
// Y
|
// 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++) {
|
for (let i = 0; i < metricData.length; i++) {
|
||||||
// Default
|
|
||||||
// if (!extendedLegendData) {
|
|
||||||
pendingSeries.push({
|
pendingSeries.push({
|
||||||
label: `${metricData[i]?.name} (${metricData[i]?.unit?.prefix}${metricData[i]?.unit?.base})`,
|
label: publicMode ? null : `${metricData[i]?.name} (${metricData[i]?.unit?.prefix}${metricData[i]?.unit?.base})`,
|
||||||
scale: `y${i+1}`,
|
scale: `y${i+1}`,
|
||||||
width: lineWidth,
|
width: lineWidth,
|
||||||
stroke: lineColor(i, metricData.length),
|
stroke: fixedLineColors[i],
|
||||||
});
|
});
|
||||||
// }
|
|
||||||
// Extended Legend For NodeList
|
|
||||||
// else {
|
|
||||||
// pendingSeries.push({
|
|
||||||
// label:
|
|
||||||
// scope === "node"
|
|
||||||
// ? series[i].hostname
|
|
||||||
// : scope === "accelerator"
|
|
||||||
// ? 'Acc #' + (i + 1) // series[i].id.slice(9, 14) | Too Hardware Specific
|
|
||||||
// : scope + " #" + (i + 1),
|
|
||||||
// scale: "y",
|
|
||||||
// width: lineWidth,
|
|
||||||
// stroke: lineColor(i, series?.length),
|
|
||||||
// values: (u, sidx, idx) => {
|
|
||||||
// // "i" = "sidx - 1" : sidx contains x-axis-data
|
|
||||||
// if (idx == null)
|
|
||||||
// return {
|
|
||||||
// time: '-',
|
|
||||||
// value: '-',
|
|
||||||
// user: '-',
|
|
||||||
// job: '-'
|
|
||||||
// };
|
|
||||||
|
|
||||||
// if (series[i].id in extendedLegendData) {
|
|
||||||
// return {
|
|
||||||
// time: formatDurationTime(plotData[0][idx], forNode),
|
|
||||||
// value: plotData[sidx][idx],
|
|
||||||
// user: extendedLegendData[series[i].id].user,
|
|
||||||
// job: extendedLegendData[series[i].id].job,
|
|
||||||
// };
|
|
||||||
// } else {
|
|
||||||
// return {
|
|
||||||
// time: formatDurationTime(plotData[0][idx], forNode),
|
|
||||||
// value: plotData[sidx][idx],
|
|
||||||
// user: '-',
|
|
||||||
// job: '-',
|
|
||||||
// };
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
// });
|
|
||||||
// }
|
|
||||||
// };
|
|
||||||
};
|
};
|
||||||
return pendingSeries;
|
return pendingSeries;
|
||||||
})
|
})
|
||||||
|
|
||||||
/* Effects */
|
// Set Options
|
||||||
// $effect(() => {
|
function getOpts(optWidth, optHeight) {
|
||||||
// if (!useStatsSeries && statisticsSeries != null) useStatsSeries = true;
|
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
|
// This updates plot on all size changes if wrapper (== data) exists
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (plotWrapper) {
|
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
|
// UPLOT PLUGIN // converts the legend into a simple tooltip
|
||||||
function legendAsTooltipPlugin({
|
function legendAsTooltipPlugin({
|
||||||
className,
|
className,
|
||||||
@@ -408,219 +285,22 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// RETURN BG COLOR FROM THRESHOLD
|
function onSizeChange(chgWidth, chgHeight) {
|
||||||
// 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;
|
|
||||||
if (timeoutId != null) clearTimeout(timeoutId);
|
if (timeoutId != null) clearTimeout(timeoutId);
|
||||||
timeoutId = setTimeout(() => {
|
timeoutId = setTimeout(() => {
|
||||||
timeoutId = null;
|
timeoutId = null;
|
||||||
render(chg_width, chg_height);
|
render(chgWidth, chgHeight);
|
||||||
}, renderSleepTime);
|
}, renderSleepTime);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* On Mount */
|
function render(renWidth, renHeight) {
|
||||||
onMount(() => {
|
if (!uplot) {
|
||||||
if (plotWrapper) {
|
let opts = getOpts(renWidth, renHeight);
|
||||||
render(width, height);
|
uplot = new uPlot(opts, plotData, plotWrapper);
|
||||||
|
} else {
|
||||||
|
uplot.setSize({ width: renWidth, height: renHeight });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
|
||||||
|
|
||||||
/* On Destroy */
|
/* On Destroy */
|
||||||
onDestroy(() => {
|
onDestroy(() => {
|
||||||
@@ -635,8 +315,12 @@
|
|||||||
<div bind:this={plotWrapper} bind:clientWidth={width}
|
<div bind:this={plotWrapper} bind:clientWidth={width}
|
||||||
class={forNode ? 'py-2 rounded' : 'rounded'}
|
class={forNode ? 'py-2 rounded' : 'rounded'}
|
||||||
></div>
|
></div>
|
||||||
|
{:else if cluster}
|
||||||
|
<Card body color="warning" class="mx-4"
|
||||||
|
>Cannot render plot: No series data returned for <code>{cluster}</code>.</Card
|
||||||
|
>
|
||||||
{:else}
|
{:else}
|
||||||
<Card body color="warning" class="mx-4"
|
<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}
|
{/if}
|
||||||
|
|||||||
@@ -56,9 +56,6 @@
|
|||||||
/* Const Init */
|
/* Const Init */
|
||||||
const clusterCockpitConfig = getContext("cc-config");
|
const clusterCockpitConfig = getContext("cc-config");
|
||||||
const resampleConfig = getContext("resampling");
|
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 lineWidth = clusterCockpitConfig.plotConfiguration_lineWidth / window.devicePixelRatio;
|
||||||
const cbmode = clusterCockpitConfig?.plotConfiguration_colorblindMode || false;
|
const cbmode = clusterCockpitConfig?.plotConfiguration_colorblindMode || false;
|
||||||
const renderSleepTime = 200;
|
const renderSleepTime = 200;
|
||||||
@@ -77,6 +74,8 @@
|
|||||||
let uplot = $state(null);
|
let uplot = $state(null);
|
||||||
|
|
||||||
/* Derived */
|
/* 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 usesMeanStatsSeries = $derived((statisticsSeries?.mean && statisticsSeries.mean.length != 0));
|
||||||
const resampleTrigger = $derived(resampleConfig?.trigger ? Number(resampleConfig.trigger) : null);
|
const resampleTrigger = $derived(resampleConfig?.trigger ? Number(resampleConfig.trigger) : null);
|
||||||
const resampleResolutions = $derived(resampleConfig?.resolutions ? [...resampleConfig.resolutions] : null);
|
const resampleResolutions = $derived(resampleConfig?.resolutions ? [...resampleConfig.resolutions] : null);
|
||||||
@@ -200,7 +199,7 @@
|
|||||||
: scope + " #" + (i + 1),
|
: scope + " #" + (i + 1),
|
||||||
scale: "y",
|
scale: "y",
|
||||||
width: lineWidth,
|
width: lineWidth,
|
||||||
stroke: lineColor(i, series?.length),
|
stroke: lineColor(i, clusterCockpitConfig.plotConfiguration_colorScheme),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
// Extended Legend For NodeList
|
// Extended Legend For NodeList
|
||||||
@@ -214,7 +213,7 @@
|
|||||||
: scope + " #" + (i + 1),
|
: scope + " #" + (i + 1),
|
||||||
scale: "y",
|
scale: "y",
|
||||||
width: lineWidth,
|
width: lineWidth,
|
||||||
stroke: lineColor(i, series?.length),
|
stroke: lineColor(i, clusterCockpitConfig.plotConfiguration_colorScheme),
|
||||||
values: (u, sidx, idx) => {
|
values: (u, sidx, idx) => {
|
||||||
// "i" = "sidx - 1" : sidx contains x-axis-data
|
// "i" = "sidx - 1" : sidx contains x-axis-data
|
||||||
if (idx == null)
|
if (idx == null)
|
||||||
@@ -446,9 +445,8 @@
|
|||||||
return backgroundColors.normal;
|
return backgroundColors.normal;
|
||||||
}
|
}
|
||||||
|
|
||||||
function lineColor(i, n) {
|
function lineColor(index, colors) {
|
||||||
if (n && n >= lineColors.length) return lineColors[i % lineColors.length];
|
return colors[index % colors.length];
|
||||||
else return lineColors[Math.floor((i / n) * lineColors.length)];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function render(ren_width, ren_height) {
|
function render(ren_width, ren_height) {
|
||||||
|
|||||||
@@ -95,6 +95,7 @@
|
|||||||
animation: false,
|
animation: false,
|
||||||
plugins: {
|
plugins: {
|
||||||
legend: {
|
legend: {
|
||||||
|
// svelte-ignore state_referenced_locally
|
||||||
display: displayLegend
|
display: displayLegend
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
- `polarMetrics [Object]?`: Metric names and scaled peak values for rendering polar plot [Default: [] ]
|
- `polarMetrics [Object]?`: Metric names and scaled peak values for rendering polar plot [Default: [] ]
|
||||||
- `polarData [GraphQL.JobMetricStatWithName]?`: Metric data [Default: null]
|
- `polarData [GraphQL.JobMetricStatWithName]?`: Metric data [Default: null]
|
||||||
- `canvasId String?`: Unique ID for correct parallel chart.js rendering [Default: "polar-default"]
|
- `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>
|
<script>
|
||||||
@@ -38,22 +38,28 @@
|
|||||||
polarMetrics = [],
|
polarMetrics = [],
|
||||||
polarData = [],
|
polarData = [],
|
||||||
canvasId = "polar-default",
|
canvasId = "polar-default",
|
||||||
height = 350,
|
size = 375,
|
||||||
|
showLegend = true,
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
/* Const Init */
|
/* Derived */
|
||||||
const options = {
|
const options = $derived({
|
||||||
maintainAspectRatio: true,
|
responsive: true, // Default
|
||||||
|
maintainAspectRatio: true, // Default
|
||||||
animation: false,
|
animation: false,
|
||||||
scales: { // fix scale
|
scales: { // fix scale
|
||||||
r: {
|
r: {
|
||||||
suggestedMin: 0.0,
|
suggestedMin: 0.0,
|
||||||
suggestedMax: 1.0
|
suggestedMax: 1.0
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
plugins: {
|
||||||
|
legend: {
|
||||||
|
display: showLegend
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
})
|
||||||
|
|
||||||
/* Derived */
|
|
||||||
const labels = $derived(polarMetrics
|
const labels = $derived(polarMetrics
|
||||||
.filter((m) => (m.peak != null))
|
.filter((m) => (m.peak != null))
|
||||||
.map(pm => pm.name)
|
.map(pm => pm.name)
|
||||||
@@ -77,7 +83,7 @@
|
|||||||
{
|
{
|
||||||
label: 'Avg',
|
label: 'Avg',
|
||||||
data: loadData('avg'), // Node Scope Only
|
data: loadData('avg'), // Node Scope Only
|
||||||
fill: 2,
|
fill: true, // fill: 2 if min active
|
||||||
backgroundColor: 'rgba(255, 210, 0, 0.25)',
|
backgroundColor: 'rgba(255, 210, 0, 0.25)',
|
||||||
borderColor: 'rgb(255, 210, 0)',
|
borderColor: 'rgb(255, 210, 0)',
|
||||||
pointBackgroundColor: 'rgb(255, 210, 0)',
|
pointBackgroundColor: 'rgb(255, 210, 0)',
|
||||||
@@ -85,17 +91,17 @@
|
|||||||
pointHoverBackgroundColor: '#fff',
|
pointHoverBackgroundColor: '#fff',
|
||||||
pointHoverBorderColor: 'rgb(255, 210, 0)'
|
pointHoverBorderColor: 'rgb(255, 210, 0)'
|
||||||
},
|
},
|
||||||
{
|
// {
|
||||||
label: 'Min',
|
// label: 'Min',
|
||||||
data: loadData('min'), // Node Scope Only
|
// data: loadData('min'), // Node Scope Only
|
||||||
fill: true,
|
// fill: true,
|
||||||
backgroundColor: 'rgba(255, 0, 0, 0.25)',
|
// backgroundColor: 'rgba(255, 0, 0, 0.25)',
|
||||||
borderColor: 'rgb(255, 0, 0)',
|
// borderColor: 'rgb(255, 0, 0)',
|
||||||
pointBackgroundColor: 'rgb(255, 0, 0)',
|
// pointBackgroundColor: 'rgb(255, 0, 0)',
|
||||||
pointBorderColor: '#fff',
|
// pointBorderColor: '#fff',
|
||||||
pointHoverBackgroundColor: '#fff',
|
// pointHoverBackgroundColor: '#fff',
|
||||||
pointHoverBorderColor: 'rgb(255, 0, 0)'
|
// pointHoverBorderColor: 'rgb(255, 0, 0)'
|
||||||
}
|
// }
|
||||||
]
|
]
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -127,15 +133,14 @@
|
|||||||
{
|
{
|
||||||
type: 'radar',
|
type: 'radar',
|
||||||
data: data,
|
data: data,
|
||||||
options: options,
|
options: options
|
||||||
height: height
|
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<!-- <div style="width: 500px;"><canvas id="dimensions"></canvas></div><br/> -->
|
<!-- <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>
|
<canvas id={canvasId}></canvas>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -143,5 +148,7 @@
|
|||||||
.chart-container {
|
.chart-container {
|
||||||
margin: auto;
|
margin: auto;
|
||||||
position: relative;
|
position: relative;
|
||||||
|
height: var(--container-height);
|
||||||
|
width: var(--container-width);
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -40,8 +40,7 @@
|
|||||||
useColors = true,
|
useColors = true,
|
||||||
useLegend = true,
|
useLegend = true,
|
||||||
colorBackground = false,
|
colorBackground = false,
|
||||||
width = 600,
|
height = 300,
|
||||||
height = 380,
|
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
/* Const Init */
|
/* Const Init */
|
||||||
@@ -53,11 +52,12 @@
|
|||||||
|
|
||||||
/* State Init */
|
/* State Init */
|
||||||
let plotWrapper = $state(null);
|
let plotWrapper = $state(null);
|
||||||
|
let width = $state(0); // Wrapper Width
|
||||||
let uplot = $state(null);
|
let uplot = $state(null);
|
||||||
|
|
||||||
/* Effect */
|
/* Effect */
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (allowSizeChange) sizeChanged(width, height);
|
if (allowSizeChange) onSizeChange(width, height);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Copied Example Vars for Uplot Bubble
|
// Copied Example Vars for Uplot Bubble
|
||||||
@@ -517,11 +517,11 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Main Functions
|
// Main Functions
|
||||||
function sizeChanged() {
|
function onSizeChange() {
|
||||||
if (timeoutId != null) clearTimeout(timeoutId);
|
if (timeoutId != null) clearTimeout(timeoutId);
|
||||||
timeoutId = setTimeout(() => {
|
timeoutId = setTimeout(() => {
|
||||||
timeoutId = null;
|
timeoutId = null;
|
||||||
if (uplot) uplot.destroy();
|
if (uplot) uplot.destroy(); // Prevents Multi-Render
|
||||||
render(roofData, jobsData, nodesData);
|
render(roofData, jobsData, nodesData);
|
||||||
}, 200);
|
}, 200);
|
||||||
}
|
}
|
||||||
@@ -995,7 +995,7 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if roofData != null}
|
{#if roofData != null}
|
||||||
<div bind:this={plotWrapper} class="p-2"></div>
|
<div bind:this={plotWrapper} bind:clientWidth={width} class="p-2"></div>
|
||||||
{:else}
|
{:else}
|
||||||
<Card class="mx-4 my-2" body color="warning">Cannot render roofline: No data!</Card>
|
<Card class="mx-4 my-2" body color="warning">Cannot render roofline: No data!</Card>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -23,9 +23,6 @@
|
|||||||
height = 300,
|
height = 300,
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
/* Check Before */
|
|
||||||
console.assert(tiles, "you must provide tiles!")
|
|
||||||
|
|
||||||
/* Const Init */
|
/* Const Init */
|
||||||
const axesColor = '#aaaaaa';
|
const axesColor = '#aaaaaa';
|
||||||
const tickFontSize = 10;
|
const tickFontSize = 10;
|
||||||
@@ -42,10 +39,10 @@
|
|||||||
/* State Init */
|
/* State Init */
|
||||||
let ctx = $state();
|
let ctx = $state();
|
||||||
let canvasElement = $state();
|
let canvasElement = $state();
|
||||||
let prevWidth = $state(width);
|
|
||||||
let prevHeight = $state(height);
|
|
||||||
|
|
||||||
/* Derived */
|
/* Derived */
|
||||||
|
let prevWidth = $derived(width);
|
||||||
|
let prevHeight = $derived(height);
|
||||||
const data = $derived({
|
const data = $derived({
|
||||||
tiles: tiles,
|
tiles: tiles,
|
||||||
xLabel: 'Intensity [FLOPS/byte]',
|
xLabel: 'Intensity [FLOPS/byte]',
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
@component Node State/Health Data Stacked Plot Component, based on uPlot; states by timestamp
|
@component Node State/Health Data Stacked Plot Component, based on uPlot; states by timestamp
|
||||||
|
|
||||||
Properties:
|
Properties:
|
||||||
- `width Number?`: The plot width [Default: 0]
|
|
||||||
- `height Number?`: The plot height [Default: 300]
|
- `height Number?`: The plot height [Default: 300]
|
||||||
- `data [Array]`: The data object [Default: null]
|
- `data [Array]`: The data object [Default: null]
|
||||||
- `xlabel String?`: Plot X axis label [Default: ""]
|
- `xlabel String?`: Plot X axis label [Default: ""]
|
||||||
@@ -15,12 +14,11 @@
|
|||||||
<script>
|
<script>
|
||||||
import uPlot from "uplot";
|
import uPlot from "uplot";
|
||||||
import { formatUnixTime } from "../units.js";
|
import { formatUnixTime } from "../units.js";
|
||||||
import { getContext, onMount, onDestroy } from "svelte";
|
import { getContext, onDestroy } from "svelte";
|
||||||
import { Card } from "@sveltestrap/sveltestrap";
|
import { Card } from "@sveltestrap/sveltestrap";
|
||||||
|
|
||||||
/* Svelte 5 Props */
|
/* Svelte 5 Props */
|
||||||
let {
|
let {
|
||||||
width = 0,
|
|
||||||
height = 300,
|
height = 300,
|
||||||
data = null,
|
data = null,
|
||||||
xlabel = 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 //
|
// STACKED CHART FUNCTIONS //
|
||||||
function stack(data, omit) {
|
function stack(data, omit) {
|
||||||
let data2 = [];
|
let data2 = [];
|
||||||
@@ -135,17 +127,17 @@
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function getStackedOpts(title, width, height, series, data) {
|
function getStackedOpts(optTitle, optWidth, optHeight, optSeries, optData) {
|
||||||
let opts = {
|
let opts = {
|
||||||
width,
|
width: optWidth,
|
||||||
height,
|
height: optHeight,
|
||||||
title,
|
title: optTitle,
|
||||||
plugins: [legendAsTooltipPlugin()],
|
plugins: [legendAsTooltipPlugin()],
|
||||||
series,
|
series: optSeries,
|
||||||
axes: [
|
axes: [
|
||||||
{
|
{
|
||||||
scale: "x",
|
scale: "x",
|
||||||
space: 25, // Tick Spacing
|
// space: 25, // Tick Spacing
|
||||||
rotate: 30,
|
rotate: 30,
|
||||||
show: true,
|
show: true,
|
||||||
label: xlabel,
|
label: xlabel,
|
||||||
@@ -174,25 +166,25 @@
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
let stacked = stack(data, i => false);
|
let stacked = stack(optData, i => false);
|
||||||
opts.bands = stacked.bands;
|
opts.bands = stacked.bands;
|
||||||
|
|
||||||
opts.cursor = opts.cursor || {};
|
opts.cursor = opts.cursor || {};
|
||||||
opts.cursor.dataIdx = (u, seriesIdx, closestIdx, xValue) => {
|
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 => {
|
opts.series.forEach(s => {
|
||||||
// Format Time Info from Unix TS to LocalTimeString
|
// 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 || {};
|
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) => {
|
s.points.filter = (u, seriesIdx, show, gaps) => {
|
||||||
if (show) {
|
if (show) {
|
||||||
let pts = [];
|
let pts = [];
|
||||||
data[seriesIdx].forEach((v, i) => {
|
optData[seriesIdx].forEach((v, i) => {
|
||||||
v != null && pts.push(i);
|
v != null && pts.push(i);
|
||||||
});
|
});
|
||||||
return pts;
|
return pts;
|
||||||
@@ -212,7 +204,7 @@
|
|||||||
opts.hooks = {
|
opts.hooks = {
|
||||||
setSeries: [
|
setSeries: [
|
||||||
(u, i) => {
|
(u, i) => {
|
||||||
let stacked = stack(data, i => !u.series[i].show);
|
let stacked = stack(optData, i => !u.series[i].show);
|
||||||
u.delBand(null);
|
u.delBand(null);
|
||||||
stacked.bands.forEach(b => u.addBand(b));
|
stacked.bands.forEach(b => u.addBand(b));
|
||||||
u.setData(stacked.data);
|
u.setData(stacked.data);
|
||||||
@@ -287,21 +279,28 @@
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// UPLOT SERIES INIT
|
// UPLOT SERIES INIT: DERIVED FROM PROPS
|
||||||
const plotSeries = [
|
// Data Prep For uPlot
|
||||||
{
|
const sortedData = $derived(data?.sort((a, b) => a.state.localeCompare(b.state)) || []);
|
||||||
label: "Time",
|
const collectLabel = $derived(sortedData.map(d => d.state));
|
||||||
scale: "x"
|
// Align Data to Timesteps, Introduces 'undefied' as placeholder, reiterate and set those to 0
|
||||||
},
|
const collectData = $derived.by(() => {
|
||||||
...collectLabel.map(l => seriesConfig[l])
|
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 */
|
/* Var Init */
|
||||||
let timeoutId = null;
|
let timeoutId = null;
|
||||||
let uplot = null;
|
|
||||||
|
|
||||||
/* State Init */
|
/* State Init */
|
||||||
let plotWrapper = $state(null);
|
let plotWrapper = $state(null);
|
||||||
|
let width = $state(0); // Wrapper Width
|
||||||
|
let uplot = $state(null);
|
||||||
|
|
||||||
/* Effects */
|
/* Effects */
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
@@ -311,6 +310,14 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
/* Functions */
|
/* 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) {
|
function render(ren_width, ren_height) {
|
||||||
if (!uplot) {
|
if (!uplot) {
|
||||||
let { opts, data } = getStackedOpts(title, ren_width, ren_height, plotSeries, collectData);
|
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 */
|
/* On Destroy */
|
||||||
onDestroy(() => {
|
onDestroy(() => {
|
||||||
if (timeoutId != null) clearTimeout(timeoutId);
|
if (timeoutId != null) clearTimeout(timeoutId);
|
||||||
|
|||||||
@@ -27,14 +27,16 @@
|
|||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
/* State Init */
|
/* 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 leftHandle = $state();
|
||||||
let sliderMain = $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 */
|
/* Var Init */
|
||||||
let timeoutId = null;
|
let timeoutId = null;
|
||||||
|
|
||||||
|
|||||||
@@ -41,11 +41,11 @@
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
/* State Init */
|
/* State Init */
|
||||||
let sorting = $state({...presetSorting})
|
|
||||||
let activeColumnIdx = $state(0);
|
let activeColumnIdx = $state(0);
|
||||||
let metricSortables = $state([]);
|
let metricSortables = $state([]);
|
||||||
|
|
||||||
/* Derived */
|
/* Derived */
|
||||||
|
let sorting = $derived({...presetSorting})
|
||||||
let sortableColumns = $derived([...fixedSortables, ...metricSortables]);
|
let sortableColumns = $derived([...fixedSortables, ...metricSortables]);
|
||||||
|
|
||||||
/* Effect */
|
/* Effect */
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ export function formatUnixTime(t, withDate = false) {
|
|||||||
return t;
|
return t;
|
||||||
} else {
|
} else {
|
||||||
if (withDate) return new Date(t * 1000).toLocaleString();
|
if (withDate) return new Date(t * 1000).toLocaleString();
|
||||||
else return new Date(t * 1000).toLocaleTimeString();
|
else return new Date(t * 1000).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,19 +29,30 @@
|
|||||||
const showPolarTab = !!getContext("cc-config")[`jobView_showPolarPlot`];
|
const showPolarTab = !!getContext("cc-config")[`jobView_showPolarPlot`];
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Card class="overflow-auto" style="width: {width}; height: {height}">
|
<Card style="width: {width}; height: {height}">
|
||||||
|
{#if showFootprintTab && !showPolarTab}
|
||||||
|
<JobFootprintBars {job} />
|
||||||
|
{:else if !showFootprintTab && showPolarTab}
|
||||||
|
<JobFootprintPolar {job}/>
|
||||||
|
{:else if showFootprintTab && showPolarTab}
|
||||||
<TabContent>
|
<TabContent>
|
||||||
{#if showFootprintTab}
|
<TabPane tabId="foot" tab="Footprint" active>
|
||||||
<TabPane tabId="foot" tab="Footprint" active={showFootprintTab}>
|
|
||||||
<!-- Bars CardBody Here-->
|
<!-- Bars CardBody Here-->
|
||||||
<JobFootprintBars {job} />
|
<JobFootprintBars {job} />
|
||||||
</TabPane>
|
</TabPane>
|
||||||
{/if}
|
<TabPane tabId="polar" tab="Polar">
|
||||||
{#if showPolarTab}
|
|
||||||
<TabPane tabId="polar" tab="Polar" active={showPolarTab && !showFootprintTab}>
|
|
||||||
<!-- Polar Plot CardBody Here -->
|
<!-- Polar Plot CardBody Here -->
|
||||||
<JobFootprintPolar {job} />
|
<JobFootprintPolar {job} showLegend={false}/>
|
||||||
</TabPane>
|
</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>
|
</Card>
|
||||||
|
|||||||
@@ -45,7 +45,6 @@
|
|||||||
const statsPattern = /(.*)-stat$/;
|
const statsPattern = /(.*)-stat$/;
|
||||||
const resampleConfig = getContext("resampling") || null;
|
const resampleConfig = getContext("resampling") || null;
|
||||||
const resampleDefault = resampleConfig ? Math.max(...resampleConfig.resolutions) : 0;
|
const resampleDefault = resampleConfig ? Math.max(...resampleConfig.resolutions) : 0;
|
||||||
const unit = (metricUnit?.prefix ? metricUnit.prefix : "") + (metricUnit?.base ? metricUnit.base : "");
|
|
||||||
const subQuery = gql`
|
const subQuery = gql`
|
||||||
query ($dbid: ID!, $selectedMetrics: [String!]!, $selectedScopes: [MetricScope!]!, $selectedResolution: Int) {
|
query ($dbid: ID!, $selectedMetrics: [String!]!, $selectedScopes: [MetricScope!]!, $selectedResolution: Int) {
|
||||||
singleUpdate: jobMetrics(id: $dbid, metrics: $selectedMetrics, scopes: $selectedScopes, resolution: $selectedResolution) {
|
singleUpdate: jobMetrics(id: $dbid, metrics: $selectedMetrics, scopes: $selectedScopes, resolution: $selectedResolution) {
|
||||||
@@ -79,14 +78,14 @@
|
|||||||
`;
|
`;
|
||||||
|
|
||||||
/* State Init */
|
/* State Init */
|
||||||
let requestedScopes = $state(presetScopes);
|
|
||||||
let selectedResolution = $state(resampleDefault);
|
let selectedResolution = $state(resampleDefault);
|
||||||
|
|
||||||
let selectedHost = $state(null);
|
let selectedHost = $state(null);
|
||||||
let zoomState = $state(null);
|
let zoomState = $state(null);
|
||||||
let thresholdState = $state(null);
|
let thresholdState = $state(null);
|
||||||
|
|
||||||
/* Derived */
|
/* Derived */
|
||||||
|
let requestedScopes = $derived(presetScopes);
|
||||||
|
const unit = $derived.by(() => { return (metricUnit?.prefix ? metricUnit.prefix : "") + (metricUnit?.base ? metricUnit.base : "")});
|
||||||
const metricData = $derived(queryStore({
|
const metricData = $derived(queryStore({
|
||||||
client: client,
|
client: client,
|
||||||
query: subQuery,
|
query: subQuery,
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user