mirror of
https://github.com/ClusterCockpit/cc-backend
synced 2026-07-08 17:10:37 +02:00
@@ -27,3 +27,4 @@ test_ccms_write_api.sh
|
|||||||
/.vscode/*
|
/.vscode/*
|
||||||
dist/
|
dist/
|
||||||
*.db
|
*.db
|
||||||
|
.idea
|
||||||
|
|||||||
+5
-4
@@ -28,9 +28,8 @@ type NodeStates {
|
|||||||
|
|
||||||
type NodeStatesTimed {
|
type NodeStatesTimed {
|
||||||
state: String!
|
state: String!
|
||||||
type: String!
|
counts: [Int!]!
|
||||||
count: Int!
|
times: [Int!]!
|
||||||
time: Int!
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type Job {
|
type Job {
|
||||||
@@ -263,6 +262,7 @@ enum SortByAggregate {
|
|||||||
|
|
||||||
type NodeMetrics {
|
type NodeMetrics {
|
||||||
host: String!
|
host: String!
|
||||||
|
state: String!
|
||||||
subCluster: String!
|
subCluster: String!
|
||||||
metrics: [JobMetricWithName!]!
|
metrics: [JobMetricWithName!]!
|
||||||
}
|
}
|
||||||
@@ -317,7 +317,7 @@ type Query {
|
|||||||
node(id: ID!): Node
|
node(id: ID!): Node
|
||||||
nodes(filter: [NodeFilter!], order: OrderByInput): NodeStateResultList!
|
nodes(filter: [NodeFilter!], order: OrderByInput): NodeStateResultList!
|
||||||
nodeStates(filter: [NodeFilter!]): [NodeStates!]!
|
nodeStates(filter: [NodeFilter!]): [NodeStates!]!
|
||||||
nodeStatesTimed(filter: [NodeFilter!]): [NodeStatesTimed!]!
|
nodeStatesTimed(filter: [NodeFilter!], type: String!): [NodeStatesTimed!]!
|
||||||
|
|
||||||
job(id: ID!): Job
|
job(id: ID!): Job
|
||||||
jobMetrics(
|
jobMetrics(
|
||||||
@@ -376,6 +376,7 @@ type Query {
|
|||||||
nodeMetricsList(
|
nodeMetricsList(
|
||||||
cluster: String!
|
cluster: String!
|
||||||
subCluster: String!
|
subCluster: String!
|
||||||
|
stateFilter: String!
|
||||||
nodeFilter: String!
|
nodeFilter: String!
|
||||||
scopes: [MetricScope!]
|
scopes: [MetricScope!]
|
||||||
metrics: [String!]
|
metrics: [String!]
|
||||||
|
|||||||
+1
-1
@@ -1416,7 +1416,7 @@
|
|||||||
"deadline",
|
"deadline",
|
||||||
"failed",
|
"failed",
|
||||||
"node_fail",
|
"node_fail",
|
||||||
"out-of-memory",
|
"out_of_memory",
|
||||||
"pending",
|
"pending",
|
||||||
"preempted",
|
"preempted",
|
||||||
"running",
|
"running",
|
||||||
|
|||||||
+1
-1
@@ -227,7 +227,7 @@ definitions:
|
|||||||
- deadline
|
- deadline
|
||||||
- failed
|
- failed
|
||||||
- node_fail
|
- node_fail
|
||||||
- out-of-memory
|
- out_of_memory
|
||||||
- pending
|
- pending
|
||||||
- preempted
|
- preempted
|
||||||
- running
|
- running
|
||||||
|
|||||||
@@ -2,6 +2,9 @@
|
|||||||
// All rights reserved. This file is part of cc-backend.
|
// All rights reserved. This file is part of cc-backend.
|
||||||
// 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.
|
||||||
|
|
||||||
|
// Package main provides the entry point for the ClusterCockpit backend server.
|
||||||
|
// This file defines all command-line flags and their default values.
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import "flag"
|
import "flag"
|
||||||
|
|||||||
@@ -2,6 +2,10 @@
|
|||||||
// All rights reserved. This file is part of cc-backend.
|
// All rights reserved. This file is part of cc-backend.
|
||||||
// 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.
|
||||||
|
|
||||||
|
// Package main provides the entry point for the ClusterCockpit backend server.
|
||||||
|
// This file contains bootstrap logic for initializing the environment,
|
||||||
|
// creating default configuration files, and setting up the database.
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
|||||||
+309
-124
@@ -2,9 +2,14 @@
|
|||||||
// All rights reserved. This file is part of cc-backend.
|
// All rights reserved. This file is part of cc-backend.
|
||||||
// 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.
|
||||||
|
|
||||||
|
// Package main provides the entry point for the ClusterCockpit backend server.
|
||||||
|
// It orchestrates initialization of all subsystems including configuration,
|
||||||
|
// database, authentication, and the HTTP server.
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
@@ -13,6 +18,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"syscall"
|
"syscall"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/ClusterCockpit/cc-backend/internal/archiver"
|
"github.com/ClusterCockpit/cc-backend/internal/archiver"
|
||||||
"github.com/ClusterCockpit/cc-backend/internal/auth"
|
"github.com/ClusterCockpit/cc-backend/internal/auth"
|
||||||
@@ -46,90 +52,108 @@ const logoString = `
|
|||||||
|_|
|
|_|
|
||||||
`
|
`
|
||||||
|
|
||||||
|
// Environment variable names
|
||||||
|
const (
|
||||||
|
envGOGC = "GOGC"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Default configurations
|
||||||
|
const (
|
||||||
|
defaultArchiveConfig = `{"kind":"file","path":"./var/job-archive"}`
|
||||||
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
date string
|
date string
|
||||||
commit string
|
commit string
|
||||||
version string
|
version string
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func printVersion() {
|
||||||
cliInit()
|
fmt.Print(logoString)
|
||||||
|
fmt.Printf("Version:\t%s\n", version)
|
||||||
|
fmt.Printf("Git hash:\t%s\n", commit)
|
||||||
|
fmt.Printf("Build time:\t%s\n", date)
|
||||||
|
fmt.Printf("SQL db version:\t%d\n", repository.Version)
|
||||||
|
fmt.Printf("Job archive version:\t%d\n", archive.Version)
|
||||||
|
}
|
||||||
|
|
||||||
if flagVersion {
|
func initGops() error {
|
||||||
fmt.Print(logoString)
|
if !flagGops {
|
||||||
fmt.Printf("Version:\t%s\n", version)
|
return nil
|
||||||
fmt.Printf("Git hash:\t%s\n", commit)
|
|
||||||
fmt.Printf("Build time:\t%s\n", date)
|
|
||||||
fmt.Printf("SQL db version:\t%d\n", repository.Version)
|
|
||||||
fmt.Printf("Job archive version:\t%d\n", archive.Version)
|
|
||||||
os.Exit(0)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
cclog.Init(flagLogLevel, flagLogDateTime)
|
if err := agent.Listen(agent.Options{}); err != nil {
|
||||||
|
return fmt.Errorf("starting gops agent: %w", err)
|
||||||
// If init flag set, run tasks here before any file dependencies cause errors
|
|
||||||
if flagInit {
|
|
||||||
initEnv()
|
|
||||||
cclog.Exit("Successfully setup environment!\n" +
|
|
||||||
"Please review config.json and .env and adjust it to your needs.\n" +
|
|
||||||
"Add your job-archive at ./var/job-archive.")
|
|
||||||
}
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// See https://github.com/google/gops (Runtime overhead is almost zero)
|
func loadEnvironment() error {
|
||||||
if flagGops {
|
if err := godotenv.Load(); err != nil {
|
||||||
if err := agent.Listen(agent.Options{}); err != nil {
|
return fmt.Errorf("loading .env file: %w", err)
|
||||||
cclog.Abortf("Could not start gops agent with 'gops/agent.Listen(agent.Options{})'. Application startup failed, exited.\nError: %s\n", err.Error())
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
err := godotenv.Load()
|
func initConfiguration() error {
|
||||||
if err != nil {
|
|
||||||
cclog.Abortf("Could not parse existing .env file at location './.env'. Application startup failed, exited.\nError: %s\n", err.Error())
|
|
||||||
}
|
|
||||||
|
|
||||||
// Initialize sub-modules and handle command line flags.
|
|
||||||
// The order here is important!
|
|
||||||
ccconf.Init(flagConfigFile)
|
ccconf.Init(flagConfigFile)
|
||||||
|
|
||||||
// Load and check main configuration
|
cfg := ccconf.GetPackageConfig("main")
|
||||||
if cfg := ccconf.GetPackageConfig("main"); cfg != nil {
|
if cfg == nil {
|
||||||
if clustercfg := ccconf.GetPackageConfig("clusters"); clustercfg != nil {
|
return fmt.Errorf("main configuration must be present")
|
||||||
config.Init(cfg, clustercfg)
|
|
||||||
} else {
|
|
||||||
cclog.Abort("Cluster configuration must be present")
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
cclog.Abort("Main configuration must be present")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
clustercfg := ccconf.GetPackageConfig("clusters")
|
||||||
|
if clustercfg == nil {
|
||||||
|
return fmt.Errorf("cluster configuration must be present")
|
||||||
|
}
|
||||||
|
|
||||||
|
config.Init(cfg, clustercfg)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func initDatabase() error {
|
||||||
|
repository.Connect(config.Keys.DBDriver, config.Keys.DB)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleDatabaseCommands() error {
|
||||||
if flagMigrateDB {
|
if flagMigrateDB {
|
||||||
err := repository.MigrateDB(config.Keys.DBDriver, config.Keys.DB)
|
err := repository.MigrateDB(config.Keys.DBDriver, config.Keys.DB)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
cclog.Abortf("MigrateDB Failed: Could not migrate '%s' database at location '%s' to version %d.\nError: %s\n", config.Keys.DBDriver, config.Keys.DB, repository.Version, err.Error())
|
return fmt.Errorf("migrating database to version %d: %w", repository.Version, err)
|
||||||
}
|
}
|
||||||
cclog.Exitf("MigrateDB Success: Migrated '%s' database at location '%s' to version %d.\n", config.Keys.DBDriver, config.Keys.DB, repository.Version)
|
cclog.Exitf("MigrateDB Success: Migrated '%s' database at location '%s' to version %d.\n",
|
||||||
|
config.Keys.DBDriver, config.Keys.DB, repository.Version)
|
||||||
}
|
}
|
||||||
|
|
||||||
if flagRevertDB {
|
if flagRevertDB {
|
||||||
err := repository.RevertDB(config.Keys.DBDriver, config.Keys.DB)
|
err := repository.RevertDB(config.Keys.DBDriver, config.Keys.DB)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
cclog.Abortf("RevertDB Failed: Could not revert '%s' database at location '%s' to version %d.\nError: %s\n", config.Keys.DBDriver, config.Keys.DB, (repository.Version - 1), err.Error())
|
return fmt.Errorf("reverting database to version %d: %w", repository.Version-1, err)
|
||||||
}
|
}
|
||||||
cclog.Exitf("RevertDB Success: Reverted '%s' database at location '%s' to version %d.\n", config.Keys.DBDriver, config.Keys.DB, (repository.Version - 1))
|
cclog.Exitf("RevertDB Success: Reverted '%s' database at location '%s' to version %d.\n",
|
||||||
|
config.Keys.DBDriver, config.Keys.DB, repository.Version-1)
|
||||||
}
|
}
|
||||||
|
|
||||||
if flagForceDB {
|
if flagForceDB {
|
||||||
err := repository.ForceDB(config.Keys.DBDriver, config.Keys.DB)
|
err := repository.ForceDB(config.Keys.DBDriver, config.Keys.DB)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
cclog.Abortf("ForceDB Failed: Could not force '%s' database at location '%s' to version %d.\nError: %s\n", config.Keys.DBDriver, config.Keys.DB, repository.Version, err.Error())
|
return fmt.Errorf("forcing database to version %d: %w", repository.Version, err)
|
||||||
}
|
}
|
||||||
cclog.Exitf("ForceDB Success: Forced '%s' database at location '%s' to version %d.\n", config.Keys.DBDriver, config.Keys.DB, repository.Version)
|
cclog.Exitf("ForceDB Success: Forced '%s' database at location '%s' to version %d.\n",
|
||||||
|
config.Keys.DBDriver, config.Keys.DB, repository.Version)
|
||||||
}
|
}
|
||||||
|
|
||||||
repository.Connect(config.Keys.DBDriver, config.Keys.DB)
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleUserCommands() error {
|
||||||
|
if config.Keys.DisableAuthentication && (flagNewUser != "" || flagDelUser != "") {
|
||||||
|
return fmt.Errorf("--add-user and --del-user can only be used if authentication is enabled")
|
||||||
|
}
|
||||||
|
|
||||||
if !config.Keys.DisableAuthentication {
|
if !config.Keys.DisableAuthentication {
|
||||||
|
|
||||||
if cfg := ccconf.GetPackageConfig("auth"); cfg != nil {
|
if cfg := ccconf.GetPackageConfig("auth"); cfg != nil {
|
||||||
auth.Init(&cfg)
|
auth.Init(&cfg)
|
||||||
} else {
|
} else {
|
||||||
@@ -137,157 +161,318 @@ func main() {
|
|||||||
auth.Init(nil)
|
auth.Init(nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
if flagNewUser != "" {
|
// Check for default security keys
|
||||||
parts := strings.SplitN(flagNewUser, ":", 3)
|
checkDefaultSecurityKeys()
|
||||||
if len(parts) != 3 || len(parts[0]) == 0 {
|
|
||||||
cclog.Abortf("Add User: Could not parse supplied argument format: No changes.\n"+
|
|
||||||
"Want: <username>:[admin,support,manager,api,user]:<password>\n"+
|
|
||||||
"Have: %s\n", flagNewUser)
|
|
||||||
}
|
|
||||||
|
|
||||||
ur := repository.GetUserRepository()
|
if flagNewUser != "" {
|
||||||
if err := ur.AddUser(&schema.User{
|
if err := addUser(flagNewUser); err != nil {
|
||||||
Username: parts[0], Projects: make([]string, 0), Password: parts[2], Roles: strings.Split(parts[1], ","),
|
return err
|
||||||
}); err != nil {
|
|
||||||
cclog.Abortf("Add User: Could not add new user authentication for '%s' and roles '%s'.\nError: %s\n", parts[0], parts[1], err.Error())
|
|
||||||
} else {
|
|
||||||
cclog.Printf("Add User: Added new user '%s' with roles '%s'.\n", parts[0], parts[1])
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if flagDelUser != "" {
|
if flagDelUser != "" {
|
||||||
ur := repository.GetUserRepository()
|
if err := delUser(flagDelUser); err != nil {
|
||||||
if err := ur.DelUser(flagDelUser); err != nil {
|
return err
|
||||||
cclog.Abortf("Delete User: Could not delete user '%s' from DB.\nError: %s\n", flagDelUser, err.Error())
|
|
||||||
} else {
|
|
||||||
cclog.Printf("Delete User: Deleted user '%s' from DB.\n", flagDelUser)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
authHandle := auth.GetAuthInstance()
|
authHandle := auth.GetAuthInstance()
|
||||||
|
|
||||||
if flagSyncLDAP {
|
if flagSyncLDAP {
|
||||||
if authHandle.LdapAuth == nil {
|
if err := syncLDAP(authHandle); err != nil {
|
||||||
cclog.Abort("Sync LDAP: LDAP authentication is not configured, could not synchronize. No changes, exited.")
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := authHandle.LdapAuth.Sync(); err != nil {
|
|
||||||
cclog.Abortf("Sync LDAP: Could not synchronize, failed with error.\nError: %s\n", err.Error())
|
|
||||||
}
|
|
||||||
cclog.Print("Sync LDAP: LDAP synchronization successfull.")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if flagGenJWT != "" {
|
if flagGenJWT != "" {
|
||||||
ur := repository.GetUserRepository()
|
if err := generateJWT(authHandle, flagGenJWT); err != nil {
|
||||||
user, err := ur.GetUser(flagGenJWT)
|
return err
|
||||||
if err != nil {
|
|
||||||
cclog.Abortf("JWT: Could not get supplied user '%s' from DB. No changes, exited.\nError: %s\n", flagGenJWT, err.Error())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if !user.HasRole(schema.RoleApi) {
|
|
||||||
cclog.Warnf("JWT: User '%s' does not have the role 'api'. REST API endpoints will return error!\n", user.Username)
|
|
||||||
}
|
|
||||||
|
|
||||||
jwt, err := authHandle.JwtAuth.ProvideJWT(user)
|
|
||||||
if err != nil {
|
|
||||||
cclog.Abortf("JWT: User '%s' found in DB, but failed to provide JWT.\nError: %s\n", user.Username, err.Error())
|
|
||||||
}
|
|
||||||
|
|
||||||
cclog.Printf("JWT: Successfully generated JWT for user '%s': %s\n", user.Username, jwt)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
} else if flagNewUser != "" || flagDelUser != "" {
|
|
||||||
cclog.Abort("Error: Arguments '--add-user' and '--del-user' can only be used if authentication is enabled. No changes, exited.")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if archiveCfg := ccconf.GetPackageConfig("archive"); archiveCfg != nil {
|
return nil
|
||||||
err = archive.Init(archiveCfg, config.Keys.DisableArchive)
|
}
|
||||||
} else {
|
|
||||||
err = archive.Init(json.RawMessage("{\"kind\":\"file\",\"path\":\"./var/job-archive\"}"), config.Keys.DisableArchive)
|
// checkDefaultSecurityKeys warns if default JWT keys are detected
|
||||||
|
func checkDefaultSecurityKeys() {
|
||||||
|
// Default JWT public key from init.go
|
||||||
|
defaultJWTPublic := "kzfYrYy+TzpanWZHJ5qSdMj5uKUWgq74BWhQG6copP0="
|
||||||
|
|
||||||
|
if os.Getenv("JWT_PUBLIC_KEY") == defaultJWTPublic {
|
||||||
|
cclog.Warn("Using default JWT keys - not recommended for production environments")
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func addUser(userSpec string) error {
|
||||||
|
parts := strings.SplitN(userSpec, ":", 3)
|
||||||
|
if len(parts) != 3 || len(parts[0]) == 0 {
|
||||||
|
return fmt.Errorf("invalid user format, want: <username>:[admin,support,manager,api,user]:<password>, have: %s", userSpec)
|
||||||
|
}
|
||||||
|
|
||||||
|
ur := repository.GetUserRepository()
|
||||||
|
if err := ur.AddUser(&schema.User{
|
||||||
|
Username: parts[0],
|
||||||
|
Projects: make([]string, 0),
|
||||||
|
Password: parts[2],
|
||||||
|
Roles: strings.Split(parts[1], ","),
|
||||||
|
}); err != nil {
|
||||||
|
return fmt.Errorf("adding user '%s' with roles '%s': %w", parts[0], parts[1], err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cclog.Printf("Add User: Added new user '%s' with roles '%s'.\n", parts[0], parts[1])
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func delUser(username string) error {
|
||||||
|
ur := repository.GetUserRepository()
|
||||||
|
if err := ur.DelUser(username); err != nil {
|
||||||
|
return fmt.Errorf("deleting user '%s': %w", username, err)
|
||||||
|
}
|
||||||
|
cclog.Printf("Delete User: Deleted user '%s' from DB.\n", username)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func syncLDAP(authHandle *auth.Authentication) error {
|
||||||
|
if authHandle.LdapAuth == nil {
|
||||||
|
return fmt.Errorf("LDAP authentication is not configured")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := authHandle.LdapAuth.Sync(); err != nil {
|
||||||
|
return fmt.Errorf("synchronizing LDAP: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cclog.Print("Sync LDAP: LDAP synchronization successfull.")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func generateJWT(authHandle *auth.Authentication, username string) error {
|
||||||
|
ur := repository.GetUserRepository()
|
||||||
|
user, err := ur.GetUser(username)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
cclog.Abortf("Init: Failed to initialize archive.\nError: %s\n", err.Error())
|
return fmt.Errorf("getting user '%s': %w", username, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if !user.HasRole(schema.RoleApi) {
|
||||||
|
cclog.Warnf("JWT: User '%s' does not have the role 'api'. REST API endpoints will return error!\n", user.Username)
|
||||||
|
}
|
||||||
|
|
||||||
|
jwt, err := authHandle.JwtAuth.ProvideJWT(user)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("generating JWT for user '%s': %w", user.Username, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cclog.Printf("JWT: Successfully generated JWT for user '%s': %s\n", user.Username, jwt)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func initSubsystems() error {
|
||||||
|
// Initialize job archive
|
||||||
|
archiveCfg := ccconf.GetPackageConfig("archive")
|
||||||
|
if archiveCfg == nil {
|
||||||
|
archiveCfg = json.RawMessage(defaultArchiveConfig)
|
||||||
|
}
|
||||||
|
if err := archive.Init(archiveCfg, config.Keys.DisableArchive); err != nil {
|
||||||
|
return fmt.Errorf("initializing archive: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize metricdata
|
||||||
if err := metricdata.Init(); err != nil {
|
if err := metricdata.Init(); err != nil {
|
||||||
cclog.Abortf("Init: Failed to initialize metricdata repository.\nError %s\n", err.Error())
|
return fmt.Errorf("initializing metricdata repository: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Handle database re-initialization
|
||||||
if flagReinitDB {
|
if flagReinitDB {
|
||||||
if err := importer.InitDB(); err != nil {
|
if err := importer.InitDB(); err != nil {
|
||||||
cclog.Abortf("Init DB: Failed to re-initialize repository DB.\nError: %s\n", err.Error())
|
return fmt.Errorf("re-initializing repository DB: %w", err)
|
||||||
} else {
|
|
||||||
cclog.Print("Init DB: Sucessfully re-initialized repository DB.")
|
|
||||||
}
|
}
|
||||||
|
cclog.Print("Init DB: Successfully re-initialized repository DB.")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Handle job import
|
||||||
if flagImportJob != "" {
|
if flagImportJob != "" {
|
||||||
if err := importer.HandleImportFlag(flagImportJob); err != nil {
|
if err := importer.HandleImportFlag(flagImportJob); err != nil {
|
||||||
cclog.Abortf("Import Job: Job import failed.\nError: %s\n", err.Error())
|
return fmt.Errorf("importing job: %w", err)
|
||||||
} else {
|
|
||||||
cclog.Printf("Import Job: Imported Job '%s' into DB.\n", flagImportJob)
|
|
||||||
}
|
}
|
||||||
|
cclog.Printf("Import Job: Imported Job '%s' into DB.\n", flagImportJob)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Initialize taggers
|
||||||
if config.Keys.EnableJobTaggers {
|
if config.Keys.EnableJobTaggers {
|
||||||
tagger.Init()
|
tagger.Init()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Apply tags if requested
|
||||||
if flagApplyTags {
|
if flagApplyTags {
|
||||||
if err := tagger.RunTaggers(); err != nil {
|
if err := tagger.RunTaggers(); err != nil {
|
||||||
cclog.Abortf("Running job taggers.\nError: %s\n", err.Error())
|
return fmt.Errorf("running job taggers: %w", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if !flagServer {
|
return nil
|
||||||
cclog.Exit("No errors, server flag not set. Exiting cc-backend.")
|
}
|
||||||
}
|
|
||||||
|
|
||||||
|
func runServer(ctx context.Context) error {
|
||||||
var wg sync.WaitGroup
|
var wg sync.WaitGroup
|
||||||
|
|
||||||
// Metric Store starts after all flags have been processes
|
// Start metric store if enabled
|
||||||
if memorystore.InternalCCMSFlag {
|
if memorystore.InternalCCMSFlag {
|
||||||
if mscfg := ccconf.GetPackageConfig("metric-store"); mscfg != nil {
|
mscfg := ccconf.GetPackageConfig("metric-store")
|
||||||
memorystore.Init(mscfg, &wg)
|
if mscfg == nil {
|
||||||
} else {
|
return fmt.Errorf("metric store configuration must be present")
|
||||||
cclog.Abort("Metric Store configuration must be present")
|
|
||||||
}
|
}
|
||||||
|
memorystore.Init(mscfg, &wg)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Start archiver and task manager
|
||||||
archiver.Start(repository.GetJobRepository())
|
archiver.Start(repository.GetJobRepository())
|
||||||
|
taskManager.Start(ccconf.GetPackageConfig("cron"), ccconf.GetPackageConfig("archive"))
|
||||||
|
|
||||||
taskManager.Start(ccconf.GetPackageConfig("cron"),
|
// Initialize web UI
|
||||||
ccconf.GetPackageConfig("archive"))
|
|
||||||
|
|
||||||
cfg := ccconf.GetPackageConfig("ui")
|
cfg := ccconf.GetPackageConfig("ui")
|
||||||
web.Init(cfg)
|
web.Init(cfg)
|
||||||
|
|
||||||
serverInit()
|
// Initialize HTTP server
|
||||||
|
srv, err := NewServer(version, commit, date)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("creating server: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Channel to collect errors from server
|
||||||
|
errChan := make(chan error, 1)
|
||||||
|
|
||||||
|
// Start HTTP server
|
||||||
wg.Add(1)
|
wg.Add(1)
|
||||||
go func() {
|
go func() {
|
||||||
defer wg.Done()
|
defer wg.Done()
|
||||||
serverStart()
|
if err := srv.Start(ctx); err != nil {
|
||||||
|
errChan <- err
|
||||||
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
|
// Handle shutdown signals
|
||||||
wg.Add(1)
|
wg.Add(1)
|
||||||
sigs := make(chan os.Signal, 1)
|
sigs := make(chan os.Signal, 1)
|
||||||
signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM)
|
signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM)
|
||||||
go func() {
|
go func() {
|
||||||
defer wg.Done()
|
defer wg.Done()
|
||||||
<-sigs
|
select {
|
||||||
|
case <-sigs:
|
||||||
|
cclog.Info("Shutdown signal received")
|
||||||
|
case <-ctx.Done():
|
||||||
|
}
|
||||||
|
|
||||||
runtimeEnv.SystemdNotifiy(false, "Shutting down ...")
|
runtimeEnv.SystemdNotifiy(false, "Shutting down ...")
|
||||||
|
srv.Shutdown(ctx)
|
||||||
serverShutdown()
|
|
||||||
|
|
||||||
util.FsWatcherShutdown()
|
util.FsWatcherShutdown()
|
||||||
|
|
||||||
taskManager.Shutdown()
|
taskManager.Shutdown()
|
||||||
}()
|
}()
|
||||||
|
|
||||||
if os.Getenv("GOGC") == "" {
|
// Set GC percent if not configured
|
||||||
|
if os.Getenv(envGOGC) == "" {
|
||||||
debug.SetGCPercent(25)
|
debug.SetGCPercent(25)
|
||||||
}
|
}
|
||||||
runtimeEnv.SystemdNotifiy(true, "running")
|
runtimeEnv.SystemdNotifiy(true, "running")
|
||||||
wg.Wait()
|
|
||||||
|
// Wait for completion or error
|
||||||
|
go func() {
|
||||||
|
wg.Wait()
|
||||||
|
close(errChan)
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Check for server startup errors
|
||||||
|
select {
|
||||||
|
case err := <-errChan:
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
case <-time.After(100 * time.Millisecond):
|
||||||
|
// Server started successfully, wait for completion
|
||||||
|
if err := <-errChan; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
cclog.Print("Graceful shutdown completed!")
|
cclog.Print("Graceful shutdown completed!")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func run() error {
|
||||||
|
cliInit()
|
||||||
|
|
||||||
|
if flagVersion {
|
||||||
|
printVersion()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize logger
|
||||||
|
cclog.Init(flagLogLevel, flagLogDateTime)
|
||||||
|
|
||||||
|
// Handle init flag
|
||||||
|
if flagInit {
|
||||||
|
initEnv()
|
||||||
|
cclog.Exit("Successfully setup environment!\n" +
|
||||||
|
"Please review config.json and .env and adjust it to your needs.\n" +
|
||||||
|
"Add your job-archive at ./var/job-archive.")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize gops agent
|
||||||
|
if err := initGops(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize subsystems in dependency order:
|
||||||
|
// 1. Load environment variables from .env file (contains sensitive configuration)
|
||||||
|
// 2. Load configuration from config.json (may reference environment variables)
|
||||||
|
// 3. Handle database migration commands if requested
|
||||||
|
// 4. Initialize database connection (requires config for connection string)
|
||||||
|
// 5. Handle user commands if requested (requires database and authentication config)
|
||||||
|
// 6. Initialize subsystems like archive and metrics (require config and database)
|
||||||
|
|
||||||
|
// Load environment and configuration
|
||||||
|
if err := loadEnvironment(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := initConfiguration(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle database migration (migrate, revert, force)
|
||||||
|
if err := handleDatabaseCommands(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize database
|
||||||
|
if err := initDatabase(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle user commands (add, delete, sync, JWT)
|
||||||
|
if err := handleUserCommands(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize subsystems (archive, metrics, taggers)
|
||||||
|
if err := initSubsystems(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Exit if start server is not requested
|
||||||
|
if !flagServer {
|
||||||
|
cclog.Exit("No errors, server flag not set. Exiting cc-backend.")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run server with context
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
return runServer(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
if err := run(); err != nil {
|
||||||
|
cclog.Error(err.Error())
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+108
-108
@@ -2,6 +2,9 @@
|
|||||||
// All rights reserved. This file is part of cc-backend.
|
// All rights reserved. This file is part of cc-backend.
|
||||||
// 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.
|
||||||
|
// Package main provides the entry point for the ClusterCockpit backend server.
|
||||||
|
// This file contains HTTP server setup, routing configuration, and
|
||||||
|
// authentication middleware integration.
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -36,11 +39,19 @@ import (
|
|||||||
httpSwagger "github.com/swaggo/http-swagger"
|
httpSwagger "github.com/swaggo/http-swagger"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var buildInfo web.Build
|
||||||
|
|
||||||
|
// Environment variable names
|
||||||
|
const (
|
||||||
|
envDebug = "DEBUG"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Server encapsulates the HTTP server state and dependencies
|
||||||
|
type Server struct {
|
||||||
router *mux.Router
|
router *mux.Router
|
||||||
server *http.Server
|
server *http.Server
|
||||||
apiHandle *api.RestApi
|
apiHandle *api.RestApi
|
||||||
)
|
}
|
||||||
|
|
||||||
func onFailureResponse(rw http.ResponseWriter, r *http.Request, err error) {
|
func onFailureResponse(rw http.ResponseWriter, r *http.Request, err error) {
|
||||||
rw.Header().Add("Content-Type", "application/json")
|
rw.Header().Add("Content-Type", "application/json")
|
||||||
@@ -51,25 +62,31 @@ func onFailureResponse(rw http.ResponseWriter, r *http.Request, err error) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func serverInit() {
|
// NewServer creates and initializes a new Server instance
|
||||||
|
func NewServer(version, commit, buildDate string) (*Server, error) {
|
||||||
|
buildInfo = web.Build{Version: version, Hash: commit, Buildtime: buildDate}
|
||||||
|
|
||||||
|
s := &Server{
|
||||||
|
router: mux.NewRouter(),
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.init(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return s, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) init() error {
|
||||||
// Setup the http.Handler/Router used by the server
|
// Setup the http.Handler/Router used by the server
|
||||||
graph.Init()
|
graph.Init()
|
||||||
resolver := graph.GetResolverInstance()
|
resolver := graph.GetResolverInstance()
|
||||||
graphQLServer := handler.New(
|
graphQLServer := handler.New(
|
||||||
generated.NewExecutableSchema(generated.Config{Resolvers: resolver}))
|
generated.NewExecutableSchema(generated.Config{Resolvers: resolver}))
|
||||||
|
|
||||||
// graphQLServer.AddTransport(transport.SSE{})
|
|
||||||
graphQLServer.AddTransport(transport.POST{})
|
graphQLServer.AddTransport(transport.POST{})
|
||||||
// graphQLServer.AddTransport(transport.Websocket{
|
|
||||||
// KeepAlivePingInterval: 10 * time.Second,
|
|
||||||
// Upgrader: websocket.Upgrader{
|
|
||||||
// CheckOrigin: func(r *http.Request) bool {
|
|
||||||
// return true
|
|
||||||
// },
|
|
||||||
// },
|
|
||||||
// })
|
|
||||||
|
|
||||||
if os.Getenv("DEBUG") != "1" {
|
if os.Getenv(envDebug) != "1" {
|
||||||
// Having this handler means that a error message is returned via GraphQL instead of the connection simply beeing closed.
|
// Having this handler means that a error message is returned via GraphQL instead of the connection simply beeing closed.
|
||||||
// The problem with this is that then, no more stacktrace is printed to stderr.
|
// The problem with this is that then, no more stacktrace is printed to stderr.
|
||||||
graphQLServer.SetRecoverFunc(func(ctx context.Context, err any) error {
|
graphQLServer.SetRecoverFunc(func(ctx context.Context, err any) error {
|
||||||
@@ -86,73 +103,56 @@ func serverInit() {
|
|||||||
|
|
||||||
authHandle := auth.GetAuthInstance()
|
authHandle := auth.GetAuthInstance()
|
||||||
|
|
||||||
apiHandle = api.New()
|
s.apiHandle = api.New()
|
||||||
|
|
||||||
router = mux.NewRouter()
|
|
||||||
buildInfo := web.Build{Version: version, Hash: commit, Buildtime: date}
|
|
||||||
|
|
||||||
info := map[string]any{}
|
info := map[string]any{}
|
||||||
info["hasOpenIDConnect"] = false
|
info["hasOpenIDConnect"] = false
|
||||||
|
|
||||||
if auth.Keys.OpenIDConfig != nil {
|
if auth.Keys.OpenIDConfig != nil {
|
||||||
openIDConnect := auth.NewOIDC(authHandle)
|
openIDConnect := auth.NewOIDC(authHandle)
|
||||||
openIDConnect.RegisterEndpoints(router)
|
openIDConnect.RegisterEndpoints(s.router)
|
||||||
info["hasOpenIDConnect"] = true
|
info["hasOpenIDConnect"] = true
|
||||||
}
|
}
|
||||||
|
|
||||||
router.HandleFunc("/login", func(rw http.ResponseWriter, r *http.Request) {
|
s.router.HandleFunc("/login", func(rw http.ResponseWriter, r *http.Request) {
|
||||||
rw.Header().Add("Content-Type", "text/html; charset=utf-8")
|
rw.Header().Add("Content-Type", "text/html; charset=utf-8")
|
||||||
cclog.Debugf("##%v##", info)
|
cclog.Debugf("##%v##", info)
|
||||||
web.RenderTemplate(rw, "login.tmpl", &web.Page{Title: "Login", Build: buildInfo, Infos: info})
|
web.RenderTemplate(rw, "login.tmpl", &web.Page{Title: "Login", Build: buildInfo, Infos: info})
|
||||||
}).Methods(http.MethodGet)
|
}).Methods(http.MethodGet)
|
||||||
router.HandleFunc("/imprint", func(rw http.ResponseWriter, r *http.Request) {
|
s.router.HandleFunc("/imprint", func(rw http.ResponseWriter, r *http.Request) {
|
||||||
rw.Header().Add("Content-Type", "text/html; charset=utf-8")
|
rw.Header().Add("Content-Type", "text/html; charset=utf-8")
|
||||||
web.RenderTemplate(rw, "imprint.tmpl", &web.Page{Title: "Imprint", Build: buildInfo})
|
web.RenderTemplate(rw, "imprint.tmpl", &web.Page{Title: "Imprint", Build: buildInfo})
|
||||||
})
|
})
|
||||||
router.HandleFunc("/privacy", func(rw http.ResponseWriter, r *http.Request) {
|
s.router.HandleFunc("/privacy", func(rw http.ResponseWriter, r *http.Request) {
|
||||||
rw.Header().Add("Content-Type", "text/html; charset=utf-8")
|
rw.Header().Add("Content-Type", "text/html; charset=utf-8")
|
||||||
web.RenderTemplate(rw, "privacy.tmpl", &web.Page{Title: "Privacy", Build: buildInfo})
|
web.RenderTemplate(rw, "privacy.tmpl", &web.Page{Title: "Privacy", Build: buildInfo})
|
||||||
})
|
})
|
||||||
|
|
||||||
secured := router.PathPrefix("/").Subrouter()
|
secured := s.router.PathPrefix("/").Subrouter()
|
||||||
securedapi := router.PathPrefix("/api").Subrouter()
|
securedapi := s.router.PathPrefix("/api").Subrouter()
|
||||||
userapi := router.PathPrefix("/userapi").Subrouter()
|
userapi := s.router.PathPrefix("/userapi").Subrouter()
|
||||||
configapi := router.PathPrefix("/config").Subrouter()
|
configapi := s.router.PathPrefix("/config").Subrouter()
|
||||||
frontendapi := router.PathPrefix("/frontend").Subrouter()
|
frontendapi := s.router.PathPrefix("/frontend").Subrouter()
|
||||||
metricstoreapi := router.PathPrefix("/metricstore").Subrouter()
|
metricstoreapi := s.router.PathPrefix("/metricstore").Subrouter()
|
||||||
|
|
||||||
if !config.Keys.DisableAuthentication {
|
if !config.Keys.DisableAuthentication {
|
||||||
router.Handle("/login", authHandle.Login(
|
// Create login failure handler (used by both /login and /jwt-login)
|
||||||
// On success: Handled within Login()
|
loginFailureHandler := func(rw http.ResponseWriter, r *http.Request, err error) {
|
||||||
// On failure:
|
rw.Header().Add("Content-Type", "text/html; charset=utf-8")
|
||||||
func(rw http.ResponseWriter, r *http.Request, err error) {
|
rw.WriteHeader(http.StatusUnauthorized)
|
||||||
rw.Header().Add("Content-Type", "text/html; charset=utf-8")
|
web.RenderTemplate(rw, "login.tmpl", &web.Page{
|
||||||
rw.WriteHeader(http.StatusUnauthorized)
|
Title: "Login failed - ClusterCockpit",
|
||||||
web.RenderTemplate(rw, "login.tmpl", &web.Page{
|
MsgType: "alert-warning",
|
||||||
Title: "Login failed - ClusterCockpit",
|
Message: err.Error(),
|
||||||
MsgType: "alert-warning",
|
Build: buildInfo,
|
||||||
Message: err.Error(),
|
Infos: info,
|
||||||
Build: buildInfo,
|
})
|
||||||
Infos: info,
|
}
|
||||||
})
|
|
||||||
})).Methods(http.MethodPost)
|
|
||||||
|
|
||||||
router.Handle("/jwt-login", authHandle.Login(
|
s.router.Handle("/login", authHandle.Login(loginFailureHandler)).Methods(http.MethodPost)
|
||||||
// On success: Handled within Login()
|
s.router.Handle("/jwt-login", authHandle.Login(loginFailureHandler))
|
||||||
// On failure:
|
|
||||||
func(rw http.ResponseWriter, r *http.Request, err error) {
|
|
||||||
rw.Header().Add("Content-Type", "text/html; charset=utf-8")
|
|
||||||
rw.WriteHeader(http.StatusUnauthorized)
|
|
||||||
web.RenderTemplate(rw, "login.tmpl", &web.Page{
|
|
||||||
Title: "Login failed - ClusterCockpit",
|
|
||||||
MsgType: "alert-warning",
|
|
||||||
Message: err.Error(),
|
|
||||||
Build: buildInfo,
|
|
||||||
Infos: info,
|
|
||||||
})
|
|
||||||
}))
|
|
||||||
|
|
||||||
router.Handle("/logout", authHandle.Logout(
|
s.router.Handle("/logout", authHandle.Logout(
|
||||||
http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
|
http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
|
||||||
rw.Header().Add("Content-Type", "text/html; charset=utf-8")
|
rw.Header().Add("Content-Type", "text/html; charset=utf-8")
|
||||||
rw.WriteHeader(http.StatusOK)
|
rw.WriteHeader(http.StatusOK)
|
||||||
@@ -226,8 +226,8 @@ func serverInit() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if flagDev {
|
if flagDev {
|
||||||
router.Handle("/playground", playground.Handler("GraphQL playground", "/query"))
|
s.router.Handle("/playground", playground.Handler("GraphQL playground", "/query"))
|
||||||
router.PathPrefix("/swagger/").Handler(httpSwagger.Handler(
|
s.router.PathPrefix("/swagger/").Handler(httpSwagger.Handler(
|
||||||
httpSwagger.URL("http://" + config.Keys.Addr + "/swagger/doc.json"))).Methods(http.MethodGet)
|
httpSwagger.URL("http://" + config.Keys.Addr + "/swagger/doc.json"))).Methods(http.MethodGet)
|
||||||
}
|
}
|
||||||
secured.Handle("/query", graphQLServer)
|
secured.Handle("/query", graphQLServer)
|
||||||
@@ -239,67 +239,46 @@ func serverInit() {
|
|||||||
|
|
||||||
// Mount all /monitoring/... and /api/... routes.
|
// Mount all /monitoring/... and /api/... routes.
|
||||||
routerConfig.SetupRoutes(secured, buildInfo)
|
routerConfig.SetupRoutes(secured, buildInfo)
|
||||||
apiHandle.MountApiRoutes(securedapi)
|
s.apiHandle.MountApiRoutes(securedapi)
|
||||||
apiHandle.MountUserApiRoutes(userapi)
|
s.apiHandle.MountUserApiRoutes(userapi)
|
||||||
apiHandle.MountConfigApiRoutes(configapi)
|
s.apiHandle.MountConfigApiRoutes(configapi)
|
||||||
apiHandle.MountFrontendApiRoutes(frontendapi)
|
s.apiHandle.MountFrontendApiRoutes(frontendapi)
|
||||||
|
|
||||||
if memorystore.InternalCCMSFlag {
|
if memorystore.InternalCCMSFlag {
|
||||||
apiHandle.MountMetricStoreApiRoutes(metricstoreapi)
|
s.apiHandle.MountMetricStoreApiRoutes(metricstoreapi)
|
||||||
}
|
}
|
||||||
|
|
||||||
if config.Keys.EmbedStaticFiles {
|
if config.Keys.EmbedStaticFiles {
|
||||||
if i, err := os.Stat("./var/img"); err == nil {
|
if i, err := os.Stat("./var/img"); err == nil {
|
||||||
if i.IsDir() {
|
if i.IsDir() {
|
||||||
cclog.Info("Use local directory for static images")
|
cclog.Info("Use local directory for static images")
|
||||||
router.PathPrefix("/img/").Handler(http.StripPrefix("/img/", http.FileServer(http.Dir("./var/img"))))
|
s.router.PathPrefix("/img/").Handler(http.StripPrefix("/img/", http.FileServer(http.Dir("./var/img"))))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
router.PathPrefix("/").Handler(http.StripPrefix("/", web.ServeFiles()))
|
s.router.PathPrefix("/").Handler(http.StripPrefix("/", web.ServeFiles()))
|
||||||
} else {
|
} else {
|
||||||
router.PathPrefix("/").Handler(http.FileServer(http.Dir(config.Keys.StaticFiles)))
|
s.router.PathPrefix("/").Handler(http.FileServer(http.Dir(config.Keys.StaticFiles)))
|
||||||
}
|
}
|
||||||
|
|
||||||
router.Use(handlers.CompressHandler)
|
s.router.Use(handlers.CompressHandler)
|
||||||
router.Use(handlers.RecoveryHandler(handlers.PrintRecoveryStack(true)))
|
s.router.Use(handlers.RecoveryHandler(handlers.PrintRecoveryStack(true)))
|
||||||
router.Use(handlers.CORS(
|
s.router.Use(handlers.CORS(
|
||||||
handlers.AllowCredentials(),
|
handlers.AllowCredentials(),
|
||||||
handlers.AllowedHeaders([]string{"X-Requested-With", "Content-Type", "Authorization", "Origin"}),
|
handlers.AllowedHeaders([]string{"X-Requested-With", "Content-Type", "Authorization", "Origin"}),
|
||||||
handlers.AllowedMethods([]string{"GET", "POST", "HEAD", "OPTIONS"}),
|
handlers.AllowedMethods([]string{"GET", "POST", "HEAD", "OPTIONS"}),
|
||||||
handlers.AllowedOrigins([]string{"*"})))
|
handlers.AllowedOrigins([]string{"*"})))
|
||||||
|
|
||||||
// secured.NotFoundHandler = http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
|
return nil
|
||||||
// page := web.Page{
|
|
||||||
// Title: "ClusterCockpit - Not Found",
|
|
||||||
// Build: buildInfo,
|
|
||||||
// }
|
|
||||||
// rw.Header().Add("Content-Type", "text/html; charset=utf-8")
|
|
||||||
// web.RenderTemplate(rw, "404.tmpl", &page)
|
|
||||||
// })
|
|
||||||
|
|
||||||
// secured.NotFoundHandler = http.HandlerFunc(http.NotFound)
|
|
||||||
// router.NotFoundHandler = router.NewRoute().HandlerFunc(http.NotFound).GetHandler()
|
|
||||||
|
|
||||||
// printEndpoints(router)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// func printEndpoints(r *mux.Router) {
|
// Server timeout defaults (in seconds)
|
||||||
// r.Walk(func(route *mux.Route, router *mux.Router, ancestors []*mux.Route) error {
|
const (
|
||||||
// path, err := route.GetPathTemplate()
|
defaultReadTimeout = 20
|
||||||
// if err != nil {
|
defaultWriteTimeout = 20
|
||||||
// path = "nopath"
|
)
|
||||||
// }
|
|
||||||
// methods, err := route.GetMethods()
|
|
||||||
// if err != nil {
|
|
||||||
// methods = append(methods, "nomethod")
|
|
||||||
// }
|
|
||||||
// fmt.Printf("%v %s\n", methods, path)
|
|
||||||
// return nil
|
|
||||||
// })
|
|
||||||
// }
|
|
||||||
|
|
||||||
func serverStart() {
|
func (s *Server) Start(ctx context.Context) error {
|
||||||
handler := handlers.CustomLoggingHandler(io.Discard, router, func(_ io.Writer, params handlers.LogFormatterParams) {
|
handler := handlers.CustomLoggingHandler(io.Discard, s.router, func(_ io.Writer, params handlers.LogFormatterParams) {
|
||||||
if strings.HasPrefix(params.Request.RequestURI, "/api/") {
|
if strings.HasPrefix(params.Request.RequestURI, "/api/") {
|
||||||
cclog.Debugf("%s %s (%d, %.02fkb, %dms)",
|
cclog.Debugf("%s %s (%d, %.02fkb, %dms)",
|
||||||
params.Request.Method, params.URL.RequestURI(),
|
params.Request.Method, params.URL.RequestURI(),
|
||||||
@@ -313,9 +292,13 @@ func serverStart() {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
server = &http.Server{
|
// Use configurable timeouts with defaults
|
||||||
ReadTimeout: 20 * time.Second,
|
readTimeout := time.Duration(defaultReadTimeout) * time.Second
|
||||||
WriteTimeout: 20 * time.Second,
|
writeTimeout := time.Duration(defaultWriteTimeout) * time.Second
|
||||||
|
|
||||||
|
s.server = &http.Server{
|
||||||
|
ReadTimeout: readTimeout,
|
||||||
|
WriteTimeout: writeTimeout,
|
||||||
Handler: handler,
|
Handler: handler,
|
||||||
Addr: config.Keys.Addr,
|
Addr: config.Keys.Addr,
|
||||||
}
|
}
|
||||||
@@ -323,7 +306,7 @@ func serverStart() {
|
|||||||
// Start http or https server
|
// Start http or https server
|
||||||
listener, err := net.Listen("tcp", config.Keys.Addr)
|
listener, err := net.Listen("tcp", config.Keys.Addr)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
cclog.Abortf("Server Start: Starting http listener on '%s' failed.\nError: %s\n", config.Keys.Addr, err.Error())
|
return fmt.Errorf("starting listener on '%s': %w", config.Keys.Addr, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if !strings.HasSuffix(config.Keys.Addr, ":80") && config.Keys.RedirectHTTPTo != "" {
|
if !strings.HasSuffix(config.Keys.Addr, ":80") && config.Keys.RedirectHTTPTo != "" {
|
||||||
@@ -336,7 +319,7 @@ func serverStart() {
|
|||||||
cert, err := tls.LoadX509KeyPair(
|
cert, err := tls.LoadX509KeyPair(
|
||||||
config.Keys.HTTPSCertFile, config.Keys.HTTPSKeyFile)
|
config.Keys.HTTPSCertFile, config.Keys.HTTPSKeyFile)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
cclog.Abortf("Server Start: Loading X509 keypair failed. Check options 'https-cert-file' and 'https-key-file' in 'config.json'.\nError: %s\n", err.Error())
|
return fmt.Errorf("loading X509 keypair (check 'https-cert-file' and 'https-key-file' in config.json): %w", err)
|
||||||
}
|
}
|
||||||
listener = tls.NewListener(listener, &tls.Config{
|
listener = tls.NewListener(listener, &tls.Config{
|
||||||
Certificates: []tls.Certificate{cert},
|
Certificates: []tls.Certificate{cert},
|
||||||
@@ -356,17 +339,34 @@ func serverStart() {
|
|||||||
// be established first, then the user can be changed, and after that,
|
// be established first, then the user can be changed, and after that,
|
||||||
// the actual http server can be started.
|
// the actual http server can be started.
|
||||||
if err := runtimeEnv.DropPrivileges(config.Keys.Group, config.Keys.User); err != nil {
|
if err := runtimeEnv.DropPrivileges(config.Keys.Group, config.Keys.User); err != nil {
|
||||||
cclog.Abortf("Server Start: Error while preparing server start.\nError: %s\n", err.Error())
|
return fmt.Errorf("dropping privileges: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err = server.Serve(listener); err != nil && err != http.ErrServerClosed {
|
// Handle context cancellation for graceful shutdown
|
||||||
cclog.Abortf("Server Start: Starting server failed.\nError: %s\n", err.Error())
|
go func() {
|
||||||
|
<-ctx.Done()
|
||||||
|
shutdownCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
if err := s.server.Shutdown(shutdownCtx); err != nil {
|
||||||
|
cclog.Errorf("Server shutdown error: %v", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
if err = s.server.Serve(listener); err != nil && err != http.ErrServerClosed {
|
||||||
|
return fmt.Errorf("server failed: %w", err)
|
||||||
}
|
}
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func serverShutdown() {
|
func (s *Server) Shutdown(ctx context.Context) {
|
||||||
|
// Create a shutdown context with timeout
|
||||||
|
shutdownCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
// First shut down the server gracefully (waiting for all ongoing requests)
|
// First shut down the server gracefully (waiting for all ongoing requests)
|
||||||
server.Shutdown(context.Background())
|
if err := s.server.Shutdown(shutdownCtx); err != nil {
|
||||||
|
cclog.Errorf("Server shutdown error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
// Archive all the metric store data
|
// Archive all the metric store data
|
||||||
if memorystore.InternalCCMSFlag {
|
if memorystore.InternalCCMSFlag {
|
||||||
|
|||||||
@@ -5,14 +5,18 @@ go 1.24.0
|
|||||||
toolchain go1.24.1
|
toolchain go1.24.1
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/99designs/gqlgen v0.17.81
|
github.com/99designs/gqlgen v0.17.84
|
||||||
github.com/ClusterCockpit/cc-lib v0.10.1
|
github.com/ClusterCockpit/cc-lib v0.10.2
|
||||||
github.com/Masterminds/squirrel v1.5.4
|
github.com/Masterminds/squirrel v1.5.4
|
||||||
|
github.com/aws/aws-sdk-go-v2 v1.39.6
|
||||||
|
github.com/aws/aws-sdk-go-v2/config v1.31.20
|
||||||
|
github.com/aws/aws-sdk-go-v2/credentials v1.18.24
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/s3 v1.90.2
|
||||||
github.com/coreos/go-oidc/v3 v3.16.0
|
github.com/coreos/go-oidc/v3 v3.16.0
|
||||||
github.com/expr-lang/expr v1.17.6
|
github.com/expr-lang/expr v1.17.6
|
||||||
github.com/go-co-op/gocron/v2 v2.16.0
|
github.com/go-co-op/gocron/v2 v2.17.0
|
||||||
github.com/go-ldap/ldap/v3 v3.4.12
|
github.com/go-ldap/ldap/v3 v3.4.12
|
||||||
github.com/go-sql-driver/mysql v1.9.0
|
github.com/go-sql-driver/mysql v1.9.3
|
||||||
github.com/golang-jwt/jwt/v5 v5.2.2
|
github.com/golang-jwt/jwt/v5 v5.2.2
|
||||||
github.com/golang-migrate/migrate/v4 v4.18.2
|
github.com/golang-migrate/migrate/v4 v4.18.2
|
||||||
github.com/google/gops v0.3.28
|
github.com/google/gops v0.3.28
|
||||||
@@ -23,18 +27,19 @@ require (
|
|||||||
github.com/jmoiron/sqlx v1.4.0
|
github.com/jmoiron/sqlx v1.4.0
|
||||||
github.com/joho/godotenv v1.5.1
|
github.com/joho/godotenv v1.5.1
|
||||||
github.com/linkedin/goavro/v2 v2.14.0
|
github.com/linkedin/goavro/v2 v2.14.0
|
||||||
github.com/mattn/go-sqlite3 v1.14.24
|
github.com/mattn/go-sqlite3 v1.14.32
|
||||||
github.com/nats-io/nats.go v1.47.0
|
github.com/nats-io/nats.go v1.47.0
|
||||||
github.com/prometheus/client_golang v1.23.2
|
github.com/prometheus/client_golang v1.23.2
|
||||||
github.com/prometheus/common v0.66.1
|
github.com/prometheus/common v0.66.1
|
||||||
github.com/qustavo/sqlhooks/v2 v2.1.0
|
github.com/qustavo/sqlhooks/v2 v2.1.0
|
||||||
github.com/santhosh-tekuri/jsonschema/v5 v5.3.1
|
github.com/santhosh-tekuri/jsonschema/v5 v5.3.1
|
||||||
|
github.com/stretchr/testify v1.11.1
|
||||||
github.com/swaggo/http-swagger v1.3.4
|
github.com/swaggo/http-swagger v1.3.4
|
||||||
github.com/swaggo/swag v1.16.6
|
github.com/swaggo/swag v1.16.6
|
||||||
github.com/vektah/gqlparser/v2 v2.5.30
|
github.com/vektah/gqlparser/v2 v2.5.31
|
||||||
golang.org/x/crypto v0.43.0
|
golang.org/x/crypto v0.44.0
|
||||||
golang.org/x/oauth2 v0.32.0
|
golang.org/x/oauth2 v0.32.0
|
||||||
golang.org/x/time v0.13.0
|
golang.org/x/time v0.14.0
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
@@ -42,24 +47,40 @@ require (
|
|||||||
github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358 // indirect
|
github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358 // indirect
|
||||||
github.com/KyleBanks/depth v1.2.1 // indirect
|
github.com/KyleBanks/depth v1.2.1 // indirect
|
||||||
github.com/agnivade/levenshtein v1.2.1 // indirect
|
github.com/agnivade/levenshtein v1.2.1 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.3 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.13 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.13 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.13 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.13 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.3 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.4 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.13 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.13 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/sso v1.30.3 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.7 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/sts v1.40.2 // indirect
|
||||||
|
github.com/aws/smithy-go v1.23.2 // indirect
|
||||||
github.com/beorn7/perks v1.0.1 // indirect
|
github.com/beorn7/perks v1.0.1 // indirect
|
||||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||||
github.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect
|
github.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect
|
||||||
|
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||||
github.com/felixge/httpsnoop v1.0.4 // indirect
|
github.com/felixge/httpsnoop v1.0.4 // indirect
|
||||||
github.com/fsnotify/fsnotify v1.9.0 // indirect
|
github.com/fsnotify/fsnotify v1.9.0 // indirect
|
||||||
github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 // indirect
|
github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 // indirect
|
||||||
github.com/go-jose/go-jose/v4 v4.1.3 // indirect
|
github.com/go-jose/go-jose/v4 v4.1.3 // indirect
|
||||||
github.com/go-openapi/jsonpointer v0.22.1 // indirect
|
github.com/go-openapi/jsonpointer v0.22.3 // indirect
|
||||||
github.com/go-openapi/jsonreference v0.21.2 // indirect
|
github.com/go-openapi/jsonreference v0.21.3 // indirect
|
||||||
github.com/go-openapi/spec v0.22.0 // indirect
|
github.com/go-openapi/spec v0.22.1 // indirect
|
||||||
github.com/go-openapi/swag/conv v0.25.1 // indirect
|
github.com/go-openapi/swag/conv v0.25.4 // indirect
|
||||||
github.com/go-openapi/swag/jsonname v0.25.1 // indirect
|
github.com/go-openapi/swag/jsonname v0.25.4 // indirect
|
||||||
github.com/go-openapi/swag/jsonutils v0.25.1 // indirect
|
github.com/go-openapi/swag/jsonutils v0.25.4 // indirect
|
||||||
github.com/go-openapi/swag/loading v0.25.1 // indirect
|
github.com/go-openapi/swag/loading v0.25.4 // indirect
|
||||||
github.com/go-openapi/swag/stringutils v0.25.1 // indirect
|
github.com/go-openapi/swag/stringutils v0.25.4 // indirect
|
||||||
github.com/go-openapi/swag/typeutils v0.25.1 // indirect
|
github.com/go-openapi/swag/typeutils v0.25.4 // indirect
|
||||||
github.com/go-openapi/swag/yamlutils v0.25.1 // indirect
|
github.com/go-openapi/swag/yamlutils v0.25.4 // indirect
|
||||||
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
|
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
|
||||||
|
github.com/goccy/go-yaml v1.19.0 // indirect
|
||||||
github.com/golang/snappy v0.0.4 // indirect
|
github.com/golang/snappy v0.0.4 // indirect
|
||||||
github.com/google/uuid v1.6.0 // indirect
|
github.com/google/uuid v1.6.0 // indirect
|
||||||
github.com/gorilla/securecookie v1.1.2 // indirect
|
github.com/gorilla/securecookie v1.1.2 // indirect
|
||||||
@@ -70,7 +91,7 @@ require (
|
|||||||
github.com/jonboulle/clockwork v0.5.0 // indirect
|
github.com/jonboulle/clockwork v0.5.0 // indirect
|
||||||
github.com/jpillora/backoff v1.0.0 // indirect
|
github.com/jpillora/backoff v1.0.0 // indirect
|
||||||
github.com/json-iterator/go v1.1.12 // indirect
|
github.com/json-iterator/go v1.1.12 // indirect
|
||||||
github.com/klauspost/compress v1.18.0 // indirect
|
github.com/klauspost/compress v1.18.1 // indirect
|
||||||
github.com/lann/builder v0.0.0-20180802200727-47ae307949d0 // indirect
|
github.com/lann/builder v0.0.0-20180802200727-47ae307949d0 // indirect
|
||||||
github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0 // indirect
|
github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0 // indirect
|
||||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||||
@@ -79,25 +100,28 @@ require (
|
|||||||
github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f // indirect
|
github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f // indirect
|
||||||
github.com/nats-io/nkeys v0.4.11 // indirect
|
github.com/nats-io/nkeys v0.4.11 // indirect
|
||||||
github.com/nats-io/nuid v1.0.1 // indirect
|
github.com/nats-io/nuid v1.0.1 // indirect
|
||||||
|
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||||
github.com/prometheus/client_model v0.6.2 // indirect
|
github.com/prometheus/client_model v0.6.2 // indirect
|
||||||
github.com/prometheus/procfs v0.16.1 // indirect
|
github.com/prometheus/procfs v0.16.1 // indirect
|
||||||
github.com/robfig/cron/v3 v3.0.1 // indirect
|
github.com/robfig/cron/v3 v3.0.1 // indirect
|
||||||
github.com/russross/blackfriday/v2 v2.1.0 // indirect
|
github.com/russross/blackfriday/v2 v2.1.0 // indirect
|
||||||
github.com/sosodev/duration v1.3.1 // indirect
|
github.com/sosodev/duration v1.3.1 // indirect
|
||||||
|
github.com/stretchr/objx v0.5.2 // indirect
|
||||||
github.com/swaggo/files v1.0.1 // indirect
|
github.com/swaggo/files v1.0.1 // indirect
|
||||||
github.com/urfave/cli/v2 v2.27.7 // indirect
|
github.com/urfave/cli/v2 v2.27.7 // indirect
|
||||||
|
github.com/urfave/cli/v3 v3.6.1 // indirect
|
||||||
github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342 // indirect
|
github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342 // indirect
|
||||||
go.uber.org/atomic v1.11.0 // indirect
|
go.uber.org/atomic v1.11.0 // indirect
|
||||||
go.yaml.in/yaml/v2 v2.4.3 // indirect
|
go.yaml.in/yaml/v2 v2.4.3 // indirect
|
||||||
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
||||||
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect
|
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect
|
||||||
golang.org/x/mod v0.29.0 // indirect
|
golang.org/x/mod v0.30.0 // indirect
|
||||||
golang.org/x/net v0.46.0 // indirect
|
golang.org/x/net v0.47.0 // indirect
|
||||||
golang.org/x/sync v0.17.0 // indirect
|
golang.org/x/sync v0.18.0 // indirect
|
||||||
golang.org/x/sys v0.37.0 // indirect
|
golang.org/x/sys v0.38.0 // indirect
|
||||||
golang.org/x/text v0.30.0 // indirect
|
golang.org/x/text v0.31.0 // indirect
|
||||||
golang.org/x/tools v0.38.0 // indirect
|
golang.org/x/tools v0.39.0 // indirect
|
||||||
google.golang.org/protobuf v1.36.9 // indirect
|
google.golang.org/protobuf v1.36.10 // indirect
|
||||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||||
sigs.k8s.io/yaml v1.6.0 // indirect
|
sigs.k8s.io/yaml v1.6.0 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
|
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
|
||||||
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
|
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
|
||||||
github.com/99designs/gqlgen v0.17.81 h1:kCkN/xVyRb5rEQpuwOHRTYq83i0IuTQg9vdIiwEerTs=
|
github.com/99designs/gqlgen v0.17.84 h1:iVMdiStgUVx/BFkMb0J5GAXlqfqtQ7bqMCYK6v52kQ0=
|
||||||
github.com/99designs/gqlgen v0.17.81/go.mod h1:vgNcZlLwemsUhYim4dC1pvFP5FX0pr2Y+uYUoHFb1ig=
|
github.com/99designs/gqlgen v0.17.84/go.mod h1:qjoUqzTeiejdo+bwUg8unqSpeYG42XrcrQboGIezmFA=
|
||||||
github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 h1:L/gRVlceqvL25UVaW/CKtUDjefjrs0SPonmDGUVOYP0=
|
github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 h1:L/gRVlceqvL25UVaW/CKtUDjefjrs0SPonmDGUVOYP0=
|
||||||
github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
|
github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
|
||||||
github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358 h1:mFRzDkZVAjdal+s7s0MwaRv9igoPqLRdzOLzw/8Xvq8=
|
github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358 h1:mFRzDkZVAjdal+s7s0MwaRv9igoPqLRdzOLzw/8Xvq8=
|
||||||
github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358/go.mod h1:chxPXzSsl7ZWRAuOIE23GDNzjWuZquvFlgA8xmpunjU=
|
github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358/go.mod h1:chxPXzSsl7ZWRAuOIE23GDNzjWuZquvFlgA8xmpunjU=
|
||||||
github.com/ClusterCockpit/cc-lib v0.10.1 h1:tjGEH8mFGgznYxO8BKLiiar0eZR1Oytk8x5iIQHZR5s=
|
github.com/ClusterCockpit/cc-lib v0.10.2 h1:0sYW34EEfVLcdmHcZ1uErZepgwcu+HQg38WkBslQjqg=
|
||||||
github.com/ClusterCockpit/cc-lib v0.10.1/go.mod h1:nvTZuxFCTwlos8I1rL5O1RPab7vRtkU8E/PGiaF6pQA=
|
github.com/ClusterCockpit/cc-lib v0.10.2/go.mod h1:0LKjDJs813/NMmaSJXJc11A9rxiFDPV/QdWQbZUp0XY=
|
||||||
github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc=
|
github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc=
|
||||||
github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE=
|
github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE=
|
||||||
github.com/Masterminds/squirrel v1.5.4 h1:uUcX/aBc8O7Fg9kaISIUsHXdKuqehiXAMQTYX8afzqM=
|
github.com/Masterminds/squirrel v1.5.4 h1:uUcX/aBc8O7Fg9kaISIUsHXdKuqehiXAMQTYX8afzqM=
|
||||||
@@ -16,8 +16,8 @@ github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERo
|
|||||||
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
|
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
|
||||||
github.com/NVIDIA/go-nvml v0.13.0-1 h1:OLX8Jq3dONuPOQPC7rndB6+iDmDakw0XTYgzMxObkEw=
|
github.com/NVIDIA/go-nvml v0.13.0-1 h1:OLX8Jq3dONuPOQPC7rndB6+iDmDakw0XTYgzMxObkEw=
|
||||||
github.com/NVIDIA/go-nvml v0.13.0-1/go.mod h1:+KNA7c7gIBH7SKSJ1ntlwkfN80zdx8ovl4hrK3LmPt4=
|
github.com/NVIDIA/go-nvml v0.13.0-1/go.mod h1:+KNA7c7gIBH7SKSJ1ntlwkfN80zdx8ovl4hrK3LmPt4=
|
||||||
github.com/PuerkitoBio/goquery v1.10.3 h1:pFYcNSqHxBD06Fpj/KsbStFRsgRATgnf3LeXiUkhzPo=
|
github.com/PuerkitoBio/goquery v1.11.0 h1:jZ7pwMQXIITcUXNH83LLk+txlaEy6NVOfTuP43xxfqw=
|
||||||
github.com/PuerkitoBio/goquery v1.10.3/go.mod h1:tMUX0zDMHXYlAQk6p35XxQMqMweEKB7iK7iLNd4RH4Y=
|
github.com/PuerkitoBio/goquery v1.11.0/go.mod h1:wQHgxUOU3JGuj3oD/QFfxUdlzW6xPHfqyHre6VMY4DQ=
|
||||||
github.com/agnivade/levenshtein v1.2.1 h1:EHBY3UOn1gwdy/VbFwgo4cxecRznFk7fKWN1KOX7eoM=
|
github.com/agnivade/levenshtein v1.2.1 h1:EHBY3UOn1gwdy/VbFwgo4cxecRznFk7fKWN1KOX7eoM=
|
||||||
github.com/agnivade/levenshtein v1.2.1/go.mod h1:QVVI16kDrtSuwcpd0p1+xMC6Z/VfhtCyDIjcwga4/DU=
|
github.com/agnivade/levenshtein v1.2.1/go.mod h1:QVVI16kDrtSuwcpd0p1+xMC6Z/VfhtCyDIjcwga4/DU=
|
||||||
github.com/alexbrainman/sspi v0.0.0-20250919150558-7d374ff0d59e h1:4dAU9FXIyQktpoUAgOJK3OTFc/xug0PCXYCqU0FgDKI=
|
github.com/alexbrainman/sspi v0.0.0-20250919150558-7d374ff0d59e h1:4dAU9FXIyQktpoUAgOJK3OTFc/xug0PCXYCqU0FgDKI=
|
||||||
@@ -30,6 +30,42 @@ github.com/apapsch/go-jsonmerge/v2 v2.0.0 h1:axGnT1gRIfimI7gJifB699GoE/oq+F2MU7D
|
|||||||
github.com/apapsch/go-jsonmerge/v2 v2.0.0/go.mod h1:lvDnEdqiQrp0O42VQGgmlKpxL1AP2+08jFMw88y4klk=
|
github.com/apapsch/go-jsonmerge/v2 v2.0.0/go.mod h1:lvDnEdqiQrp0O42VQGgmlKpxL1AP2+08jFMw88y4klk=
|
||||||
github.com/arbovm/levenshtein v0.0.0-20160628152529-48b4e1c0c4d0 h1:jfIu9sQUG6Ig+0+Ap1h4unLjW6YQJpKZVmUzxsD4E/Q=
|
github.com/arbovm/levenshtein v0.0.0-20160628152529-48b4e1c0c4d0 h1:jfIu9sQUG6Ig+0+Ap1h4unLjW6YQJpKZVmUzxsD4E/Q=
|
||||||
github.com/arbovm/levenshtein v0.0.0-20160628152529-48b4e1c0c4d0/go.mod h1:t2tdKJDJF9BV14lnkjHmOQgcvEKgtqs5a1N3LNdJhGE=
|
github.com/arbovm/levenshtein v0.0.0-20160628152529-48b4e1c0c4d0/go.mod h1:t2tdKJDJF9BV14lnkjHmOQgcvEKgtqs5a1N3LNdJhGE=
|
||||||
|
github.com/aws/aws-sdk-go-v2 v1.39.6 h1:2JrPCVgWJm7bm83BDwY5z8ietmeJUbh3O2ACnn+Xsqk=
|
||||||
|
github.com/aws/aws-sdk-go-v2 v1.39.6/go.mod h1:c9pm7VwuW0UPxAEYGyTmyurVcNrbF6Rt/wixFqDhcjE=
|
||||||
|
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.3 h1:DHctwEM8P8iTXFxC/QK0MRjwEpWQeM9yzidCRjldUz0=
|
||||||
|
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.3/go.mod h1:xdCzcZEtnSTKVDOmUZs4l/j3pSV6rpo1WXl5ugNsL8Y=
|
||||||
|
github.com/aws/aws-sdk-go-v2/config v1.31.20 h1:/jWF4Wu90EhKCgjTdy1DGxcbcbNrjfBHvksEL79tfQc=
|
||||||
|
github.com/aws/aws-sdk-go-v2/config v1.31.20/go.mod h1:95Hh1Tc5VYKL9NJ7tAkDcqeKt+MCXQB1hQZaRdJIZE0=
|
||||||
|
github.com/aws/aws-sdk-go-v2/credentials v1.18.24 h1:iJ2FmPT35EaIB0+kMa6TnQ+PwG5A1prEdAw+PsMzfHg=
|
||||||
|
github.com/aws/aws-sdk-go-v2/credentials v1.18.24/go.mod h1:U91+DrfjAiXPDEGYhh/x29o4p0qHX5HDqG7y5VViv64=
|
||||||
|
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.13 h1:T1brd5dR3/fzNFAQch/iBKeX07/ffu/cLu+q+RuzEWk=
|
||||||
|
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.13/go.mod h1:Peg/GBAQ6JDt+RoBf4meB1wylmAipb7Kg2ZFakZTlwk=
|
||||||
|
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.13 h1:a+8/MLcWlIxo1lF9xaGt3J/u3yOZx+CdSveSNwjhD40=
|
||||||
|
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.13/go.mod h1:oGnKwIYZ4XttyU2JWxFrwvhF6YKiK/9/wmE3v3Iu9K8=
|
||||||
|
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.13 h1:HBSI2kDkMdWz4ZM7FjwE7e/pWDEZ+nR95x8Ztet1ooY=
|
||||||
|
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.13/go.mod h1:YE94ZoDArI7awZqJzBAZ3PDD2zSfuP7w6P2knOzIn8M=
|
||||||
|
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 h1:WKuaxf++XKWlHWu9ECbMlha8WOEGm0OUEZqm4K/Gcfk=
|
||||||
|
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4/go.mod h1:ZWy7j6v1vWGmPReu0iSGvRiise4YI5SkR3OHKTZ6Wuc=
|
||||||
|
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.13 h1:eg/WYAa12vqTphzIdWMzqYRVKKnCboVPRlvaybNCqPA=
|
||||||
|
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.13/go.mod h1:/FDdxWhz1486obGrKKC1HONd7krpk38LBt+dutLcN9k=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.3 h1:x2Ibm/Af8Fi+BH+Hsn9TXGdT+hKbDd5XOTZxTMxDk7o=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.3/go.mod h1:IW1jwyrQgMdhisceG8fQLmQIydcT/jWY21rFhzgaKwo=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.4 h1:NvMjwvv8hpGUILarKw7Z4Q0w1H9anXKsesMxtw++MA4=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.4/go.mod h1:455WPHSwaGj2waRSpQp7TsnpOnBfw8iDfPfbwl7KPJE=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.13 h1:kDqdFvMY4AtKoACfzIGD8A0+hbT41KTKF//gq7jITfM=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.13/go.mod h1:lmKuogqSU3HzQCwZ9ZtcqOc5XGMqtDK7OIc2+DxiUEg=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.13 h1:zhBJXdhWIFZ1acfDYIhu4+LCzdUS2Vbcum7D01dXlHQ=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.13/go.mod h1:JaaOeCE368qn2Hzi3sEzY6FgAZVCIYcC2nwbro2QCh8=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/s3 v1.90.2 h1:DhdbtDl4FdNlj31+xiRXANxEE+eC7n8JQz+/ilwQ8Uc=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/s3 v1.90.2/go.mod h1:+wArOOrcHUevqdto9k1tKOF5++YTe9JEcPSc9Tx2ZSw=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/sso v1.30.3 h1:NjShtS1t8r5LUfFVtFeI8xLAHQNTa7UI0VawXlrBMFQ=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/sso v1.30.3/go.mod h1:fKvyjJcz63iL/ftA6RaM8sRCtN4r4zl4tjL3qw5ec7k=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.7 h1:gTsnx0xXNQ6SBbymoDvcoRHL+q4l/dAFsQuKfDWSaGc=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.7/go.mod h1:klO+ejMvYsB4QATfEOIXk8WAEwN4N0aBfJpvC+5SZBo=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/sts v1.40.2 h1:HK5ON3KmQV2HcAunnx4sKLB9aPf3gKGwVAf7xnx0QT0=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/sts v1.40.2/go.mod h1:E19xDjpzPZC7LS2knI9E6BaRFDK43Eul7vd6rSq2HWk=
|
||||||
|
github.com/aws/smithy-go v1.23.2 h1:Crv0eatJUQhaManss33hS5r40CG3ZFH+21XSkqMrIUM=
|
||||||
|
github.com/aws/smithy-go v1.23.2/go.mod h1:LEj2LM3rBRQJxPZTB4KuzZkaZYnZPnvgIhb4pu07mx0=
|
||||||
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
||||||
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
|
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
|
||||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||||
@@ -66,8 +102,8 @@ github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S
|
|||||||
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
|
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
|
||||||
github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 h1:BP4M0CvQ4S3TGls2FvczZtj5Re/2ZzkV9VwqPHH/3Bo=
|
github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 h1:BP4M0CvQ4S3TGls2FvczZtj5Re/2ZzkV9VwqPHH/3Bo=
|
||||||
github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0=
|
github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0=
|
||||||
github.com/go-co-op/gocron/v2 v2.16.0 h1:uqUF6WFZ4enRU45pWFNcn1xpDLc+jBOTKhPQI16Z1xs=
|
github.com/go-co-op/gocron/v2 v2.17.0 h1:e/oj6fcAM8vOOKZxv2Cgfmjo+s8AXC46po5ZPtaSea4=
|
||||||
github.com/go-co-op/gocron/v2 v2.16.0/go.mod h1:opexeOFy5BplhsKdA7bzY9zeYih8I8/WNJ4arTIFPVc=
|
github.com/go-co-op/gocron/v2 v2.17.0/go.mod h1:Zii6he+Zfgy5W9B+JKk/KwejFOW0kZTFvHtwIpR4aBI=
|
||||||
github.com/go-jose/go-jose/v4 v4.1.3 h1:CVLmWDhDVRa6Mi/IgCgaopNosCaHz7zrMeF9MlZRkrs=
|
github.com/go-jose/go-jose/v4 v4.1.3 h1:CVLmWDhDVRa6Mi/IgCgaopNosCaHz7zrMeF9MlZRkrs=
|
||||||
github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
|
github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
|
||||||
github.com/go-ldap/ldap/v3 v3.4.12 h1:1b81mv7MagXZ7+1r7cLTWmyuTqVqdwbtJSjC0DAp9s4=
|
github.com/go-ldap/ldap/v3 v3.4.12 h1:1b81mv7MagXZ7+1r7cLTWmyuTqVqdwbtJSjC0DAp9s4=
|
||||||
@@ -76,35 +112,41 @@ github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
|
|||||||
github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||||
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||||
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
||||||
github.com/go-openapi/jsonpointer v0.22.1 h1:sHYI1He3b9NqJ4wXLoJDKmUmHkWy/L7rtEo92JUxBNk=
|
github.com/go-openapi/jsonpointer v0.22.3 h1:dKMwfV4fmt6Ah90zloTbUKWMD+0he+12XYAsPotrkn8=
|
||||||
github.com/go-openapi/jsonpointer v0.22.1/go.mod h1:pQT9OsLkfz1yWoMgYFy4x3U5GY5nUlsOn1qSBH5MkCM=
|
github.com/go-openapi/jsonpointer v0.22.3/go.mod h1:0lBbqeRsQ5lIanv3LHZBrmRGHLHcQoOXQnf88fHlGWo=
|
||||||
github.com/go-openapi/jsonreference v0.21.2 h1:Wxjda4M/BBQllegefXrY/9aq1fxBA8sI5M/lFU6tSWU=
|
github.com/go-openapi/jsonreference v0.21.3 h1:96Dn+MRPa0nYAR8DR1E03SblB5FJvh7W6krPI0Z7qMc=
|
||||||
github.com/go-openapi/jsonreference v0.21.2/go.mod h1:pp3PEjIsJ9CZDGCNOyXIQxsNuroxm8FAJ/+quA0yKzQ=
|
github.com/go-openapi/jsonreference v0.21.3/go.mod h1:RqkUP0MrLf37HqxZxrIAtTWW4ZJIK1VzduhXYBEeGc4=
|
||||||
github.com/go-openapi/spec v0.22.0 h1:xT/EsX4frL3U09QviRIZXvkh80yibxQmtoEvyqug0Tw=
|
github.com/go-openapi/spec v0.22.1 h1:beZMa5AVQzRspNjvhe5aG1/XyBSMeX1eEOs7dMoXh/k=
|
||||||
github.com/go-openapi/spec v0.22.0/go.mod h1:K0FhKxkez8YNS94XzF8YKEMULbFrRw4m15i2YUht4L0=
|
github.com/go-openapi/spec v0.22.1/go.mod h1:c7aeIQT175dVowfp7FeCvXXnjN/MrpaONStibD2WtDA=
|
||||||
github.com/go-openapi/swag v0.19.15 h1:D2NRCBzS9/pEY3gP9Nl8aDqGUcPFrwG2p+CNFrLyrCM=
|
github.com/go-openapi/swag v0.19.15 h1:D2NRCBzS9/pEY3gP9Nl8aDqGUcPFrwG2p+CNFrLyrCM=
|
||||||
github.com/go-openapi/swag/conv v0.25.1 h1:+9o8YUg6QuqqBM5X6rYL/p1dpWeZRhoIt9x7CCP+he0=
|
github.com/go-openapi/swag/conv v0.25.4 h1:/Dd7p0LZXczgUcC/Ikm1+YqVzkEeCc9LnOWjfkpkfe4=
|
||||||
github.com/go-openapi/swag/conv v0.25.1/go.mod h1:Z1mFEGPfyIKPu0806khI3zF+/EUXde+fdeksUl2NiDs=
|
github.com/go-openapi/swag/conv v0.25.4/go.mod h1:3LXfie/lwoAv0NHoEuY1hjoFAYkvlqI/Bn5EQDD3PPU=
|
||||||
github.com/go-openapi/swag/jsonname v0.25.1 h1:Sgx+qbwa4ej6AomWC6pEfXrA6uP2RkaNjA9BR8a1RJU=
|
github.com/go-openapi/swag/jsonname v0.25.4 h1:bZH0+MsS03MbnwBXYhuTttMOqk+5KcQ9869Vye1bNHI=
|
||||||
github.com/go-openapi/swag/jsonname v0.25.1/go.mod h1:71Tekow6UOLBD3wS7XhdT98g5J5GR13NOTQ9/6Q11Zo=
|
github.com/go-openapi/swag/jsonname v0.25.4/go.mod h1:GPVEk9CWVhNvWhZgrnvRA6utbAltopbKwDu8mXNUMag=
|
||||||
github.com/go-openapi/swag/jsonutils v0.25.1 h1:AihLHaD0brrkJoMqEZOBNzTLnk81Kg9cWr+SPtxtgl8=
|
github.com/go-openapi/swag/jsonutils v0.25.4 h1:VSchfbGhD4UTf4vCdR2F4TLBdLwHyUDTd1/q4i+jGZA=
|
||||||
github.com/go-openapi/swag/jsonutils v0.25.1/go.mod h1:JpEkAjxQXpiaHmRO04N1zE4qbUEg3b7Udll7AMGTNOo=
|
github.com/go-openapi/swag/jsonutils v0.25.4/go.mod h1:7OYGXpvVFPn4PpaSdPHJBtF0iGnbEaTk8AvBkoWnaAY=
|
||||||
github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.1 h1:DSQGcdB6G0N9c/KhtpYc71PzzGEIc/fZ1no35x4/XBY=
|
github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.4 h1:IACsSvBhiNJwlDix7wq39SS2Fh7lUOCJRmx/4SN4sVo=
|
||||||
github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.1/go.mod h1:kjmweouyPwRUEYMSrbAidoLMGeJ5p6zdHi9BgZiqmsg=
|
github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.4/go.mod h1:Mt0Ost9l3cUzVv4OEZG+WSeoHwjWLnarzMePNDAOBiM=
|
||||||
github.com/go-openapi/swag/loading v0.25.1 h1:6OruqzjWoJyanZOim58iG2vj934TysYVptyaoXS24kw=
|
github.com/go-openapi/swag/loading v0.25.4 h1:jN4MvLj0X6yhCDduRsxDDw1aHe+ZWoLjW+9ZQWIKn2s=
|
||||||
github.com/go-openapi/swag/loading v0.25.1/go.mod h1:xoIe2EG32NOYYbqxvXgPzne989bWvSNoWoyQVWEZicc=
|
github.com/go-openapi/swag/loading v0.25.4/go.mod h1:rpUM1ZiyEP9+mNLIQUdMiD7dCETXvkkC30z53i+ftTE=
|
||||||
github.com/go-openapi/swag/stringutils v0.25.1 h1:Xasqgjvk30eUe8VKdmyzKtjkVjeiXx1Iz0zDfMNpPbw=
|
github.com/go-openapi/swag/stringutils v0.25.4 h1:O6dU1Rd8bej4HPA3/CLPciNBBDwZj9HiEpdVsb8B5A8=
|
||||||
github.com/go-openapi/swag/stringutils v0.25.1/go.mod h1:JLdSAq5169HaiDUbTvArA2yQxmgn4D6h4A+4HqVvAYg=
|
github.com/go-openapi/swag/stringutils v0.25.4/go.mod h1:GTsRvhJW5xM5gkgiFe0fV3PUlFm0dr8vki6/VSRaZK0=
|
||||||
github.com/go-openapi/swag/typeutils v0.25.1 h1:rD/9HsEQieewNt6/k+JBwkxuAHktFtH3I3ysiFZqukA=
|
github.com/go-openapi/swag/typeutils v0.25.4 h1:1/fbZOUN472NTc39zpa+YGHn3jzHWhv42wAJSN91wRw=
|
||||||
github.com/go-openapi/swag/typeutils v0.25.1/go.mod h1:9McMC/oCdS4BKwk2shEB7x17P6HmMmA6dQRtAkSnNb8=
|
github.com/go-openapi/swag/typeutils v0.25.4/go.mod h1:Ou7g//Wx8tTLS9vG0UmzfCsjZjKhpjxayRKTHXf2pTE=
|
||||||
github.com/go-openapi/swag/yamlutils v0.25.1 h1:mry5ez8joJwzvMbaTGLhw8pXUnhDK91oSJLDPF1bmGk=
|
github.com/go-openapi/swag/yamlutils v0.25.4 h1:6jdaeSItEUb7ioS9lFoCZ65Cne1/RZtPBZ9A56h92Sw=
|
||||||
github.com/go-openapi/swag/yamlutils v0.25.1/go.mod h1:cm9ywbzncy3y6uPm/97ysW8+wZ09qsks+9RS8fLWKqg=
|
github.com/go-openapi/swag/yamlutils v0.25.4/go.mod h1:MNzq1ulQu+yd8Kl7wPOut/YHAAU/H6hL91fF+E2RFwc=
|
||||||
|
github.com/go-openapi/testify/enable/yaml/v2 v2.0.2 h1:0+Y41Pz1NkbTHz8NngxTuAXxEodtNSI1WG1c/m5Akw4=
|
||||||
|
github.com/go-openapi/testify/enable/yaml/v2 v2.0.2/go.mod h1:kme83333GCtJQHXQ8UKX3IBZu6z8T5Dvy5+CW3NLUUg=
|
||||||
|
github.com/go-openapi/testify/v2 v2.0.2 h1:X999g3jeLcoY8qctY/c/Z8iBHTbwLz7R2WXd6Ub6wls=
|
||||||
|
github.com/go-openapi/testify/v2 v2.0.2/go.mod h1:HCPmvFFnheKK2BuwSA0TbbdxJ3I16pjwMkYkP4Ywn54=
|
||||||
github.com/go-sql-driver/mysql v1.4.1/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w=
|
github.com/go-sql-driver/mysql v1.4.1/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w=
|
||||||
github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg=
|
github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg=
|
||||||
github.com/go-sql-driver/mysql v1.9.0 h1:Y0zIbQXhQKmQgTp44Y1dp3wTXcn804QoTptLZT1vtvo=
|
github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo=
|
||||||
github.com/go-sql-driver/mysql v1.9.0/go.mod h1:pDetrLJeA3oMujJuvXc8RJoasr589B6A9fwzD3QMrqw=
|
github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU=
|
||||||
github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
|
github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
|
||||||
github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
|
github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
|
||||||
|
github.com/goccy/go-yaml v1.19.0 h1:EmkZ9RIsX+Uq4DYFowegAuJo8+xdX3T/2dwNPXbxEYE=
|
||||||
|
github.com/goccy/go-yaml v1.19.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
|
||||||
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
|
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
|
||||||
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
|
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
|
||||||
github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8=
|
github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8=
|
||||||
@@ -177,8 +219,8 @@ github.com/jpillora/backoff v1.0.0 h1:uvFg412JmmHBHw7iwprIxkPMI+sGQ4kzOWsMeHnm2E
|
|||||||
github.com/jpillora/backoff v1.0.0/go.mod h1:J/6gKK9jxlEcS3zixgDgUAsiuZ7yrSoa/FX5e0EB2j4=
|
github.com/jpillora/backoff v1.0.0/go.mod h1:J/6gKK9jxlEcS3zixgDgUAsiuZ7yrSoa/FX5e0EB2j4=
|
||||||
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||||
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
|
github.com/klauspost/compress v1.18.1 h1:bcSGx7UbpBqMChDtsF28Lw6v/G94LPrrbMbdC3JH2co=
|
||||||
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
|
github.com/klauspost/compress v1.18.1/go.mod h1:ZQFFVG+MdnR0P+l6wpXgIL4NTtwiKIdBnrBd8Nrxr+0=
|
||||||
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
|
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
|
||||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||||
@@ -197,8 +239,8 @@ github.com/linkedin/goavro/v2 v2.14.0 h1:aNO/js65U+Mwq4yB5f1h01c3wiM458qtRad1DN0
|
|||||||
github.com/linkedin/goavro/v2 v2.14.0/go.mod h1:KXx+erlq+RPlGSPmLF7xGo6SAbh8sCQ53x064+ioxhk=
|
github.com/linkedin/goavro/v2 v2.14.0/go.mod h1:KXx+erlq+RPlGSPmLF7xGo6SAbh8sCQ53x064+ioxhk=
|
||||||
github.com/mattn/go-sqlite3 v1.10.0/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc=
|
github.com/mattn/go-sqlite3 v1.10.0/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc=
|
||||||
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||||
github.com/mattn/go-sqlite3 v1.14.24 h1:tpSp2G2KyMnnQu99ngJ47EIkWVmliIizyZBfPrBWDRM=
|
github.com/mattn/go-sqlite3 v1.14.32 h1:JD12Ag3oLy1zQA+BNn74xRgaBbdhbNIDYvQUEuuErjs=
|
||||||
github.com/mattn/go-sqlite3 v1.14.24/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
github.com/mattn/go-sqlite3 v1.14.32/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||||
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
|
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
|
||||||
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
|
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
|
||||||
github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0=
|
github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0=
|
||||||
@@ -244,8 +286,8 @@ github.com/qustavo/sqlhooks/v2 v2.1.0 h1:54yBemHnGHp/7xgT+pxwmIlMSDNYKx5JW5dfRAi
|
|||||||
github.com/qustavo/sqlhooks/v2 v2.1.0/go.mod h1:aMREyKo7fOKTwiLuWPsaHRXEmtqG4yREztO0idF83AU=
|
github.com/qustavo/sqlhooks/v2 v2.1.0/go.mod h1:aMREyKo7fOKTwiLuWPsaHRXEmtqG4yREztO0idF83AU=
|
||||||
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
|
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
|
||||||
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
|
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
|
||||||
github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8=
|
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||||
github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4=
|
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
||||||
github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=
|
github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=
|
||||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||||
github.com/santhosh-tekuri/jsonschema/v5 v5.3.1 h1:lZUw3E0/J3roVtGQ+SCrUrg3ON6NgVqpn3+iol9aGu4=
|
github.com/santhosh-tekuri/jsonschema/v5 v5.3.1 h1:lZUw3E0/J3roVtGQ+SCrUrg3ON6NgVqpn3+iol9aGu4=
|
||||||
@@ -256,6 +298,8 @@ github.com/sosodev/duration v1.3.1 h1:qtHBDMQ6lvMQsL15g4aopM4HEfOaYuhWBw3NPTtlqq
|
|||||||
github.com/sosodev/duration v1.3.1/go.mod h1:RQIBBX0+fMLc/D9+Jb/fwvVmo0eZvDDEERAikUR6SDg=
|
github.com/sosodev/duration v1.3.1/go.mod h1:RQIBBX0+fMLc/D9+Jb/fwvVmo0eZvDDEERAikUR6SDg=
|
||||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||||
|
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
|
||||||
|
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||||
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
|
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
|
||||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||||
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
||||||
@@ -271,8 +315,10 @@ github.com/swaggo/swag v1.16.6 h1:qBNcx53ZaX+M5dxVyTrgQ0PJ/ACK+NzhwcbieTt+9yI=
|
|||||||
github.com/swaggo/swag v1.16.6/go.mod h1:ngP2etMK5a0P3QBizic5MEwpRmluJZPHjXcMoj4Xesg=
|
github.com/swaggo/swag v1.16.6/go.mod h1:ngP2etMK5a0P3QBizic5MEwpRmluJZPHjXcMoj4Xesg=
|
||||||
github.com/urfave/cli/v2 v2.27.7 h1:bH59vdhbjLv3LAvIu6gd0usJHgoTTPhCFib8qqOwXYU=
|
github.com/urfave/cli/v2 v2.27.7 h1:bH59vdhbjLv3LAvIu6gd0usJHgoTTPhCFib8qqOwXYU=
|
||||||
github.com/urfave/cli/v2 v2.27.7/go.mod h1:CyNAG/xg+iAOg0N4MPGZqVmv2rCoP267496AOXUZjA4=
|
github.com/urfave/cli/v2 v2.27.7/go.mod h1:CyNAG/xg+iAOg0N4MPGZqVmv2rCoP267496AOXUZjA4=
|
||||||
github.com/vektah/gqlparser/v2 v2.5.30 h1:EqLwGAFLIzt1wpx1IPpY67DwUujF1OfzgEyDsLrN6kE=
|
github.com/urfave/cli/v3 v3.6.1 h1:j8Qq8NyUawj/7rTYdBGrxcH7A/j7/G8Q5LhWEW4G3Mo=
|
||||||
github.com/vektah/gqlparser/v2 v2.5.30/go.mod h1:D1/VCZtV3LPnQrcPBeR/q5jkSQIPti0uYCP/RI0gIeo=
|
github.com/urfave/cli/v3 v3.6.1/go.mod h1:ysVLtOEmg2tOy6PknnYVhDoouyC/6N42TMeoMzskhso=
|
||||||
|
github.com/vektah/gqlparser/v2 v2.5.31 h1:YhWGA1mfTjID7qJhd1+Vxhpk5HTgydrGU9IgkWBTJ7k=
|
||||||
|
github.com/vektah/gqlparser/v2 v2.5.31/go.mod h1:c1I28gSOVNzlfc4WuDlqU7voQnsqI6OG2amkBAFmgts=
|
||||||
github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342 h1:FnBeRrxr7OU4VvAzt5X7s6266i6cSVkkFPS0TuXWbIg=
|
github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342 h1:FnBeRrxr7OU4VvAzt5X7s6266i6cSVkkFPS0TuXWbIg=
|
||||||
github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342/go.mod h1:Ohn+xnUBiLI6FVj/9LpzZWtj1/D6lUovWYBkxHVV3aM=
|
github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342/go.mod h1:Ohn+xnUBiLI6FVj/9LpzZWtj1/D6lUovWYBkxHVV3aM=
|
||||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||||
@@ -294,33 +340,33 @@ go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
|
|||||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||||
golang.org/x/crypto v0.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04=
|
golang.org/x/crypto v0.44.0 h1:A97SsFvM3AIwEEmTBiaxPPTYpDC47w720rdiiUvgoAU=
|
||||||
golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0=
|
golang.org/x/crypto v0.44.0/go.mod h1:013i+Nw79BMiQiMsOPcVCB5ZIJbYkerPrGnOa00tvmc=
|
||||||
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o=
|
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o=
|
||||||
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8=
|
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8=
|
||||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||||
golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA=
|
golang.org/x/mod v0.30.0 h1:fDEXFVZ/fmCKProc/yAXXUijritrDzahmwwefnjoPFk=
|
||||||
golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w=
|
golang.org/x/mod v0.30.0/go.mod h1:lAsf5O2EvJeSFMiBxXDki7sCgAxEUcZHXoXMKT4GJKc=
|
||||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||||
golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||||
golang.org/x/net v0.46.0 h1:giFlY12I07fugqwPuWJi68oOnpfqFnJIJzaIIm2JVV4=
|
golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
|
||||||
golang.org/x/net v0.46.0/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210=
|
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
|
||||||
golang.org/x/oauth2 v0.32.0 h1:jsCblLleRMDrxMN29H3z/k1KliIvpLgCkE6R8FXXNgY=
|
golang.org/x/oauth2 v0.32.0 h1:jsCblLleRMDrxMN29H3z/k1KliIvpLgCkE6R8FXXNgY=
|
||||||
golang.org/x/oauth2 v0.32.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
|
golang.org/x/oauth2 v0.32.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
|
||||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
|
golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=
|
||||||
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ=
|
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
|
||||||
golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||||
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||||
@@ -328,19 +374,19 @@ golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
|||||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||||
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||||
golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k=
|
golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
|
||||||
golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM=
|
golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
|
||||||
golang.org/x/time v0.13.0 h1:eUlYslOIt32DgYD6utsuUeHs4d7AsEYLuIAdg7FlYgI=
|
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
|
||||||
golang.org/x/time v0.13.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
|
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
|
||||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||||
golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ=
|
golang.org/x/tools v0.39.0 h1:ik4ho21kwuQln40uelmciQPp9SipgNDdrafrYA4TmQQ=
|
||||||
golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs=
|
golang.org/x/tools v0.39.0/go.mod h1:JnefbkDPyD8UU2kI5fuf8ZX4/yUeh9W877ZeBONxUqQ=
|
||||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
google.golang.org/protobuf v1.36.9 h1:w2gp2mA27hUeUzj9Ex9FBjsBm40zfaDtEWow293U7Iw=
|
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
|
||||||
google.golang.org/protobuf v1.36.9/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU=
|
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ func setup(t *testing.T) *api.RestApi {
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
}`
|
}`
|
||||||
const testclusterJson = `{
|
const testclusterJSON = `{
|
||||||
"name": "testcluster",
|
"name": "testcluster",
|
||||||
"subClusters": [
|
"subClusters": [
|
||||||
{
|
{
|
||||||
@@ -128,7 +128,7 @@ func setup(t *testing.T) *api.RestApi {
|
|||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := os.WriteFile(filepath.Join(jobarchive, "version.txt"), fmt.Appendf(nil, "%d", 2), 0o666); err != nil {
|
if err := os.WriteFile(filepath.Join(jobarchive, "version.txt"), fmt.Appendf(nil, "%d", 3), 0o666); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -136,7 +136,7 @@ func setup(t *testing.T) *api.RestApi {
|
|||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := os.WriteFile(filepath.Join(jobarchive, "testcluster", "cluster.json"), []byte(testclusterJson), 0o666); err != nil {
|
if err := os.WriteFile(filepath.Join(jobarchive, "testcluster", "cluster.json"), []byte(testclusterJSON), 0o666); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1423,7 +1423,7 @@ const docTemplate = `{
|
|||||||
"deadline",
|
"deadline",
|
||||||
"failed",
|
"failed",
|
||||||
"node_fail",
|
"node_fail",
|
||||||
"out-of-memory",
|
"out_of_memory",
|
||||||
"pending",
|
"pending",
|
||||||
"preempted",
|
"preempted",
|
||||||
"running",
|
"running",
|
||||||
|
|||||||
+90
-60
@@ -2,6 +2,7 @@
|
|||||||
// All rights reserved. This file is part of cc-backend.
|
// All rights reserved. This file is part of cc-backend.
|
||||||
// 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.
|
||||||
|
|
||||||
package api
|
package api
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -29,9 +30,15 @@ import (
|
|||||||
"github.com/gorilla/mux"
|
"github.com/gorilla/mux"
|
||||||
)
|
)
|
||||||
|
|
||||||
// StopJobApiRequest model
|
const (
|
||||||
type StopJobApiRequest struct {
|
// secondsPerDay is the number of seconds in 24 hours.
|
||||||
JobId *int64 `json:"jobId" example:"123000"`
|
// Used for duplicate job detection within a day window.
|
||||||
|
secondsPerDay = 86400
|
||||||
|
)
|
||||||
|
|
||||||
|
// StopJobAPIRequest model
|
||||||
|
type StopJobAPIRequest struct {
|
||||||
|
JobID *int64 `json:"jobId" example:"123000"`
|
||||||
Cluster *string `json:"cluster" example:"fritz"`
|
Cluster *string `json:"cluster" example:"fritz"`
|
||||||
StartTime *int64 `json:"startTime" example:"1649723812"`
|
StartTime *int64 `json:"startTime" example:"1649723812"`
|
||||||
State schema.JobState `json:"jobState" validate:"required" example:"completed"`
|
State schema.JobState `json:"jobState" validate:"required" example:"completed"`
|
||||||
@@ -40,7 +47,7 @@ type StopJobApiRequest struct {
|
|||||||
|
|
||||||
// DeleteJobApiRequest model
|
// DeleteJobApiRequest model
|
||||||
type DeleteJobApiRequest struct {
|
type DeleteJobApiRequest struct {
|
||||||
JobId *int64 `json:"jobId" validate:"required" example:"123000"` // Cluster Job ID of job
|
JobID *int64 `json:"jobId" validate:"required" example:"123000"` // Cluster Job ID of job
|
||||||
Cluster *string `json:"cluster" example:"fritz"` // Cluster of job
|
Cluster *string `json:"cluster" example:"fritz"` // Cluster of job
|
||||||
StartTime *int64 `json:"startTime" example:"1649723812"` // Start Time of job as epoch
|
StartTime *int64 `json:"startTime" example:"1649723812"` // Start Time of job as epoch
|
||||||
}
|
}
|
||||||
@@ -113,7 +120,8 @@ func (api *RestApi) getJobs(rw http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
for key, vals := range r.URL.Query() {
|
for key, vals := range r.URL.Query() {
|
||||||
switch key {
|
switch key {
|
||||||
// TODO: add project filter
|
case "project":
|
||||||
|
filter.Project = &model.StringInput{Eq: &vals[0]}
|
||||||
case "state":
|
case "state":
|
||||||
for _, s := range vals {
|
for _, s := range vals {
|
||||||
state := schema.JobState(s)
|
state := schema.JobState(s)
|
||||||
@@ -363,7 +371,7 @@ func (api *RestApi) getJobById(rw http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
var metrics GetJobApiRequest
|
var metrics GetJobApiRequest
|
||||||
if err = decode(r.Body, &metrics); err != nil {
|
if err = decode(r.Body, &metrics); err != nil {
|
||||||
http.Error(rw, err.Error(), http.StatusBadRequest)
|
handleError(fmt.Errorf("decoding request failed: %w", err), http.StatusBadRequest, rw)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -434,30 +442,32 @@ func (api *RestApi) getJobById(rw http.ResponseWriter, r *http.Request) {
|
|||||||
func (api *RestApi) editMeta(rw http.ResponseWriter, r *http.Request) {
|
func (api *RestApi) editMeta(rw http.ResponseWriter, r *http.Request) {
|
||||||
id, err := strconv.ParseInt(mux.Vars(r)["id"], 10, 64)
|
id, err := strconv.ParseInt(mux.Vars(r)["id"], 10, 64)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(rw, err.Error(), http.StatusBadRequest)
|
handleError(fmt.Errorf("parsing job ID failed: %w", err), http.StatusBadRequest, rw)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
job, err := api.JobRepository.FindById(r.Context(), id)
|
job, err := api.JobRepository.FindById(r.Context(), id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(rw, err.Error(), http.StatusNotFound)
|
handleError(fmt.Errorf("finding job failed: %w", err), http.StatusNotFound, rw)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
var req EditMetaRequest
|
var req EditMetaRequest
|
||||||
if err := decode(r.Body, &req); err != nil {
|
if err := decode(r.Body, &req); err != nil {
|
||||||
http.Error(rw, err.Error(), http.StatusBadRequest)
|
handleError(fmt.Errorf("decoding request failed: %w", err), http.StatusBadRequest, rw)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := api.JobRepository.UpdateMetadata(job, req.Key, req.Value); err != nil {
|
if err := api.JobRepository.UpdateMetadata(job, req.Key, req.Value); err != nil {
|
||||||
http.Error(rw, err.Error(), http.StatusInternalServerError)
|
handleError(fmt.Errorf("updating metadata failed: %w", err), http.StatusInternalServerError, rw)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
rw.Header().Add("Content-Type", "application/json")
|
rw.Header().Add("Content-Type", "application/json")
|
||||||
rw.WriteHeader(http.StatusOK)
|
rw.WriteHeader(http.StatusOK)
|
||||||
json.NewEncoder(rw).Encode(job)
|
if err := json.NewEncoder(rw).Encode(job); err != nil {
|
||||||
|
cclog.Errorf("Failed to encode job response: %v", err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// tagJob godoc
|
// tagJob godoc
|
||||||
@@ -480,32 +490,32 @@ func (api *RestApi) editMeta(rw http.ResponseWriter, r *http.Request) {
|
|||||||
func (api *RestApi) tagJob(rw http.ResponseWriter, r *http.Request) {
|
func (api *RestApi) tagJob(rw http.ResponseWriter, r *http.Request) {
|
||||||
id, err := strconv.ParseInt(mux.Vars(r)["id"], 10, 64)
|
id, err := strconv.ParseInt(mux.Vars(r)["id"], 10, 64)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(rw, err.Error(), http.StatusBadRequest)
|
handleError(fmt.Errorf("parsing job ID failed: %w", err), http.StatusBadRequest, rw)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
job, err := api.JobRepository.FindById(r.Context(), id)
|
job, err := api.JobRepository.FindById(r.Context(), id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(rw, err.Error(), http.StatusNotFound)
|
handleError(fmt.Errorf("finding job failed: %w", err), http.StatusNotFound, rw)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
job.Tags, err = api.JobRepository.GetTags(repository.GetUserFromContext(r.Context()), job.ID)
|
job.Tags, err = api.JobRepository.GetTags(repository.GetUserFromContext(r.Context()), job.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(rw, err.Error(), http.StatusInternalServerError)
|
handleError(fmt.Errorf("getting tags failed: %w", err), http.StatusInternalServerError, rw)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
var req TagJobApiRequest
|
var req TagJobApiRequest
|
||||||
if err := decode(r.Body, &req); err != nil {
|
if err := decode(r.Body, &req); err != nil {
|
||||||
http.Error(rw, err.Error(), http.StatusBadRequest)
|
handleError(fmt.Errorf("decoding request failed: %w", err), http.StatusBadRequest, rw)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, tag := range req {
|
for _, tag := range req {
|
||||||
tagId, err := api.JobRepository.AddTagOrCreate(repository.GetUserFromContext(r.Context()), *job.ID, tag.Type, tag.Name, tag.Scope)
|
tagId, err := api.JobRepository.AddTagOrCreate(repository.GetUserFromContext(r.Context()), *job.ID, tag.Type, tag.Name, tag.Scope)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(rw, err.Error(), http.StatusInternalServerError)
|
handleError(fmt.Errorf("adding tag failed: %w", err), http.StatusInternalServerError, rw)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -519,7 +529,9 @@ func (api *RestApi) tagJob(rw http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
rw.Header().Add("Content-Type", "application/json")
|
rw.Header().Add("Content-Type", "application/json")
|
||||||
rw.WriteHeader(http.StatusOK)
|
rw.WriteHeader(http.StatusOK)
|
||||||
json.NewEncoder(rw).Encode(job)
|
if err := json.NewEncoder(rw).Encode(job); err != nil {
|
||||||
|
cclog.Errorf("Failed to encode job response: %v", err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// removeTagJob godoc
|
// removeTagJob godoc
|
||||||
@@ -542,25 +554,25 @@ func (api *RestApi) tagJob(rw http.ResponseWriter, r *http.Request) {
|
|||||||
func (api *RestApi) removeTagJob(rw http.ResponseWriter, r *http.Request) {
|
func (api *RestApi) removeTagJob(rw http.ResponseWriter, r *http.Request) {
|
||||||
id, err := strconv.ParseInt(mux.Vars(r)["id"], 10, 64)
|
id, err := strconv.ParseInt(mux.Vars(r)["id"], 10, 64)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(rw, err.Error(), http.StatusBadRequest)
|
handleError(fmt.Errorf("parsing job ID failed: %w", err), http.StatusBadRequest, rw)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
job, err := api.JobRepository.FindById(r.Context(), id)
|
job, err := api.JobRepository.FindById(r.Context(), id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(rw, err.Error(), http.StatusNotFound)
|
handleError(fmt.Errorf("finding job failed: %w", err), http.StatusNotFound, rw)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
job.Tags, err = api.JobRepository.GetTags(repository.GetUserFromContext(r.Context()), job.ID)
|
job.Tags, err = api.JobRepository.GetTags(repository.GetUserFromContext(r.Context()), job.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(rw, err.Error(), http.StatusInternalServerError)
|
handleError(fmt.Errorf("getting tags failed: %w", err), http.StatusInternalServerError, rw)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
var req TagJobApiRequest
|
var req TagJobApiRequest
|
||||||
if err := decode(r.Body, &req); err != nil {
|
if err := decode(r.Body, &req); err != nil {
|
||||||
http.Error(rw, err.Error(), http.StatusBadRequest)
|
handleError(fmt.Errorf("decoding request failed: %w", err), http.StatusBadRequest, rw)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -573,7 +585,7 @@ func (api *RestApi) removeTagJob(rw http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
remainingTags, err := api.JobRepository.RemoveJobTagByRequest(repository.GetUserFromContext(r.Context()), *job.ID, rtag.Type, rtag.Name, rtag.Scope)
|
remainingTags, err := api.JobRepository.RemoveJobTagByRequest(repository.GetUserFromContext(r.Context()), *job.ID, rtag.Type, rtag.Name, rtag.Scope)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(rw, err.Error(), http.StatusInternalServerError)
|
handleError(fmt.Errorf("removing tag failed: %w", err), http.StatusInternalServerError, rw)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -582,7 +594,9 @@ func (api *RestApi) removeTagJob(rw http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
rw.Header().Add("Content-Type", "application/json")
|
rw.Header().Add("Content-Type", "application/json")
|
||||||
rw.WriteHeader(http.StatusOK)
|
rw.WriteHeader(http.StatusOK)
|
||||||
json.NewEncoder(rw).Encode(job)
|
if err := json.NewEncoder(rw).Encode(job); err != nil {
|
||||||
|
cclog.Errorf("Failed to encode job response: %v", err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// removeTags godoc
|
// removeTags godoc
|
||||||
@@ -604,7 +618,7 @@ func (api *RestApi) removeTagJob(rw http.ResponseWriter, r *http.Request) {
|
|||||||
func (api *RestApi) removeTags(rw http.ResponseWriter, r *http.Request) {
|
func (api *RestApi) removeTags(rw http.ResponseWriter, r *http.Request) {
|
||||||
var req TagJobApiRequest
|
var req TagJobApiRequest
|
||||||
if err := decode(r.Body, &req); err != nil {
|
if err := decode(r.Body, &req); err != nil {
|
||||||
http.Error(rw, err.Error(), http.StatusBadRequest)
|
handleError(fmt.Errorf("decoding request failed: %w", err), http.StatusBadRequest, rw)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -619,11 +633,10 @@ func (api *RestApi) removeTags(rw http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
err := api.JobRepository.RemoveTagByRequest(rtag.Type, rtag.Name, rtag.Scope)
|
err := api.JobRepository.RemoveTagByRequest(rtag.Type, rtag.Name, rtag.Scope)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(rw, err.Error(), http.StatusInternalServerError)
|
handleError(fmt.Errorf("removing tag failed: %w", err), http.StatusInternalServerError, rw)
|
||||||
return
|
return
|
||||||
} else {
|
|
||||||
currentCount++
|
|
||||||
}
|
}
|
||||||
|
currentCount++
|
||||||
}
|
}
|
||||||
|
|
||||||
rw.WriteHeader(http.StatusOK)
|
rw.WriteHeader(http.StatusOK)
|
||||||
@@ -674,9 +687,11 @@ func (api *RestApi) startJob(rw http.ResponseWriter, r *http.Request) {
|
|||||||
if err != nil && err != sql.ErrNoRows {
|
if err != nil && err != sql.ErrNoRows {
|
||||||
handleError(fmt.Errorf("checking for duplicate failed: %w", err), http.StatusInternalServerError, rw)
|
handleError(fmt.Errorf("checking for duplicate failed: %w", err), http.StatusInternalServerError, rw)
|
||||||
return
|
return
|
||||||
} else if err == nil {
|
}
|
||||||
|
if err == nil {
|
||||||
for _, job := range jobs {
|
for _, job := range jobs {
|
||||||
if (req.StartTime - job.StartTime) < 86400 {
|
// Check if jobs are within the same day (prevent duplicates)
|
||||||
|
if (req.StartTime - job.StartTime) < secondsPerDay {
|
||||||
handleError(fmt.Errorf("a job with that jobId, cluster and startTime already exists: dbid: %d, jobid: %d", job.ID, job.JobID), http.StatusUnprocessableEntity, rw)
|
handleError(fmt.Errorf("a job with that jobId, cluster and startTime already exists: dbid: %d, jobid: %d", job.ID, job.JobID), http.StatusUnprocessableEntity, rw)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -693,7 +708,6 @@ func (api *RestApi) startJob(rw http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
for _, tag := range req.Tags {
|
for _, tag := range req.Tags {
|
||||||
if _, err := api.JobRepository.AddTagOrCreate(repository.GetUserFromContext(r.Context()), id, tag.Type, tag.Name, tag.Scope); err != nil {
|
if _, err := api.JobRepository.AddTagOrCreate(repository.GetUserFromContext(r.Context()), id, tag.Type, tag.Name, tag.Scope); err != nil {
|
||||||
http.Error(rw, err.Error(), http.StatusInternalServerError)
|
|
||||||
handleError(fmt.Errorf("adding tag to new job %d failed: %w", id, err), http.StatusInternalServerError, rw)
|
handleError(fmt.Errorf("adding tag to new job %d failed: %w", id, err), http.StatusInternalServerError, rw)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -702,9 +716,11 @@ func (api *RestApi) startJob(rw http.ResponseWriter, r *http.Request) {
|
|||||||
cclog.Printf("new job (id: %d): cluster=%s, jobId=%d, user=%s, startTime=%d", id, req.Cluster, req.JobID, req.User, req.StartTime)
|
cclog.Printf("new job (id: %d): cluster=%s, jobId=%d, user=%s, startTime=%d", id, req.Cluster, req.JobID, req.User, req.StartTime)
|
||||||
rw.Header().Add("Content-Type", "application/json")
|
rw.Header().Add("Content-Type", "application/json")
|
||||||
rw.WriteHeader(http.StatusCreated)
|
rw.WriteHeader(http.StatusCreated)
|
||||||
json.NewEncoder(rw).Encode(DefaultApiResponse{
|
if err := json.NewEncoder(rw).Encode(DefaultApiResponse{
|
||||||
Message: "success",
|
Message: "success",
|
||||||
})
|
}); err != nil {
|
||||||
|
cclog.Errorf("Failed to encode response: %v", err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// stopJobByRequest godoc
|
// stopJobByRequest godoc
|
||||||
@@ -725,7 +741,7 @@ func (api *RestApi) startJob(rw http.ResponseWriter, r *http.Request) {
|
|||||||
// @router /api/jobs/stop_job/ [post]
|
// @router /api/jobs/stop_job/ [post]
|
||||||
func (api *RestApi) stopJobByRequest(rw http.ResponseWriter, r *http.Request) {
|
func (api *RestApi) stopJobByRequest(rw http.ResponseWriter, r *http.Request) {
|
||||||
// Parse request body
|
// Parse request body
|
||||||
req := StopJobApiRequest{}
|
req := StopJobAPIRequest{}
|
||||||
if err := decode(r.Body, &req); err != nil {
|
if err := decode(r.Body, &req); err != nil {
|
||||||
handleError(fmt.Errorf("parsing request body failed: %w", err), http.StatusBadRequest, rw)
|
handleError(fmt.Errorf("parsing request body failed: %w", err), http.StatusBadRequest, rw)
|
||||||
return
|
return
|
||||||
@@ -734,20 +750,22 @@ func (api *RestApi) stopJobByRequest(rw http.ResponseWriter, r *http.Request) {
|
|||||||
// Fetch job (that will be stopped) from db
|
// Fetch job (that will be stopped) from db
|
||||||
var job *schema.Job
|
var job *schema.Job
|
||||||
var err error
|
var err error
|
||||||
if req.JobId == nil {
|
if req.JobID == nil {
|
||||||
handleError(errors.New("the field 'jobId' is required"), http.StatusBadRequest, rw)
|
handleError(errors.New("the field 'jobId' is required"), http.StatusBadRequest, rw)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// cclog.Printf("loading db job for stopJobByRequest... : stopJobApiRequest=%v", req)
|
// cclog.Printf("loading db job for stopJobByRequest... : stopJobApiRequest=%v", req)
|
||||||
job, err = api.JobRepository.Find(req.JobId, req.Cluster, req.StartTime)
|
job, err = api.JobRepository.Find(req.JobID, req.Cluster, req.StartTime)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
job, err = api.JobRepository.FindCached(req.JobId, req.Cluster, req.StartTime)
|
// Try cached jobs if not found in main repository
|
||||||
// FIXME: Previous error is hidden
|
cachedJob, cachedErr := api.JobRepository.FindCached(req.JobID, req.Cluster, req.StartTime)
|
||||||
if err != nil {
|
if cachedErr != nil {
|
||||||
handleError(fmt.Errorf("finding job failed: %w", err), http.StatusUnprocessableEntity, rw)
|
// Combine both errors for better debugging
|
||||||
|
handleError(fmt.Errorf("finding job failed: %w (cached lookup also failed: %v)", err, cachedErr), http.StatusNotFound, rw)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
job = cachedJob
|
||||||
}
|
}
|
||||||
|
|
||||||
api.checkAndHandleStopJob(rw, job, req)
|
api.checkAndHandleStopJob(rw, job, req)
|
||||||
@@ -790,9 +808,11 @@ func (api *RestApi) deleteJobById(rw http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
rw.Header().Add("Content-Type", "application/json")
|
rw.Header().Add("Content-Type", "application/json")
|
||||||
rw.WriteHeader(http.StatusOK)
|
rw.WriteHeader(http.StatusOK)
|
||||||
json.NewEncoder(rw).Encode(DefaultApiResponse{
|
if err := json.NewEncoder(rw).Encode(DefaultApiResponse{
|
||||||
Message: fmt.Sprintf("Successfully deleted job %s", id),
|
Message: fmt.Sprintf("Successfully deleted job %s", id),
|
||||||
})
|
}); err != nil {
|
||||||
|
cclog.Errorf("Failed to encode response: %v", err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// deleteJobByRequest godoc
|
// deleteJobByRequest godoc
|
||||||
@@ -822,12 +842,12 @@ func (api *RestApi) deleteJobByRequest(rw http.ResponseWriter, r *http.Request)
|
|||||||
// Fetch job (that will be deleted) from db
|
// Fetch job (that will be deleted) from db
|
||||||
var job *schema.Job
|
var job *schema.Job
|
||||||
var err error
|
var err error
|
||||||
if req.JobId == nil {
|
if req.JobID == nil {
|
||||||
handleError(errors.New("the field 'jobId' is required"), http.StatusBadRequest, rw)
|
handleError(errors.New("the field 'jobId' is required"), http.StatusBadRequest, rw)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
job, err = api.JobRepository.Find(req.JobId, req.Cluster, req.StartTime)
|
job, err = api.JobRepository.Find(req.JobID, req.Cluster, req.StartTime)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
handleError(fmt.Errorf("finding job failed: %w", err), http.StatusUnprocessableEntity, rw)
|
handleError(fmt.Errorf("finding job failed: %w", err), http.StatusUnprocessableEntity, rw)
|
||||||
return
|
return
|
||||||
@@ -841,9 +861,11 @@ func (api *RestApi) deleteJobByRequest(rw http.ResponseWriter, r *http.Request)
|
|||||||
|
|
||||||
rw.Header().Add("Content-Type", "application/json")
|
rw.Header().Add("Content-Type", "application/json")
|
||||||
rw.WriteHeader(http.StatusOK)
|
rw.WriteHeader(http.StatusOK)
|
||||||
json.NewEncoder(rw).Encode(DefaultApiResponse{
|
if err := json.NewEncoder(rw).Encode(DefaultApiResponse{
|
||||||
Message: fmt.Sprintf("Successfully deleted job %d", job.ID),
|
Message: fmt.Sprintf("Successfully deleted job %d", job.ID),
|
||||||
})
|
}); err != nil {
|
||||||
|
cclog.Errorf("Failed to encode response: %v", err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// deleteJobBefore godoc
|
// deleteJobBefore godoc
|
||||||
@@ -885,19 +907,21 @@ func (api *RestApi) deleteJobBefore(rw http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
rw.Header().Add("Content-Type", "application/json")
|
rw.Header().Add("Content-Type", "application/json")
|
||||||
rw.WriteHeader(http.StatusOK)
|
rw.WriteHeader(http.StatusOK)
|
||||||
json.NewEncoder(rw).Encode(DefaultApiResponse{
|
if err := json.NewEncoder(rw).Encode(DefaultApiResponse{
|
||||||
Message: fmt.Sprintf("Successfully deleted %d jobs", cnt),
|
Message: fmt.Sprintf("Successfully deleted %d jobs", cnt),
|
||||||
})
|
}); err != nil {
|
||||||
|
cclog.Errorf("Failed to encode response: %v", err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (api *RestApi) checkAndHandleStopJob(rw http.ResponseWriter, job *schema.Job, req StopJobApiRequest) {
|
func (api *RestApi) checkAndHandleStopJob(rw http.ResponseWriter, job *schema.Job, req StopJobAPIRequest) {
|
||||||
// Sanity checks
|
// Sanity checks
|
||||||
if job.State != schema.JobStateRunning {
|
if job.State != schema.JobStateRunning {
|
||||||
handleError(fmt.Errorf("jobId %d (id %d) on %s : job has already been stopped (state is: %s)", job.JobID, job.ID, job.Cluster, job.State), http.StatusUnprocessableEntity, rw)
|
handleError(fmt.Errorf("jobId %d (id %d) on %s : job has already been stopped (state is: %s)", job.JobID, job.ID, job.Cluster, job.State), http.StatusUnprocessableEntity, rw)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if job == nil || job.StartTime > req.StopTime {
|
if job.StartTime > req.StopTime {
|
||||||
handleError(fmt.Errorf("jobId %d (id %d) on %s : stopTime %d must be larger/equal than startTime %d", job.JobID, job.ID, job.Cluster, req.StopTime, job.StartTime), http.StatusBadRequest, rw)
|
handleError(fmt.Errorf("jobId %d (id %d) on %s : stopTime %d must be larger/equal than startTime %d", job.JobID, job.ID, job.Cluster, req.StopTime, job.StartTime), http.StatusBadRequest, rw)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -913,14 +937,14 @@ func (api *RestApi) checkAndHandleStopJob(rw http.ResponseWriter, job *schema.Jo
|
|||||||
job.Duration = int32(req.StopTime - job.StartTime)
|
job.Duration = int32(req.StopTime - job.StartTime)
|
||||||
job.State = req.State
|
job.State = req.State
|
||||||
api.JobRepository.Mutex.Lock()
|
api.JobRepository.Mutex.Lock()
|
||||||
|
defer api.JobRepository.Mutex.Unlock()
|
||||||
|
|
||||||
if err := api.JobRepository.Stop(*job.ID, job.Duration, job.State, job.MonitoringStatus); err != nil {
|
if err := api.JobRepository.Stop(*job.ID, job.Duration, job.State, job.MonitoringStatus); err != nil {
|
||||||
if err := api.JobRepository.StopCached(*job.ID, job.Duration, job.State, job.MonitoringStatus); err != nil {
|
if err := api.JobRepository.StopCached(*job.ID, job.Duration, job.State, job.MonitoringStatus); err != nil {
|
||||||
api.JobRepository.Mutex.Unlock()
|
|
||||||
handleError(fmt.Errorf("jobId %d (id %d) on %s : marking job as '%s' (duration: %d) in DB failed: %w", job.JobID, job.ID, job.Cluster, job.State, job.Duration, err), http.StatusInternalServerError, rw)
|
handleError(fmt.Errorf("jobId %d (id %d) on %s : marking job as '%s' (duration: %d) in DB failed: %w", job.JobID, job.ID, job.Cluster, job.State, job.Duration, err), http.StatusInternalServerError, rw)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
api.JobRepository.Mutex.Unlock()
|
|
||||||
|
|
||||||
cclog.Printf("archiving job... (dbid: %d): cluster=%s, jobId=%d, user=%s, startTime=%d, duration=%d, state=%s", job.ID, job.Cluster, job.JobID, job.User, job.StartTime, job.Duration, job.State)
|
cclog.Printf("archiving job... (dbid: %d): cluster=%s, jobId=%d, user=%s, startTime=%d, duration=%d, state=%s", job.ID, job.Cluster, job.JobID, job.User, job.StartTime, job.Duration, job.State)
|
||||||
|
|
||||||
@@ -929,7 +953,9 @@ func (api *RestApi) checkAndHandleStopJob(rw http.ResponseWriter, job *schema.Jo
|
|||||||
// writing to the filesystem fails, the client will not know.
|
// writing to the filesystem fails, the client will not know.
|
||||||
rw.Header().Add("Content-Type", "application/json")
|
rw.Header().Add("Content-Type", "application/json")
|
||||||
rw.WriteHeader(http.StatusOK)
|
rw.WriteHeader(http.StatusOK)
|
||||||
json.NewEncoder(rw).Encode(job)
|
if err := json.NewEncoder(rw).Encode(job); err != nil {
|
||||||
|
cclog.Errorf("Failed to encode job response: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
// Monitoring is disabled...
|
// Monitoring is disabled...
|
||||||
if job.MonitoringStatus == schema.MonitoringStatusDisabled {
|
if job.MonitoringStatus == schema.MonitoringStatusDisabled {
|
||||||
@@ -947,7 +973,7 @@ func (api *RestApi) getJobMetrics(rw http.ResponseWriter, r *http.Request) {
|
|||||||
for _, scope := range r.URL.Query()["scope"] {
|
for _, scope := range r.URL.Query()["scope"] {
|
||||||
var s schema.MetricScope
|
var s schema.MetricScope
|
||||||
if err := s.UnmarshalGQL(scope); err != nil {
|
if err := s.UnmarshalGQL(scope); err != nil {
|
||||||
http.Error(rw, err.Error(), http.StatusBadRequest)
|
handleError(fmt.Errorf("unmarshaling scope failed: %w", err), http.StatusBadRequest, rw)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
scopes = append(scopes, s)
|
scopes = append(scopes, s)
|
||||||
@@ -956,7 +982,7 @@ func (api *RestApi) getJobMetrics(rw http.ResponseWriter, r *http.Request) {
|
|||||||
rw.Header().Add("Content-Type", "application/json")
|
rw.Header().Add("Content-Type", "application/json")
|
||||||
rw.WriteHeader(http.StatusOK)
|
rw.WriteHeader(http.StatusOK)
|
||||||
|
|
||||||
type Respone struct {
|
type Response struct {
|
||||||
Data *struct {
|
Data *struct {
|
||||||
JobMetrics []*model.JobMetricWithName `json:"jobMetrics"`
|
JobMetrics []*model.JobMetricWithName `json:"jobMetrics"`
|
||||||
} `json:"data"`
|
} `json:"data"`
|
||||||
@@ -968,17 +994,21 @@ func (api *RestApi) getJobMetrics(rw http.ResponseWriter, r *http.Request) {
|
|||||||
resolver := graph.GetResolverInstance()
|
resolver := graph.GetResolverInstance()
|
||||||
data, err := resolver.Query().JobMetrics(r.Context(), id, metrics, scopes, nil)
|
data, err := resolver.Query().JobMetrics(r.Context(), id, metrics, scopes, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
json.NewEncoder(rw).Encode(Respone{
|
if err := json.NewEncoder(rw).Encode(Response{
|
||||||
Error: &struct {
|
Error: &struct {
|
||||||
Message string "json:\"message\""
|
Message string `json:"message"`
|
||||||
}{Message: err.Error()},
|
}{Message: err.Error()},
|
||||||
})
|
}); err != nil {
|
||||||
|
cclog.Errorf("Failed to encode error response: %v", err)
|
||||||
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
json.NewEncoder(rw).Encode(Respone{
|
if err := json.NewEncoder(rw).Encode(Response{
|
||||||
Data: &struct {
|
Data: &struct {
|
||||||
JobMetrics []*model.JobMetricWithName "json:\"jobMetrics\""
|
JobMetrics []*model.JobMetricWithName `json:"jobMetrics"`
|
||||||
}{JobMetrics: data},
|
}{JobMetrics: data},
|
||||||
})
|
}); err != nil {
|
||||||
|
cclog.Errorf("Failed to encode response: %v", err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,13 +50,6 @@ func freeMetrics(rw http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// // TODO: lastCheckpoint might be modified by different go-routines.
|
|
||||||
// // Load it using the sync/atomic package?
|
|
||||||
// freeUpTo := lastCheckpoint.Unix()
|
|
||||||
// if to < freeUpTo {
|
|
||||||
// freeUpTo = to
|
|
||||||
// }
|
|
||||||
|
|
||||||
bodyDec := json.NewDecoder(r.Body)
|
bodyDec := json.NewDecoder(r.Body)
|
||||||
var selectors [][]string
|
var selectors [][]string
|
||||||
err = bodyDec.Decode(&selectors)
|
err = bodyDec.Decode(&selectors)
|
||||||
|
|||||||
+81
-37
@@ -2,6 +2,11 @@
|
|||||||
// All rights reserved. This file is part of cc-backend.
|
// All rights reserved. This file is part of cc-backend.
|
||||||
// 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.
|
||||||
|
|
||||||
|
// Package api provides the REST API layer for ClusterCockpit.
|
||||||
|
// It handles HTTP requests for job management, user administration,
|
||||||
|
// cluster queries, node state updates, and metrics storage operations.
|
||||||
|
// The API supports both JWT token authentication and session-based authentication.
|
||||||
package api
|
package api
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -11,6 +16,7 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
"github.com/ClusterCockpit/cc-backend/internal/auth"
|
"github.com/ClusterCockpit/cc-backend/internal/auth"
|
||||||
@@ -39,10 +45,19 @@ import (
|
|||||||
// @in header
|
// @in header
|
||||||
// @name X-Auth-Token
|
// @name X-Auth-Token
|
||||||
|
|
||||||
|
const (
|
||||||
|
noticeFilePath = "./var/notice.txt"
|
||||||
|
noticeFilePerms = 0o644
|
||||||
|
)
|
||||||
|
|
||||||
type RestApi struct {
|
type RestApi struct {
|
||||||
JobRepository *repository.JobRepository
|
JobRepository *repository.JobRepository
|
||||||
Authentication *auth.Authentication
|
Authentication *auth.Authentication
|
||||||
MachineStateDir string
|
MachineStateDir string
|
||||||
|
// RepositoryMutex protects job creation operations from race conditions
|
||||||
|
// when checking for duplicate jobs during startJob API calls.
|
||||||
|
// It prevents concurrent job starts with the same jobId/cluster/startTime
|
||||||
|
// from creating duplicate entries in the database.
|
||||||
RepositoryMutex sync.Mutex
|
RepositoryMutex sync.Mutex
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -66,7 +81,6 @@ func (api *RestApi) MountApiRoutes(r *mux.Router) {
|
|||||||
// Job Handler
|
// Job Handler
|
||||||
r.HandleFunc("/jobs/start_job/", api.startJob).Methods(http.MethodPost, http.MethodPut)
|
r.HandleFunc("/jobs/start_job/", api.startJob).Methods(http.MethodPost, http.MethodPut)
|
||||||
r.HandleFunc("/jobs/stop_job/", api.stopJobByRequest).Methods(http.MethodPost, http.MethodPut)
|
r.HandleFunc("/jobs/stop_job/", api.stopJobByRequest).Methods(http.MethodPost, http.MethodPut)
|
||||||
// r.HandleFunc("/jobs/import/", api.importJob).Methods(http.MethodPost, http.MethodPut)
|
|
||||||
r.HandleFunc("/jobs/", api.getJobs).Methods(http.MethodGet)
|
r.HandleFunc("/jobs/", api.getJobs).Methods(http.MethodGet)
|
||||||
r.HandleFunc("/jobs/{id}", api.getJobById).Methods(http.MethodPost)
|
r.HandleFunc("/jobs/{id}", api.getJobById).Methods(http.MethodPost)
|
||||||
r.HandleFunc("/jobs/{id}", api.getCompleteJobById).Methods(http.MethodGet)
|
r.HandleFunc("/jobs/{id}", api.getCompleteJobById).Methods(http.MethodGet)
|
||||||
@@ -97,6 +111,7 @@ func (api *RestApi) MountUserApiRoutes(r *mux.Router) {
|
|||||||
|
|
||||||
func (api *RestApi) MountMetricStoreApiRoutes(r *mux.Router) {
|
func (api *RestApi) MountMetricStoreApiRoutes(r *mux.Router) {
|
||||||
// REST API Uses TokenAuth
|
// REST API Uses TokenAuth
|
||||||
|
// Note: StrictSlash handles trailing slash variations automatically
|
||||||
r.HandleFunc("/api/free", freeMetrics).Methods(http.MethodPost)
|
r.HandleFunc("/api/free", freeMetrics).Methods(http.MethodPost)
|
||||||
r.HandleFunc("/api/write", writeMetrics).Methods(http.MethodPost)
|
r.HandleFunc("/api/write", writeMetrics).Methods(http.MethodPost)
|
||||||
r.HandleFunc("/api/debug", debugMetrics).Methods(http.MethodGet)
|
r.HandleFunc("/api/debug", debugMetrics).Methods(http.MethodGet)
|
||||||
@@ -146,10 +161,12 @@ func handleError(err error, statusCode int, rw http.ResponseWriter) {
|
|||||||
cclog.Warnf("REST ERROR : %s", err.Error())
|
cclog.Warnf("REST ERROR : %s", err.Error())
|
||||||
rw.Header().Add("Content-Type", "application/json")
|
rw.Header().Add("Content-Type", "application/json")
|
||||||
rw.WriteHeader(statusCode)
|
rw.WriteHeader(statusCode)
|
||||||
json.NewEncoder(rw).Encode(ErrorResponse{
|
if err := json.NewEncoder(rw).Encode(ErrorResponse{
|
||||||
Status: http.StatusText(statusCode),
|
Status: http.StatusText(statusCode),
|
||||||
Error: err.Error(),
|
Error: err.Error(),
|
||||||
})
|
}); err != nil {
|
||||||
|
cclog.Errorf("Failed to encode error response: %v", err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func decode(r io.Reader, val any) error {
|
func decode(r io.Reader, val any) error {
|
||||||
@@ -162,41 +179,41 @@ func (api *RestApi) editNotice(rw http.ResponseWriter, r *http.Request) {
|
|||||||
// SecuredCheck() only worked with TokenAuth: Removed
|
// SecuredCheck() only worked with TokenAuth: Removed
|
||||||
|
|
||||||
if user := repository.GetUserFromContext(r.Context()); !user.HasRole(schema.RoleAdmin) {
|
if user := repository.GetUserFromContext(r.Context()); !user.HasRole(schema.RoleAdmin) {
|
||||||
http.Error(rw, "Only admins are allowed to update the notice.txt file", http.StatusForbidden)
|
handleError(fmt.Errorf("only admins are allowed to update the notice.txt file"), http.StatusForbidden, rw)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get Value
|
// Get Value
|
||||||
newContent := r.FormValue("new-content")
|
newContent := r.FormValue("new-content")
|
||||||
|
|
||||||
// Check FIle
|
// Validate content length to prevent DoS
|
||||||
noticeExists := util.CheckFileExists("./var/notice.txt")
|
if len(newContent) > 10000 {
|
||||||
|
handleError(fmt.Errorf("notice content exceeds maximum length of 10000 characters"), http.StatusBadRequest, rw)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check File
|
||||||
|
noticeExists := util.CheckFileExists(noticeFilePath)
|
||||||
if !noticeExists {
|
if !noticeExists {
|
||||||
ntxt, err := os.Create("./var/notice.txt")
|
ntxt, err := os.Create(noticeFilePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
cclog.Errorf("Creating ./var/notice.txt failed: %s", err.Error())
|
handleError(fmt.Errorf("creating notice file failed: %w", err), http.StatusInternalServerError, rw)
|
||||||
http.Error(rw, err.Error(), http.StatusUnprocessableEntity)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
ntxt.Close()
|
ntxt.Close()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if err := os.WriteFile(noticeFilePath, []byte(newContent), noticeFilePerms); err != nil {
|
||||||
|
handleError(fmt.Errorf("writing to notice file failed: %w", err), http.StatusInternalServerError, rw)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
rw.Header().Set("Content-Type", "text/plain")
|
||||||
|
rw.WriteHeader(http.StatusOK)
|
||||||
if newContent != "" {
|
if newContent != "" {
|
||||||
if err := os.WriteFile("./var/notice.txt", []byte(newContent), 0o666); err != nil {
|
rw.Write([]byte("Update Notice Content Success"))
|
||||||
cclog.Errorf("Writing to ./var/notice.txt failed: %s", err.Error())
|
|
||||||
http.Error(rw, err.Error(), http.StatusUnprocessableEntity)
|
|
||||||
return
|
|
||||||
} else {
|
|
||||||
rw.Write([]byte("Update Notice Content Success"))
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
if err := os.WriteFile("./var/notice.txt", []byte(""), 0o666); err != nil {
|
rw.Write([]byte("Empty Notice Content Success"))
|
||||||
cclog.Errorf("Writing to ./var/notice.txt failed: %s", err.Error())
|
|
||||||
http.Error(rw, err.Error(), http.StatusUnprocessableEntity)
|
|
||||||
return
|
|
||||||
} else {
|
|
||||||
rw.Write([]byte("Empty Notice Content Success"))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -206,21 +223,20 @@ func (api *RestApi) getJWT(rw http.ResponseWriter, r *http.Request) {
|
|||||||
me := repository.GetUserFromContext(r.Context())
|
me := repository.GetUserFromContext(r.Context())
|
||||||
if !me.HasRole(schema.RoleAdmin) {
|
if !me.HasRole(schema.RoleAdmin) {
|
||||||
if username != me.Username {
|
if username != me.Username {
|
||||||
http.Error(rw, "Only admins are allowed to sign JWTs not for themselves",
|
handleError(fmt.Errorf("only admins are allowed to sign JWTs not for themselves"), http.StatusForbidden, rw)
|
||||||
http.StatusForbidden)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
user, err := repository.GetUserRepository().GetUser(username)
|
user, err := repository.GetUserRepository().GetUser(username)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(rw, err.Error(), http.StatusUnprocessableEntity)
|
handleError(fmt.Errorf("getting user failed: %w", err), http.StatusNotFound, rw)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
jwt, err := api.Authentication.JwtAuth.ProvideJWT(user)
|
jwt, err := api.Authentication.JwtAuth.ProvideJWT(user)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(rw, err.Error(), http.StatusUnprocessableEntity)
|
handleError(fmt.Errorf("providing JWT failed: %w", err), http.StatusInternalServerError, rw)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -233,17 +249,20 @@ func (api *RestApi) getRoles(rw http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
user := repository.GetUserFromContext(r.Context())
|
user := repository.GetUserFromContext(r.Context())
|
||||||
if !user.HasRole(schema.RoleAdmin) {
|
if !user.HasRole(schema.RoleAdmin) {
|
||||||
http.Error(rw, "only admins are allowed to fetch a list of roles", http.StatusForbidden)
|
handleError(fmt.Errorf("only admins are allowed to fetch a list of roles"), http.StatusForbidden, rw)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
roles, err := schema.GetValidRoles(user)
|
roles, err := schema.GetValidRoles(user)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(rw, err.Error(), http.StatusInternalServerError)
|
handleError(fmt.Errorf("getting valid roles failed: %w", err), http.StatusInternalServerError, rw)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
json.NewEncoder(rw).Encode(roles)
|
rw.Header().Set("Content-Type", "application/json")
|
||||||
|
if err := json.NewEncoder(rw).Encode(roles); err != nil {
|
||||||
|
cclog.Errorf("Failed to encode roles response: %v", err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (api *RestApi) updateConfiguration(rw http.ResponseWriter, r *http.Request) {
|
func (api *RestApi) updateConfiguration(rw http.ResponseWriter, r *http.Request) {
|
||||||
@@ -251,38 +270,50 @@ func (api *RestApi) updateConfiguration(rw http.ResponseWriter, r *http.Request)
|
|||||||
key, value := r.FormValue("key"), r.FormValue("value")
|
key, value := r.FormValue("key"), r.FormValue("value")
|
||||||
|
|
||||||
if err := repository.GetUserCfgRepo().UpdateConfig(key, value, repository.GetUserFromContext(r.Context())); err != nil {
|
if err := repository.GetUserCfgRepo().UpdateConfig(key, value, repository.GetUserFromContext(r.Context())); err != nil {
|
||||||
http.Error(rw, err.Error(), http.StatusUnprocessableEntity)
|
handleError(fmt.Errorf("updating configuration failed: %w", err), http.StatusInternalServerError, rw)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
rw.WriteHeader(http.StatusOK)
|
||||||
rw.Write([]byte("success"))
|
rw.Write([]byte("success"))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (api *RestApi) putMachineState(rw http.ResponseWriter, r *http.Request) {
|
func (api *RestApi) putMachineState(rw http.ResponseWriter, r *http.Request) {
|
||||||
if api.MachineStateDir == "" {
|
if api.MachineStateDir == "" {
|
||||||
http.Error(rw, "REST > machine state not enabled", http.StatusNotFound)
|
handleError(fmt.Errorf("machine state not enabled"), http.StatusNotFound, rw)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
vars := mux.Vars(r)
|
vars := mux.Vars(r)
|
||||||
cluster := vars["cluster"]
|
cluster := vars["cluster"]
|
||||||
host := vars["host"]
|
host := vars["host"]
|
||||||
|
|
||||||
|
// Validate cluster and host to prevent path traversal attacks
|
||||||
|
if strings.Contains(cluster, "..") || strings.Contains(cluster, "/") || strings.Contains(cluster, "\\") {
|
||||||
|
handleError(fmt.Errorf("invalid cluster name"), http.StatusBadRequest, rw)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if strings.Contains(host, "..") || strings.Contains(host, "/") || strings.Contains(host, "\\") {
|
||||||
|
handleError(fmt.Errorf("invalid host name"), http.StatusBadRequest, rw)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
dir := filepath.Join(api.MachineStateDir, cluster)
|
dir := filepath.Join(api.MachineStateDir, cluster)
|
||||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||||
http.Error(rw, err.Error(), http.StatusInternalServerError)
|
handleError(fmt.Errorf("creating directory failed: %w", err), http.StatusInternalServerError, rw)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
filename := filepath.Join(dir, fmt.Sprintf("%s.json", host))
|
filename := filepath.Join(dir, fmt.Sprintf("%s.json", host))
|
||||||
f, err := os.Create(filename)
|
f, err := os.Create(filename)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(rw, err.Error(), http.StatusInternalServerError)
|
handleError(fmt.Errorf("creating file failed: %w", err), http.StatusInternalServerError, rw)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
defer f.Close()
|
defer f.Close()
|
||||||
|
|
||||||
if _, err := io.Copy(f, r.Body); err != nil {
|
if _, err := io.Copy(f, r.Body); err != nil {
|
||||||
http.Error(rw, err.Error(), http.StatusInternalServerError)
|
handleError(fmt.Errorf("writing file failed: %w", err), http.StatusInternalServerError, rw)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -291,12 +322,25 @@ func (api *RestApi) putMachineState(rw http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
func (api *RestApi) getMachineState(rw http.ResponseWriter, r *http.Request) {
|
func (api *RestApi) getMachineState(rw http.ResponseWriter, r *http.Request) {
|
||||||
if api.MachineStateDir == "" {
|
if api.MachineStateDir == "" {
|
||||||
http.Error(rw, "REST > machine state not enabled", http.StatusNotFound)
|
handleError(fmt.Errorf("machine state not enabled"), http.StatusNotFound, rw)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
vars := mux.Vars(r)
|
vars := mux.Vars(r)
|
||||||
filename := filepath.Join(api.MachineStateDir, vars["cluster"], fmt.Sprintf("%s.json", vars["host"]))
|
cluster := vars["cluster"]
|
||||||
|
host := vars["host"]
|
||||||
|
|
||||||
|
// Validate cluster and host to prevent path traversal attacks
|
||||||
|
if strings.Contains(cluster, "..") || strings.Contains(cluster, "/") || strings.Contains(cluster, "\\") {
|
||||||
|
handleError(fmt.Errorf("invalid cluster name"), http.StatusBadRequest, rw)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if strings.Contains(host, "..") || strings.Contains(host, "/") || strings.Contains(host, "\\") {
|
||||||
|
handleError(fmt.Errorf("invalid host name"), http.StatusBadRequest, rw)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
filename := filepath.Join(api.MachineStateDir, cluster, fmt.Sprintf("%s.json", host))
|
||||||
|
|
||||||
// Sets the content-type and 'Last-Modified' Header and so on automatically
|
// Sets the content-type and 'Last-Modified' Header and so on automatically
|
||||||
http.ServeFile(rw, r, filename)
|
http.ServeFile(rw, r, filename)
|
||||||
|
|||||||
+86
-24
@@ -2,6 +2,7 @@
|
|||||||
// All rights reserved. This file is part of cc-backend.
|
// All rights reserved. This file is part of cc-backend.
|
||||||
// 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.
|
||||||
|
|
||||||
package api
|
package api
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -10,11 +11,12 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"github.com/ClusterCockpit/cc-backend/internal/repository"
|
"github.com/ClusterCockpit/cc-backend/internal/repository"
|
||||||
|
cclog "github.com/ClusterCockpit/cc-lib/ccLogger"
|
||||||
"github.com/ClusterCockpit/cc-lib/schema"
|
"github.com/ClusterCockpit/cc-lib/schema"
|
||||||
"github.com/gorilla/mux"
|
"github.com/gorilla/mux"
|
||||||
)
|
)
|
||||||
|
|
||||||
type ApiReturnedUser struct {
|
type APIReturnedUser struct {
|
||||||
Username string `json:"username"`
|
Username string `json:"username"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Roles []string `json:"roles"`
|
Roles []string `json:"roles"`
|
||||||
@@ -40,24 +42,42 @@ func (api *RestApi) getUsers(rw http.ResponseWriter, r *http.Request) {
|
|||||||
// SecuredCheck() only worked with TokenAuth: Removed
|
// SecuredCheck() only worked with TokenAuth: Removed
|
||||||
|
|
||||||
if user := repository.GetUserFromContext(r.Context()); !user.HasRole(schema.RoleAdmin) {
|
if user := repository.GetUserFromContext(r.Context()); !user.HasRole(schema.RoleAdmin) {
|
||||||
http.Error(rw, "Only admins are allowed to fetch a list of users", http.StatusForbidden)
|
handleError(fmt.Errorf("only admins are allowed to fetch a list of users"), http.StatusForbidden, rw)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
users, err := repository.GetUserRepository().ListUsers(r.URL.Query().Get("not-just-user") == "true")
|
users, err := repository.GetUserRepository().ListUsers(r.URL.Query().Get("not-just-user") == "true")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(rw, err.Error(), http.StatusInternalServerError)
|
handleError(fmt.Errorf("listing users failed: %w", err), http.StatusInternalServerError, rw)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
json.NewEncoder(rw).Encode(users)
|
rw.Header().Set("Content-Type", "application/json")
|
||||||
|
if err := json.NewEncoder(rw).Encode(users); err != nil {
|
||||||
|
cclog.Errorf("Failed to encode users response: %v", err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// updateUser godoc
|
||||||
|
// @summary Update user roles and projects
|
||||||
|
// @tags User
|
||||||
|
// @description Allows admins to add/remove roles and projects for a user
|
||||||
|
// @produce plain
|
||||||
|
// @param id path string true "Username"
|
||||||
|
// @param add-role formData string false "Role to add"
|
||||||
|
// @param remove-role formData string false "Role to remove"
|
||||||
|
// @param add-project formData string false "Project to add"
|
||||||
|
// @param remove-project formData string false "Project to remove"
|
||||||
|
// @success 200 {string} string "Success message"
|
||||||
|
// @failure 403 {object} api.ErrorResponse "Forbidden"
|
||||||
|
// @failure 422 {object} api.ErrorResponse "Unprocessable Entity"
|
||||||
|
// @security ApiKeyAuth
|
||||||
|
// @router /api/user/{id} [post]
|
||||||
func (api *RestApi) updateUser(rw http.ResponseWriter, r *http.Request) {
|
func (api *RestApi) updateUser(rw http.ResponseWriter, r *http.Request) {
|
||||||
// SecuredCheck() only worked with TokenAuth: Removed
|
// SecuredCheck() only worked with TokenAuth: Removed
|
||||||
|
|
||||||
if user := repository.GetUserFromContext(r.Context()); !user.HasRole(schema.RoleAdmin) {
|
if user := repository.GetUserFromContext(r.Context()); !user.HasRole(schema.RoleAdmin) {
|
||||||
http.Error(rw, "Only admins are allowed to update a user", http.StatusForbidden)
|
handleError(fmt.Errorf("only admins are allowed to update a user"), http.StatusForbidden, rw)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -67,43 +87,70 @@ func (api *RestApi) updateUser(rw http.ResponseWriter, r *http.Request) {
|
|||||||
newproj := r.FormValue("add-project")
|
newproj := r.FormValue("add-project")
|
||||||
delproj := r.FormValue("remove-project")
|
delproj := r.FormValue("remove-project")
|
||||||
|
|
||||||
// TODO: Handle anything but roles...
|
rw.Header().Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
// Handle role updates
|
||||||
if newrole != "" {
|
if newrole != "" {
|
||||||
if err := repository.GetUserRepository().AddRole(r.Context(), mux.Vars(r)["id"], newrole); err != nil {
|
if err := repository.GetUserRepository().AddRole(r.Context(), mux.Vars(r)["id"], newrole); err != nil {
|
||||||
http.Error(rw, err.Error(), http.StatusUnprocessableEntity)
|
handleError(fmt.Errorf("adding role failed: %w", err), http.StatusUnprocessableEntity, rw)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
rw.Write([]byte("Add Role Success"))
|
if err := json.NewEncoder(rw).Encode(DefaultApiResponse{Message: "Add Role Success"}); err != nil {
|
||||||
|
cclog.Errorf("Failed to encode response: %v", err)
|
||||||
|
}
|
||||||
} else if delrole != "" {
|
} else if delrole != "" {
|
||||||
if err := repository.GetUserRepository().RemoveRole(r.Context(), mux.Vars(r)["id"], delrole); err != nil {
|
if err := repository.GetUserRepository().RemoveRole(r.Context(), mux.Vars(r)["id"], delrole); err != nil {
|
||||||
http.Error(rw, err.Error(), http.StatusUnprocessableEntity)
|
handleError(fmt.Errorf("removing role failed: %w", err), http.StatusUnprocessableEntity, rw)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
rw.Write([]byte("Remove Role Success"))
|
if err := json.NewEncoder(rw).Encode(DefaultApiResponse{Message: "Remove Role Success"}); err != nil {
|
||||||
|
cclog.Errorf("Failed to encode response: %v", err)
|
||||||
|
}
|
||||||
} else if newproj != "" {
|
} else if newproj != "" {
|
||||||
if err := repository.GetUserRepository().AddProject(r.Context(), mux.Vars(r)["id"], newproj); err != nil {
|
if err := repository.GetUserRepository().AddProject(r.Context(), mux.Vars(r)["id"], newproj); err != nil {
|
||||||
http.Error(rw, err.Error(), http.StatusUnprocessableEntity)
|
handleError(fmt.Errorf("adding project failed: %w", err), http.StatusUnprocessableEntity, rw)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
rw.Write([]byte("Add Project Success"))
|
if err := json.NewEncoder(rw).Encode(DefaultApiResponse{Message: "Add Project Success"}); err != nil {
|
||||||
|
cclog.Errorf("Failed to encode response: %v", err)
|
||||||
|
}
|
||||||
} else if delproj != "" {
|
} else if delproj != "" {
|
||||||
if err := repository.GetUserRepository().RemoveProject(r.Context(), mux.Vars(r)["id"], delproj); err != nil {
|
if err := repository.GetUserRepository().RemoveProject(r.Context(), mux.Vars(r)["id"], delproj); err != nil {
|
||||||
http.Error(rw, err.Error(), http.StatusUnprocessableEntity)
|
handleError(fmt.Errorf("removing project failed: %w", err), http.StatusUnprocessableEntity, rw)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
rw.Write([]byte("Remove Project Success"))
|
if err := json.NewEncoder(rw).Encode(DefaultApiResponse{Message: "Remove Project Success"}); err != nil {
|
||||||
|
cclog.Errorf("Failed to encode response: %v", err)
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
http.Error(rw, "Not Add or Del [role|project]?", http.StatusInternalServerError)
|
handleError(fmt.Errorf("no operation specified: must provide add-role, remove-role, add-project, or remove-project"), http.StatusBadRequest, rw)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// createUser godoc
|
||||||
|
// @summary Create a new user
|
||||||
|
// @tags User
|
||||||
|
// @description Creates a new user with specified credentials and role
|
||||||
|
// @produce plain
|
||||||
|
// @param username formData string true "Username"
|
||||||
|
// @param password formData string false "Password (not required for API users)"
|
||||||
|
// @param role formData string true "User role"
|
||||||
|
// @param name formData string false "Full name"
|
||||||
|
// @param email formData string false "Email address"
|
||||||
|
// @param project formData string false "Project (required for managers)"
|
||||||
|
// @success 200 {string} string "Success message"
|
||||||
|
// @failure 400 {object} api.ErrorResponse "Bad Request"
|
||||||
|
// @failure 403 {object} api.ErrorResponse "Forbidden"
|
||||||
|
// @failure 422 {object} api.ErrorResponse "Unprocessable Entity"
|
||||||
|
// @security ApiKeyAuth
|
||||||
|
// @router /api/users/ [post]
|
||||||
func (api *RestApi) createUser(rw http.ResponseWriter, r *http.Request) {
|
func (api *RestApi) createUser(rw http.ResponseWriter, r *http.Request) {
|
||||||
// SecuredCheck() only worked with TokenAuth: Removed
|
// SecuredCheck() only worked with TokenAuth: Removed
|
||||||
|
|
||||||
rw.Header().Set("Content-Type", "text/plain")
|
rw.Header().Set("Content-Type", "text/plain")
|
||||||
me := repository.GetUserFromContext(r.Context())
|
me := repository.GetUserFromContext(r.Context())
|
||||||
if !me.HasRole(schema.RoleAdmin) {
|
if !me.HasRole(schema.RoleAdmin) {
|
||||||
http.Error(rw, "Only admins are allowed to create new users", http.StatusForbidden)
|
handleError(fmt.Errorf("only admins are allowed to create new users"), http.StatusForbidden, rw)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -111,18 +158,22 @@ func (api *RestApi) createUser(rw http.ResponseWriter, r *http.Request) {
|
|||||||
r.FormValue("password"), r.FormValue("role"), r.FormValue("name"),
|
r.FormValue("password"), r.FormValue("role"), r.FormValue("name"),
|
||||||
r.FormValue("email"), r.FormValue("project")
|
r.FormValue("email"), r.FormValue("project")
|
||||||
|
|
||||||
|
// Validate username length
|
||||||
|
if len(username) == 0 || len(username) > 100 {
|
||||||
|
handleError(fmt.Errorf("username must be between 1 and 100 characters"), http.StatusBadRequest, rw)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if len(password) == 0 && role != schema.GetRoleString(schema.RoleApi) {
|
if len(password) == 0 && role != schema.GetRoleString(schema.RoleApi) {
|
||||||
http.Error(rw, "Only API users are allowed to have a blank password (login will be impossible)", http.StatusBadRequest)
|
handleError(fmt.Errorf("only API users are allowed to have a blank password (login will be impossible)"), http.StatusBadRequest, rw)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(project) != 0 && role != schema.GetRoleString(schema.RoleManager) {
|
if len(project) != 0 && role != schema.GetRoleString(schema.RoleManager) {
|
||||||
http.Error(rw, "only managers require a project (can be changed later)",
|
handleError(fmt.Errorf("only managers require a project (can be changed later)"), http.StatusBadRequest, rw)
|
||||||
http.StatusBadRequest)
|
|
||||||
return
|
return
|
||||||
} else if len(project) == 0 && role == schema.GetRoleString(schema.RoleManager) {
|
} else if len(project) == 0 && role == schema.GetRoleString(schema.RoleManager) {
|
||||||
http.Error(rw, "managers require a project to manage (can be changed later)",
|
handleError(fmt.Errorf("managers require a project to manage (can be changed later)"), http.StatusBadRequest, rw)
|
||||||
http.StatusBadRequest)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -134,24 +185,35 @@ func (api *RestApi) createUser(rw http.ResponseWriter, r *http.Request) {
|
|||||||
Projects: []string{project},
|
Projects: []string{project},
|
||||||
Roles: []string{role},
|
Roles: []string{role},
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
http.Error(rw, err.Error(), http.StatusUnprocessableEntity)
|
handleError(fmt.Errorf("adding user failed: %w", err), http.StatusUnprocessableEntity, rw)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Fprintf(rw, "User %v successfully created!\n", username)
|
fmt.Fprintf(rw, "User %v successfully created!\n", username)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// deleteUser godoc
|
||||||
|
// @summary Delete a user
|
||||||
|
// @tags User
|
||||||
|
// @description Deletes a user from the system
|
||||||
|
// @produce plain
|
||||||
|
// @param username formData string true "Username to delete"
|
||||||
|
// @success 200 {string} string "Success"
|
||||||
|
// @failure 403 {object} api.ErrorResponse "Forbidden"
|
||||||
|
// @failure 422 {object} api.ErrorResponse "Unprocessable Entity"
|
||||||
|
// @security ApiKeyAuth
|
||||||
|
// @router /api/users/ [delete]
|
||||||
func (api *RestApi) deleteUser(rw http.ResponseWriter, r *http.Request) {
|
func (api *RestApi) deleteUser(rw http.ResponseWriter, r *http.Request) {
|
||||||
// SecuredCheck() only worked with TokenAuth: Removed
|
// SecuredCheck() only worked with TokenAuth: Removed
|
||||||
|
|
||||||
if user := repository.GetUserFromContext(r.Context()); !user.HasRole(schema.RoleAdmin) {
|
if user := repository.GetUserFromContext(r.Context()); !user.HasRole(schema.RoleAdmin) {
|
||||||
http.Error(rw, "Only admins are allowed to delete a user", http.StatusForbidden)
|
handleError(fmt.Errorf("only admins are allowed to delete a user"), http.StatusForbidden, rw)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
username := r.FormValue("username")
|
username := r.FormValue("username")
|
||||||
if err := repository.GetUserRepository().DelUser(username); err != nil {
|
if err := repository.GetUserRepository().DelUser(username); err != nil {
|
||||||
http.Error(rw, err.Error(), http.StatusUnprocessableEntity)
|
handleError(fmt.Errorf("deleting user failed: %w", err), http.StatusUnprocessableEntity, rw)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+121
-42
@@ -18,7 +18,6 @@ import (
|
|||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"strings"
|
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -32,8 +31,19 @@ import (
|
|||||||
"github.com/gorilla/sessions"
|
"github.com/gorilla/sessions"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Authenticator is the interface for all authentication methods.
|
||||||
|
// Each authenticator determines if it can handle a login request (CanLogin)
|
||||||
|
// and performs the actual authentication (Login).
|
||||||
type Authenticator interface {
|
type Authenticator interface {
|
||||||
|
// CanLogin determines if this authenticator can handle the login request.
|
||||||
|
// It returns the user object if available and a boolean indicating if this
|
||||||
|
// authenticator should attempt the login. This method should not perform
|
||||||
|
// expensive operations or actual authentication.
|
||||||
CanLogin(user *schema.User, username string, rw http.ResponseWriter, r *http.Request) (*schema.User, bool)
|
CanLogin(user *schema.User, username string, rw http.ResponseWriter, r *http.Request) (*schema.User, bool)
|
||||||
|
|
||||||
|
// Login performs the actually authentication for the user.
|
||||||
|
// It returns the authenticated user or an error if authentication fails.
|
||||||
|
// The user parameter may be nil if the user doesn't exist in the database yet.
|
||||||
Login(user *schema.User, rw http.ResponseWriter, r *http.Request) (*schema.User, error)
|
Login(user *schema.User, rw http.ResponseWriter, r *http.Request) (*schema.User, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -42,27 +52,70 @@ var (
|
|||||||
authInstance *Authentication
|
authInstance *Authentication
|
||||||
)
|
)
|
||||||
|
|
||||||
var ipUserLimiters sync.Map
|
// rateLimiterEntry tracks a rate limiter and its last use time for cleanup
|
||||||
|
type rateLimiterEntry struct {
|
||||||
func getIPUserLimiter(ip, username string) *rate.Limiter {
|
limiter *rate.Limiter
|
||||||
key := ip + ":" + username
|
lastUsed time.Time
|
||||||
limiter, ok := ipUserLimiters.Load(key)
|
|
||||||
if !ok {
|
|
||||||
newLimiter := rate.NewLimiter(rate.Every(time.Hour/10), 10)
|
|
||||||
ipUserLimiters.Store(key, newLimiter)
|
|
||||||
return newLimiter
|
|
||||||
}
|
|
||||||
return limiter.(*rate.Limiter)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var ipUserLimiters sync.Map
|
||||||
|
|
||||||
|
// getIPUserLimiter returns a rate limiter for the given IP and username combination.
|
||||||
|
// Rate limiters are created on demand and track 5 attempts per 15 minutes.
|
||||||
|
func getIPUserLimiter(ip, username string) *rate.Limiter {
|
||||||
|
key := ip + ":" + username
|
||||||
|
now := time.Now()
|
||||||
|
|
||||||
|
if entry, ok := ipUserLimiters.Load(key); ok {
|
||||||
|
rle := entry.(*rateLimiterEntry)
|
||||||
|
rle.lastUsed = now
|
||||||
|
return rle.limiter
|
||||||
|
}
|
||||||
|
|
||||||
|
// More aggressive rate limiting: 5 attempts per 15 minutes
|
||||||
|
newLimiter := rate.NewLimiter(rate.Every(15*time.Minute/5), 5)
|
||||||
|
ipUserLimiters.Store(key, &rateLimiterEntry{
|
||||||
|
limiter: newLimiter,
|
||||||
|
lastUsed: now,
|
||||||
|
})
|
||||||
|
return newLimiter
|
||||||
|
}
|
||||||
|
|
||||||
|
// cleanupOldRateLimiters removes rate limiters that haven't been used recently
|
||||||
|
func cleanupOldRateLimiters(olderThan time.Time) {
|
||||||
|
ipUserLimiters.Range(func(key, value any) bool {
|
||||||
|
entry := value.(*rateLimiterEntry)
|
||||||
|
if entry.lastUsed.Before(olderThan) {
|
||||||
|
ipUserLimiters.Delete(key)
|
||||||
|
cclog.Debugf("Cleaned up rate limiter for %v", key)
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// startRateLimiterCleanup starts a background goroutine to clean up old rate limiters
|
||||||
|
func startRateLimiterCleanup() {
|
||||||
|
go func() {
|
||||||
|
ticker := time.NewTicker(1 * time.Hour)
|
||||||
|
defer ticker.Stop()
|
||||||
|
for range ticker.C {
|
||||||
|
// Clean up limiters not used in the last 24 hours
|
||||||
|
cleanupOldRateLimiters(time.Now().Add(-24 * time.Hour))
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
// AuthConfig contains configuration for all authentication methods
|
||||||
type AuthConfig struct {
|
type AuthConfig struct {
|
||||||
LdapConfig *LdapConfig `json:"ldap"`
|
LdapConfig *LdapConfig `json:"ldap"`
|
||||||
JwtConfig *JWTAuthConfig `json:"jwts"`
|
JwtConfig *JWTAuthConfig `json:"jwts"`
|
||||||
OpenIDConfig *OpenIDConfig `json:"oidc"`
|
OpenIDConfig *OpenIDConfig `json:"oidc"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Keys holds the global authentication configuration
|
||||||
var Keys AuthConfig
|
var Keys AuthConfig
|
||||||
|
|
||||||
|
// Authentication manages all authentication methods and session handling
|
||||||
type Authentication struct {
|
type Authentication struct {
|
||||||
sessionStore *sessions.CookieStore
|
sessionStore *sessions.CookieStore
|
||||||
LdapAuth *LdapAuthenticator
|
LdapAuth *LdapAuthenticator
|
||||||
@@ -86,10 +139,31 @@ func (auth *Authentication) AuthViaSession(
|
|||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: Check if session keys exist
|
// Validate session data with proper type checking
|
||||||
username, _ := session.Values["username"].(string)
|
username, ok := session.Values["username"].(string)
|
||||||
projects, _ := session.Values["projects"].([]string)
|
if !ok || username == "" {
|
||||||
roles, _ := session.Values["roles"].([]string)
|
cclog.Warn("Invalid session: missing or invalid username")
|
||||||
|
// Invalidate the corrupted session
|
||||||
|
session.Options.MaxAge = -1
|
||||||
|
_ = auth.sessionStore.Save(r, rw, session)
|
||||||
|
return nil, errors.New("invalid session data")
|
||||||
|
}
|
||||||
|
|
||||||
|
projects, ok := session.Values["projects"].([]string)
|
||||||
|
if !ok {
|
||||||
|
cclog.Warn("Invalid session: projects not found or invalid type, using empty list")
|
||||||
|
projects = []string{}
|
||||||
|
}
|
||||||
|
|
||||||
|
roles, ok := session.Values["roles"].([]string)
|
||||||
|
if !ok || len(roles) == 0 {
|
||||||
|
cclog.Warn("Invalid session: missing or invalid roles")
|
||||||
|
// Invalidate the corrupted session
|
||||||
|
session.Options.MaxAge = -1
|
||||||
|
_ = auth.sessionStore.Save(r, rw, session)
|
||||||
|
return nil, errors.New("invalid session data")
|
||||||
|
}
|
||||||
|
|
||||||
return &schema.User{
|
return &schema.User{
|
||||||
Username: username,
|
Username: username,
|
||||||
Projects: projects,
|
Projects: projects,
|
||||||
@@ -103,6 +177,9 @@ func Init(authCfg *json.RawMessage) {
|
|||||||
initOnce.Do(func() {
|
initOnce.Do(func() {
|
||||||
authInstance = &Authentication{}
|
authInstance = &Authentication{}
|
||||||
|
|
||||||
|
// Start background cleanup of rate limiters
|
||||||
|
startRateLimiterCleanup()
|
||||||
|
|
||||||
sessKey := os.Getenv("SESSION_KEY")
|
sessKey := os.Getenv("SESSION_KEY")
|
||||||
if sessKey == "" {
|
if sessKey == "" {
|
||||||
cclog.Warn("environment variable 'SESSION_KEY' not set (will use non-persistent random key)")
|
cclog.Warn("environment variable 'SESSION_KEY' not set (will use non-persistent random key)")
|
||||||
@@ -185,38 +262,36 @@ func GetAuthInstance() *Authentication {
|
|||||||
return authInstance
|
return authInstance
|
||||||
}
|
}
|
||||||
|
|
||||||
func handleTokenUser(tokenUser *schema.User) {
|
// handleUserSync syncs or updates a user in the database based on configuration.
|
||||||
|
// This is used for both JWT and OIDC authentication when syncUserOnLogin or updateUserOnLogin is enabled.
|
||||||
|
func handleUserSync(user *schema.User, syncUserOnLogin, updateUserOnLogin bool) {
|
||||||
r := repository.GetUserRepository()
|
r := repository.GetUserRepository()
|
||||||
dbUser, err := r.GetUser(tokenUser.Username)
|
dbUser, err := r.GetUser(user.Username)
|
||||||
|
|
||||||
if err != nil && err != sql.ErrNoRows {
|
if err != nil && err != sql.ErrNoRows {
|
||||||
cclog.Errorf("Error while loading user '%s': %v", tokenUser.Username, err)
|
cclog.Errorf("Error while loading user '%s': %v", user.Username, err)
|
||||||
} else if err == sql.ErrNoRows && Keys.JwtConfig.SyncUserOnLogin { // Adds New User
|
return
|
||||||
if err := r.AddUser(tokenUser); err != nil {
|
}
|
||||||
cclog.Errorf("Error while adding user '%s' to DB: %v", tokenUser.Username, err)
|
|
||||||
|
if err == sql.ErrNoRows && syncUserOnLogin { // Add new user
|
||||||
|
if err := r.AddUser(user); err != nil {
|
||||||
|
cclog.Errorf("Error while adding user '%s' to DB: %v", user.Username, err)
|
||||||
}
|
}
|
||||||
} else if err == nil && Keys.JwtConfig.UpdateUserOnLogin { // Update Existing User
|
} else if err == nil && updateUserOnLogin { // Update existing user
|
||||||
if err := r.UpdateUser(dbUser, tokenUser); err != nil {
|
if err := r.UpdateUser(dbUser, user); err != nil {
|
||||||
cclog.Errorf("Error while updating user '%s' to DB: %v", dbUser.Username, err)
|
cclog.Errorf("Error while updating user '%s' in DB: %v", dbUser.Username, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func handleOIDCUser(OIDCUser *schema.User) {
|
// handleTokenUser syncs JWT token user with database
|
||||||
r := repository.GetUserRepository()
|
func handleTokenUser(tokenUser *schema.User) {
|
||||||
dbUser, err := r.GetUser(OIDCUser.Username)
|
handleUserSync(tokenUser, Keys.JwtConfig.SyncUserOnLogin, Keys.JwtConfig.UpdateUserOnLogin)
|
||||||
|
}
|
||||||
|
|
||||||
if err != nil && err != sql.ErrNoRows {
|
// handleOIDCUser syncs OIDC user with database
|
||||||
cclog.Errorf("Error while loading user '%s': %v", OIDCUser.Username, err)
|
func handleOIDCUser(OIDCUser *schema.User) {
|
||||||
} else if err == sql.ErrNoRows && Keys.OpenIDConfig.SyncUserOnLogin { // Adds New User
|
handleUserSync(OIDCUser, Keys.OpenIDConfig.SyncUserOnLogin, Keys.OpenIDConfig.UpdateUserOnLogin)
|
||||||
if err := r.AddUser(OIDCUser); err != nil {
|
|
||||||
cclog.Errorf("Error while adding user '%s' to DB: %v", OIDCUser.Username, err)
|
|
||||||
}
|
|
||||||
} else if err == nil && Keys.OpenIDConfig.UpdateUserOnLogin { // Update Existing User
|
|
||||||
if err := r.UpdateUser(dbUser, OIDCUser); err != nil {
|
|
||||||
cclog.Errorf("Error while updating user '%s' to DB: %v", dbUser.Username, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (auth *Authentication) SaveSession(rw http.ResponseWriter, r *http.Request, user *schema.User) error {
|
func (auth *Authentication) SaveSession(rw http.ResponseWriter, r *http.Request, user *schema.User) error {
|
||||||
@@ -231,6 +306,7 @@ func (auth *Authentication) SaveSession(rw http.ResponseWriter, r *http.Request,
|
|||||||
session.Options.MaxAge = int(auth.SessionMaxAge.Seconds())
|
session.Options.MaxAge = int(auth.SessionMaxAge.Seconds())
|
||||||
}
|
}
|
||||||
if config.Keys.HTTPSCertFile == "" && config.Keys.HTTPSKeyFile == "" {
|
if config.Keys.HTTPSCertFile == "" && config.Keys.HTTPSKeyFile == "" {
|
||||||
|
cclog.Warn("HTTPS not configured - session cookies will not have Secure flag set (insecure for production)")
|
||||||
session.Options.Secure = false
|
session.Options.Secure = false
|
||||||
}
|
}
|
||||||
session.Options.SameSite = http.SameSiteStrictMode
|
session.Options.SameSite = http.SameSiteStrictMode
|
||||||
@@ -532,10 +608,13 @@ func securedCheck(user *schema.User, r *http.Request) error {
|
|||||||
IPAddress = r.RemoteAddr
|
IPAddress = r.RemoteAddr
|
||||||
}
|
}
|
||||||
|
|
||||||
// FIXME: IPV6 not handled
|
// Handle both IPv4 and IPv6 addresses properly
|
||||||
if strings.Contains(IPAddress, ":") {
|
// For IPv6, this will strip the port and brackets
|
||||||
IPAddress = strings.Split(IPAddress, ":")[0]
|
// For IPv4, this will strip the port
|
||||||
|
if host, _, err := net.SplitHostPort(IPAddress); err == nil {
|
||||||
|
IPAddress = host
|
||||||
}
|
}
|
||||||
|
// 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: deny all request to this api endpoint
|
||||||
if len(config.Keys.APIAllowedIPs) == 0 {
|
if len(config.Keys.APIAllowedIPs) == 0 {
|
||||||
|
|||||||
@@ -0,0 +1,176 @@
|
|||||||
|
// 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 auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestGetIPUserLimiter tests the rate limiter creation and retrieval
|
||||||
|
func TestGetIPUserLimiter(t *testing.T) {
|
||||||
|
ip := "192.168.1.1"
|
||||||
|
username := "testuser"
|
||||||
|
|
||||||
|
// Get limiter for the first time
|
||||||
|
limiter1 := getIPUserLimiter(ip, username)
|
||||||
|
if limiter1 == nil {
|
||||||
|
t.Fatal("Expected limiter to be created")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the same limiter again
|
||||||
|
limiter2 := getIPUserLimiter(ip, username)
|
||||||
|
if limiter1 != limiter2 {
|
||||||
|
t.Error("Expected to get the same limiter instance")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get a different limiter for different user
|
||||||
|
limiter3 := getIPUserLimiter(ip, "otheruser")
|
||||||
|
if limiter1 == limiter3 {
|
||||||
|
t.Error("Expected different limiter for different user")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get a different limiter for different IP
|
||||||
|
limiter4 := getIPUserLimiter("192.168.1.2", username)
|
||||||
|
if limiter1 == limiter4 {
|
||||||
|
t.Error("Expected different limiter for different IP")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRateLimiterBehavior tests that rate limiting works correctly
|
||||||
|
func TestRateLimiterBehavior(t *testing.T) {
|
||||||
|
ip := "10.0.0.1"
|
||||||
|
username := "ratelimituser"
|
||||||
|
|
||||||
|
limiter := getIPUserLimiter(ip, username)
|
||||||
|
|
||||||
|
// Should allow first 5 attempts
|
||||||
|
for i := 0; i < 5; i++ {
|
||||||
|
if !limiter.Allow() {
|
||||||
|
t.Errorf("Request %d should be allowed within rate limit", i+1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6th attempt should be blocked
|
||||||
|
if limiter.Allow() {
|
||||||
|
t.Error("Request 6 should be blocked by rate limiter")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCleanupOldRateLimiters tests the cleanup function
|
||||||
|
func TestCleanupOldRateLimiters(t *testing.T) {
|
||||||
|
// Clear all existing limiters first to avoid interference from other tests
|
||||||
|
cleanupOldRateLimiters(time.Now().Add(24 * time.Hour))
|
||||||
|
|
||||||
|
// Create some new rate limiters
|
||||||
|
limiter1 := getIPUserLimiter("1.1.1.1", "user1")
|
||||||
|
limiter2 := getIPUserLimiter("2.2.2.2", "user2")
|
||||||
|
|
||||||
|
if limiter1 == nil || limiter2 == nil {
|
||||||
|
t.Fatal("Failed to create test limiters")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cleanup limiters older than 1 second from now (should keep both)
|
||||||
|
time.Sleep(10 * time.Millisecond) // Small delay to ensure timestamp difference
|
||||||
|
cleanupOldRateLimiters(time.Now().Add(-1 * time.Second))
|
||||||
|
|
||||||
|
// Verify they still exist (should get same instance)
|
||||||
|
if getIPUserLimiter("1.1.1.1", "user1") != limiter1 {
|
||||||
|
t.Error("Limiter 1 was incorrectly cleaned up")
|
||||||
|
}
|
||||||
|
if getIPUserLimiter("2.2.2.2", "user2") != limiter2 {
|
||||||
|
t.Error("Limiter 2 was incorrectly cleaned up")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cleanup limiters older than 1 hour from now (should remove both)
|
||||||
|
cleanupOldRateLimiters(time.Now().Add(2 * time.Hour))
|
||||||
|
|
||||||
|
// Getting them again should create new instances
|
||||||
|
newLimiter1 := getIPUserLimiter("1.1.1.1", "user1")
|
||||||
|
if newLimiter1 == limiter1 {
|
||||||
|
t.Error("Old limiter should have been cleaned up")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestIPv4Extraction tests extracting IPv4 addresses
|
||||||
|
func TestIPv4Extraction(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
input string
|
||||||
|
expected string
|
||||||
|
}{
|
||||||
|
{"IPv4 with port", "192.168.1.1:8080", "192.168.1.1"},
|
||||||
|
{"IPv4 without port", "192.168.1.1", "192.168.1.1"},
|
||||||
|
{"Localhost with port", "127.0.0.1:3000", "127.0.0.1"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
result := tt.input
|
||||||
|
if host, _, err := net.SplitHostPort(result); err == nil {
|
||||||
|
result = host
|
||||||
|
}
|
||||||
|
|
||||||
|
if result != tt.expected {
|
||||||
|
t.Errorf("Expected %s, got %s", tt.expected, result)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestIPv6Extraction tests extracting IPv6 addresses
|
||||||
|
func TestIPv6Extraction(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
input string
|
||||||
|
expected string
|
||||||
|
}{
|
||||||
|
{"IPv6 with port", "[2001:db8::1]:8080", "2001:db8::1"},
|
||||||
|
{"IPv6 localhost with port", "[::1]:3000", "::1"},
|
||||||
|
{"IPv6 without port", "2001:db8::1", "2001:db8::1"},
|
||||||
|
{"IPv6 localhost", "::1", "::1"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
result := tt.input
|
||||||
|
if host, _, err := net.SplitHostPort(result); err == nil {
|
||||||
|
result = host
|
||||||
|
}
|
||||||
|
|
||||||
|
if result != tt.expected {
|
||||||
|
t.Errorf("Expected %s, got %s", tt.expected, result)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestIPExtractionEdgeCases tests edge cases for IP extraction
|
||||||
|
func TestIPExtractionEdgeCases(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
input string
|
||||||
|
expected string
|
||||||
|
}{
|
||||||
|
{"Hostname without port", "example.com", "example.com"},
|
||||||
|
{"Empty string", "", ""},
|
||||||
|
{"Just port", ":8080", ""},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
result := tt.input
|
||||||
|
if host, _, err := net.SplitHostPort(result); err == nil {
|
||||||
|
result = host
|
||||||
|
}
|
||||||
|
|
||||||
|
if result != tt.expected {
|
||||||
|
t.Errorf("Expected %s, got %s", tt.expected, result)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
+12
-30
@@ -14,7 +14,6 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/ClusterCockpit/cc-backend/internal/repository"
|
|
||||||
cclog "github.com/ClusterCockpit/cc-lib/ccLogger"
|
cclog "github.com/ClusterCockpit/cc-lib/ccLogger"
|
||||||
"github.com/ClusterCockpit/cc-lib/schema"
|
"github.com/ClusterCockpit/cc-lib/schema"
|
||||||
"github.com/golang-jwt/jwt/v5"
|
"github.com/golang-jwt/jwt/v5"
|
||||||
@@ -102,38 +101,21 @@ func (ja *JWTAuthenticator) AuthViaJWT(
|
|||||||
|
|
||||||
// Token is valid, extract payload
|
// Token is valid, extract payload
|
||||||
claims := token.Claims.(jwt.MapClaims)
|
claims := token.Claims.(jwt.MapClaims)
|
||||||
sub, _ := claims["sub"].(string)
|
|
||||||
|
|
||||||
var roles []string
|
// Use shared helper to get user from JWT claims
|
||||||
|
var user *schema.User
|
||||||
// Validate user + roles from JWT against database?
|
user, err = getUserFromJWT(claims, Keys.JwtConfig.ValidateUser, schema.AuthToken, -1)
|
||||||
if Keys.JwtConfig.ValidateUser {
|
if err != nil {
|
||||||
ur := repository.GetUserRepository()
|
return nil, err
|
||||||
user, err := ur.GetUser(sub)
|
|
||||||
// Deny any logins for unknown usernames
|
|
||||||
if err != nil {
|
|
||||||
cclog.Warn("Could not find user from JWT in internal database.")
|
|
||||||
return nil, errors.New("unknown user")
|
|
||||||
}
|
|
||||||
// Take user roles from database instead of trusting the JWT
|
|
||||||
roles = user.Roles
|
|
||||||
} else {
|
|
||||||
// Extract roles from JWT (if present)
|
|
||||||
if rawroles, ok := claims["roles"].([]any); ok {
|
|
||||||
for _, rr := range rawroles {
|
|
||||||
if r, ok := rr.(string); ok {
|
|
||||||
roles = append(roles, r)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return &schema.User{
|
// If not validating user, we only get roles from JWT (no projects for this auth method)
|
||||||
Username: sub,
|
if !Keys.JwtConfig.ValidateUser {
|
||||||
Roles: roles,
|
user.Roles = extractRolesFromClaims(claims, false)
|
||||||
AuthType: schema.AuthToken,
|
user.Projects = nil // Standard JWT auth doesn't include projects
|
||||||
AuthSource: -1,
|
}
|
||||||
}, nil
|
|
||||||
|
return user, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ProvideJWT generates a new JWT that can be used for authentication
|
// ProvideJWT generates a new JWT that can be used for authentication
|
||||||
|
|||||||
@@ -7,14 +7,11 @@ package auth
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"crypto/ed25519"
|
"crypto/ed25519"
|
||||||
"database/sql"
|
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
|
|
||||||
"github.com/ClusterCockpit/cc-backend/internal/repository"
|
|
||||||
cclog "github.com/ClusterCockpit/cc-lib/ccLogger"
|
cclog "github.com/ClusterCockpit/cc-lib/ccLogger"
|
||||||
"github.com/ClusterCockpit/cc-lib/schema"
|
"github.com/ClusterCockpit/cc-lib/schema"
|
||||||
"github.com/golang-jwt/jwt/v5"
|
"github.com/golang-jwt/jwt/v5"
|
||||||
@@ -149,57 +146,16 @@ func (ja *JWTCookieSessionAuthenticator) Login(
|
|||||||
}
|
}
|
||||||
|
|
||||||
claims := token.Claims.(jwt.MapClaims)
|
claims := token.Claims.(jwt.MapClaims)
|
||||||
sub, _ := claims["sub"].(string)
|
|
||||||
|
|
||||||
var roles []string
|
// Use shared helper to get user from JWT claims
|
||||||
projects := make([]string, 0)
|
user, err = getUserFromJWT(claims, jc.ValidateUser, schema.AuthSession, schema.AuthViaToken)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
if jc.ValidateUser {
|
// Sync or update user if configured
|
||||||
var err error
|
if !jc.ValidateUser && (jc.SyncUserOnLogin || jc.UpdateUserOnLogin) {
|
||||||
user, err = repository.GetUserRepository().GetUser(sub)
|
handleTokenUser(user)
|
||||||
if err != nil && err != sql.ErrNoRows {
|
|
||||||
cclog.Errorf("Error while loading user '%v'", sub)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Deny any logins for unknown usernames
|
|
||||||
if user == nil {
|
|
||||||
cclog.Warn("Could not find user from JWT in internal database.")
|
|
||||||
return nil, errors.New("unknown user")
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
var name string
|
|
||||||
if wrap, ok := claims["name"].(map[string]any); ok {
|
|
||||||
if vals, ok := wrap["values"].([]any); ok {
|
|
||||||
if len(vals) != 0 {
|
|
||||||
name = fmt.Sprintf("%v", vals[0])
|
|
||||||
|
|
||||||
for i := 1; i < len(vals); i++ {
|
|
||||||
name += fmt.Sprintf(" %v", vals[i])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Extract roles from JWT (if present)
|
|
||||||
if rawroles, ok := claims["roles"].([]any); ok {
|
|
||||||
for _, rr := range rawroles {
|
|
||||||
if r, ok := rr.(string); ok {
|
|
||||||
roles = append(roles, r)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
user = &schema.User{
|
|
||||||
Username: sub,
|
|
||||||
Name: name,
|
|
||||||
Roles: roles,
|
|
||||||
Projects: projects,
|
|
||||||
AuthType: schema.AuthSession,
|
|
||||||
AuthSource: schema.AuthViaToken,
|
|
||||||
}
|
|
||||||
|
|
||||||
if jc.SyncUserOnLogin || jc.UpdateUserOnLogin {
|
|
||||||
handleTokenUser(user)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// (Ask browser to) Delete JWT cookie
|
// (Ask browser to) Delete JWT cookie
|
||||||
|
|||||||
@@ -0,0 +1,136 @@
|
|||||||
|
// 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 auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/ClusterCockpit/cc-backend/internal/repository"
|
||||||
|
cclog "github.com/ClusterCockpit/cc-lib/ccLogger"
|
||||||
|
"github.com/ClusterCockpit/cc-lib/schema"
|
||||||
|
"github.com/golang-jwt/jwt/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
// extractStringFromClaims extracts a string value from JWT claims
|
||||||
|
func extractStringFromClaims(claims jwt.MapClaims, key string) string {
|
||||||
|
if val, ok := claims[key].(string); ok {
|
||||||
|
return val
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// extractRolesFromClaims extracts roles from JWT claims
|
||||||
|
// If validateRoles is true, only valid roles are returned
|
||||||
|
func extractRolesFromClaims(claims jwt.MapClaims, validateRoles bool) []string {
|
||||||
|
var roles []string
|
||||||
|
|
||||||
|
if rawroles, ok := claims["roles"].([]any); ok {
|
||||||
|
for _, rr := range rawroles {
|
||||||
|
if r, ok := rr.(string); ok {
|
||||||
|
if validateRoles {
|
||||||
|
if schema.IsValidRole(r) {
|
||||||
|
roles = append(roles, r)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
roles = append(roles, r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return roles
|
||||||
|
}
|
||||||
|
|
||||||
|
// extractProjectsFromClaims extracts projects from JWT claims
|
||||||
|
func extractProjectsFromClaims(claims jwt.MapClaims) []string {
|
||||||
|
projects := make([]string, 0)
|
||||||
|
|
||||||
|
if rawprojs, ok := claims["projects"].([]any); ok {
|
||||||
|
for _, pp := range rawprojs {
|
||||||
|
if p, ok := pp.(string); ok {
|
||||||
|
projects = append(projects, p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if rawprojs, ok := claims["projects"]; ok {
|
||||||
|
if projSlice, ok := rawprojs.([]string); ok {
|
||||||
|
projects = append(projects, projSlice...)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return projects
|
||||||
|
}
|
||||||
|
|
||||||
|
// extractNameFromClaims extracts name from JWT claims
|
||||||
|
// Handles both simple string and complex nested structure
|
||||||
|
func extractNameFromClaims(claims jwt.MapClaims) string {
|
||||||
|
// Try simple string first
|
||||||
|
if name, ok := claims["name"].(string); ok {
|
||||||
|
return name
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try nested structure: {name: {values: [...]}}
|
||||||
|
if wrap, ok := claims["name"].(map[string]any); ok {
|
||||||
|
if vals, ok := wrap["values"].([]any); ok {
|
||||||
|
if len(vals) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
name := fmt.Sprintf("%v", vals[0])
|
||||||
|
for i := 1; i < len(vals); i++ {
|
||||||
|
name += fmt.Sprintf(" %v", vals[i])
|
||||||
|
}
|
||||||
|
return name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// getUserFromJWT creates or retrieves a user based on JWT claims
|
||||||
|
// If validateUser is true, the user must exist in the database
|
||||||
|
// Otherwise, a new user object is created from claims
|
||||||
|
// authSource should be a schema.AuthSource constant (like schema.AuthViaToken)
|
||||||
|
func getUserFromJWT(claims jwt.MapClaims, validateUser bool, authType schema.AuthType, authSource schema.AuthSource) (*schema.User, error) {
|
||||||
|
sub := extractStringFromClaims(claims, "sub")
|
||||||
|
if sub == "" {
|
||||||
|
return nil, errors.New("missing 'sub' claim in JWT")
|
||||||
|
}
|
||||||
|
|
||||||
|
if validateUser {
|
||||||
|
// Validate user against database
|
||||||
|
ur := repository.GetUserRepository()
|
||||||
|
user, err := ur.GetUser(sub)
|
||||||
|
if err != nil && err != sql.ErrNoRows {
|
||||||
|
cclog.Errorf("Error while loading user '%v': %v", sub, err)
|
||||||
|
return nil, fmt.Errorf("database error: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deny any logins for unknown usernames
|
||||||
|
if user == nil || err == sql.ErrNoRows {
|
||||||
|
cclog.Warn("Could not find user from JWT in internal database.")
|
||||||
|
return nil, errors.New("unknown user")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return database user (with database roles)
|
||||||
|
return user, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create user from JWT claims
|
||||||
|
name := extractNameFromClaims(claims)
|
||||||
|
roles := extractRolesFromClaims(claims, true) // Validate roles
|
||||||
|
projects := extractProjectsFromClaims(claims)
|
||||||
|
|
||||||
|
return &schema.User{
|
||||||
|
Username: sub,
|
||||||
|
Name: name,
|
||||||
|
Roles: roles,
|
||||||
|
Projects: projects,
|
||||||
|
AuthType: authType,
|
||||||
|
AuthSource: authSource,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,281 @@
|
|||||||
|
// 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 auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/ClusterCockpit/cc-lib/schema"
|
||||||
|
"github.com/golang-jwt/jwt/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestExtractStringFromClaims tests extracting string values from JWT claims
|
||||||
|
func TestExtractStringFromClaims(t *testing.T) {
|
||||||
|
claims := jwt.MapClaims{
|
||||||
|
"sub": "testuser",
|
||||||
|
"email": "test@example.com",
|
||||||
|
"age": 25, // not a string
|
||||||
|
}
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
key string
|
||||||
|
expected string
|
||||||
|
}{
|
||||||
|
{"Existing string", "sub", "testuser"},
|
||||||
|
{"Another string", "email", "test@example.com"},
|
||||||
|
{"Non-existent key", "missing", ""},
|
||||||
|
{"Non-string value", "age", ""},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
result := extractStringFromClaims(claims, tt.key)
|
||||||
|
if result != tt.expected {
|
||||||
|
t.Errorf("Expected %s, got %s", tt.expected, result)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestExtractRolesFromClaims tests role extraction and validation
|
||||||
|
func TestExtractRolesFromClaims(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
claims jwt.MapClaims
|
||||||
|
validateRoles bool
|
||||||
|
expected []string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "Valid roles without validation",
|
||||||
|
claims: jwt.MapClaims{
|
||||||
|
"roles": []any{"admin", "user", "invalid_role"},
|
||||||
|
},
|
||||||
|
validateRoles: false,
|
||||||
|
expected: []string{"admin", "user", "invalid_role"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Valid roles with validation",
|
||||||
|
claims: jwt.MapClaims{
|
||||||
|
"roles": []any{"admin", "user", "api"},
|
||||||
|
},
|
||||||
|
validateRoles: true,
|
||||||
|
expected: []string{"admin", "user", "api"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Invalid roles with validation",
|
||||||
|
claims: jwt.MapClaims{
|
||||||
|
"roles": []any{"invalid_role", "fake_role"},
|
||||||
|
},
|
||||||
|
validateRoles: true,
|
||||||
|
expected: []string{}, // Should filter out invalid roles
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "No roles claim",
|
||||||
|
claims: jwt.MapClaims{},
|
||||||
|
validateRoles: false,
|
||||||
|
expected: []string{},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Non-array roles",
|
||||||
|
claims: jwt.MapClaims{
|
||||||
|
"roles": "admin",
|
||||||
|
},
|
||||||
|
validateRoles: false,
|
||||||
|
expected: []string{},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
result := extractRolesFromClaims(tt.claims, tt.validateRoles)
|
||||||
|
|
||||||
|
if len(result) != len(tt.expected) {
|
||||||
|
t.Errorf("Expected %d roles, got %d", len(tt.expected), len(result))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
for i, role := range result {
|
||||||
|
if i >= len(tt.expected) || role != tt.expected[i] {
|
||||||
|
t.Errorf("Expected role %s at position %d, got %s", tt.expected[i], i, role)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestExtractProjectsFromClaims tests project extraction from claims
|
||||||
|
func TestExtractProjectsFromClaims(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
claims jwt.MapClaims
|
||||||
|
expected []string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "Projects as array of interfaces",
|
||||||
|
claims: jwt.MapClaims{
|
||||||
|
"projects": []any{"project1", "project2", "project3"},
|
||||||
|
},
|
||||||
|
expected: []string{"project1", "project2", "project3"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Projects as string array",
|
||||||
|
claims: jwt.MapClaims{
|
||||||
|
"projects": []string{"projectA", "projectB"},
|
||||||
|
},
|
||||||
|
expected: []string{"projectA", "projectB"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "No projects claim",
|
||||||
|
claims: jwt.MapClaims{},
|
||||||
|
expected: []string{},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Mixed types in projects array",
|
||||||
|
claims: jwt.MapClaims{
|
||||||
|
"projects": []any{"project1", 123, "project2"},
|
||||||
|
},
|
||||||
|
expected: []string{"project1", "project2"}, // Should skip non-strings
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
result := extractProjectsFromClaims(tt.claims)
|
||||||
|
|
||||||
|
if len(result) != len(tt.expected) {
|
||||||
|
t.Errorf("Expected %d projects, got %d", len(tt.expected), len(result))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
for i, project := range result {
|
||||||
|
if i >= len(tt.expected) || project != tt.expected[i] {
|
||||||
|
t.Errorf("Expected project %s at position %d, got %s", tt.expected[i], i, project)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestExtractNameFromClaims tests name extraction from various formats
|
||||||
|
func TestExtractNameFromClaims(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
claims jwt.MapClaims
|
||||||
|
expected string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "Simple string name",
|
||||||
|
claims: jwt.MapClaims{
|
||||||
|
"name": "John Doe",
|
||||||
|
},
|
||||||
|
expected: "John Doe",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Nested name structure",
|
||||||
|
claims: jwt.MapClaims{
|
||||||
|
"name": map[string]any{
|
||||||
|
"values": []any{"John", "Doe"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
expected: "John Doe",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Nested name with single value",
|
||||||
|
claims: jwt.MapClaims{
|
||||||
|
"name": map[string]any{
|
||||||
|
"values": []any{"Alice"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
expected: "Alice",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "No name claim",
|
||||||
|
claims: jwt.MapClaims{},
|
||||||
|
expected: "",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Empty nested values",
|
||||||
|
claims: jwt.MapClaims{
|
||||||
|
"name": map[string]any{
|
||||||
|
"values": []any{},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
expected: "",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Nested with non-string values",
|
||||||
|
claims: jwt.MapClaims{
|
||||||
|
"name": map[string]any{
|
||||||
|
"values": []any{123, "Smith"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
expected: "123 Smith", // Should convert to string
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
result := extractNameFromClaims(tt.claims)
|
||||||
|
if result != tt.expected {
|
||||||
|
t.Errorf("Expected '%s', got '%s'", tt.expected, result)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestGetUserFromJWT_NoValidation tests getUserFromJWT without database validation
|
||||||
|
func TestGetUserFromJWT_NoValidation(t *testing.T) {
|
||||||
|
claims := jwt.MapClaims{
|
||||||
|
"sub": "testuser",
|
||||||
|
"name": "Test User",
|
||||||
|
"roles": []any{"user", "admin"},
|
||||||
|
"projects": []any{"project1", "project2"},
|
||||||
|
}
|
||||||
|
|
||||||
|
user, err := getUserFromJWT(claims, false, schema.AuthToken, -1)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if user.Username != "testuser" {
|
||||||
|
t.Errorf("Expected username 'testuser', got '%s'", user.Username)
|
||||||
|
}
|
||||||
|
|
||||||
|
if user.Name != "Test User" {
|
||||||
|
t.Errorf("Expected name 'Test User', got '%s'", user.Name)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(user.Roles) != 2 {
|
||||||
|
t.Errorf("Expected 2 roles, got %d", len(user.Roles))
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(user.Projects) != 2 {
|
||||||
|
t.Errorf("Expected 2 projects, got %d", len(user.Projects))
|
||||||
|
}
|
||||||
|
|
||||||
|
if user.AuthType != schema.AuthToken {
|
||||||
|
t.Errorf("Expected AuthType %v, got %v", schema.AuthToken, user.AuthType)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestGetUserFromJWT_MissingSub tests error when sub claim is missing
|
||||||
|
func TestGetUserFromJWT_MissingSub(t *testing.T) {
|
||||||
|
claims := jwt.MapClaims{
|
||||||
|
"name": "Test User",
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := getUserFromJWT(claims, false, schema.AuthToken, -1)
|
||||||
|
|
||||||
|
if err == nil {
|
||||||
|
t.Error("Expected error for missing sub claim")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err.Error() != "missing 'sub' claim in JWT" {
|
||||||
|
t.Errorf("Expected specific error message, got: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,7 +6,6 @@
|
|||||||
package auth
|
package auth
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"database/sql"
|
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -14,7 +13,6 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/ClusterCockpit/cc-backend/internal/repository"
|
|
||||||
cclog "github.com/ClusterCockpit/cc-lib/ccLogger"
|
cclog "github.com/ClusterCockpit/cc-lib/ccLogger"
|
||||||
"github.com/ClusterCockpit/cc-lib/schema"
|
"github.com/ClusterCockpit/cc-lib/schema"
|
||||||
"github.com/golang-jwt/jwt/v5"
|
"github.com/golang-jwt/jwt/v5"
|
||||||
@@ -77,70 +75,16 @@ func (ja *JWTSessionAuthenticator) Login(
|
|||||||
}
|
}
|
||||||
|
|
||||||
claims := token.Claims.(jwt.MapClaims)
|
claims := token.Claims.(jwt.MapClaims)
|
||||||
sub, _ := claims["sub"].(string)
|
|
||||||
|
|
||||||
var roles []string
|
// Use shared helper to get user from JWT claims
|
||||||
projects := make([]string, 0)
|
user, err = getUserFromJWT(claims, Keys.JwtConfig.ValidateUser, schema.AuthSession, schema.AuthViaToken)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
if Keys.JwtConfig.ValidateUser {
|
// Sync or update user if configured
|
||||||
var err error
|
if !Keys.JwtConfig.ValidateUser && (Keys.JwtConfig.SyncUserOnLogin || Keys.JwtConfig.UpdateUserOnLogin) {
|
||||||
user, err = repository.GetUserRepository().GetUser(sub)
|
handleTokenUser(user)
|
||||||
if err != nil && err != sql.ErrNoRows {
|
|
||||||
cclog.Errorf("Error while loading user '%v'", sub)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Deny any logins for unknown usernames
|
|
||||||
if user == nil {
|
|
||||||
cclog.Warn("Could not find user from JWT in internal database.")
|
|
||||||
return nil, errors.New("unknown user")
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
var name string
|
|
||||||
if wrap, ok := claims["name"].(map[string]any); ok {
|
|
||||||
if vals, ok := wrap["values"].([]any); ok {
|
|
||||||
if len(vals) != 0 {
|
|
||||||
name = fmt.Sprintf("%v", vals[0])
|
|
||||||
|
|
||||||
for i := 1; i < len(vals); i++ {
|
|
||||||
name += fmt.Sprintf(" %v", vals[i])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Extract roles from JWT (if present)
|
|
||||||
if rawroles, ok := claims["roles"].([]any); ok {
|
|
||||||
for _, rr := range rawroles {
|
|
||||||
if r, ok := rr.(string); ok {
|
|
||||||
if schema.IsValidRole(r) {
|
|
||||||
roles = append(roles, r)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if rawprojs, ok := claims["projects"].([]any); ok {
|
|
||||||
for _, pp := range rawprojs {
|
|
||||||
if p, ok := pp.(string); ok {
|
|
||||||
projects = append(projects, p)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if rawprojs, ok := claims["projects"]; ok {
|
|
||||||
projects = append(projects, rawprojs.([]string)...)
|
|
||||||
}
|
|
||||||
|
|
||||||
user = &schema.User{
|
|
||||||
Username: sub,
|
|
||||||
Name: name,
|
|
||||||
Roles: roles,
|
|
||||||
Projects: projects,
|
|
||||||
AuthType: schema.AuthSession,
|
|
||||||
AuthSource: schema.AuthViaToken,
|
|
||||||
}
|
|
||||||
|
|
||||||
if Keys.JwtConfig.SyncUserOnLogin || Keys.JwtConfig.UpdateUserOnLogin {
|
|
||||||
handleTokenUser(user)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return user, nil
|
return user, nil
|
||||||
|
|||||||
+15
-5
@@ -54,8 +54,13 @@ func setCallbackCookie(w http.ResponseWriter, r *http.Request, name, value strin
|
|||||||
http.SetCookie(w, c)
|
http.SetCookie(w, c)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// NewOIDC creates a new OIDC authenticator with the configured provider
|
||||||
func NewOIDC(a *Authentication) *OIDC {
|
func NewOIDC(a *Authentication) *OIDC {
|
||||||
provider, err := oidc.NewProvider(context.Background(), Keys.OpenIDConfig.Provider)
|
// Use context with timeout for provider initialization
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
provider, err := oidc.NewProvider(ctx, Keys.OpenIDConfig.Provider)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
cclog.Fatal(err)
|
cclog.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -111,13 +116,18 @@ func (oa *OIDC) OAuth2Callback(rw http.ResponseWriter, r *http.Request) {
|
|||||||
http.Error(rw, "Code not found", http.StatusBadRequest)
|
http.Error(rw, "Code not found", http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
token, err := oa.client.Exchange(context.Background(), code, oauth2.VerifierOption(codeVerifier))
|
// Exchange authorization code for token with timeout
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
token, err := oa.client.Exchange(ctx, code, oauth2.VerifierOption(codeVerifier))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(rw, "Failed to exchange token: "+err.Error(), http.StatusInternalServerError)
|
http.Error(rw, "Failed to exchange token: "+err.Error(), http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
userInfo, err := oa.provider.UserInfo(context.Background(), oauth2.StaticTokenSource(token))
|
// Get user info from OIDC provider with same timeout
|
||||||
|
userInfo, err := oa.provider.UserInfo(ctx, oauth2.StaticTokenSource(token))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(rw, "Failed to get userinfo: "+err.Error(), http.StatusInternalServerError)
|
http.Error(rw, "Failed to get userinfo: "+err.Error(), http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
@@ -180,8 +190,8 @@ func (oa *OIDC) OAuth2Callback(rw http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
oa.authentication.SaveSession(rw, r, user)
|
oa.authentication.SaveSession(rw, r, user)
|
||||||
cclog.Infof("login successfull: user: %#v (roles: %v, projects: %v)", user.Username, user.Roles, user.Projects)
|
cclog.Infof("login successfull: user: %#v (roles: %v, projects: %v)", user.Username, user.Roles, user.Projects)
|
||||||
ctx := context.WithValue(r.Context(), repository.ContextUserKey, user)
|
userCtx := context.WithValue(r.Context(), repository.ContextUserKey, user)
|
||||||
http.RedirectHandler("/", http.StatusTemporaryRedirect).ServeHTTP(rw, r.WithContext(ctx))
|
http.RedirectHandler("/", http.StatusTemporaryRedirect).ServeHTTP(rw, r.WithContext(userCtx))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (oa *OIDC) OAuth2Login(rw http.ResponseWriter, r *http.Request) {
|
func (oa *OIDC) OAuth2Login(rw http.ResponseWriter, r *http.Request) {
|
||||||
|
|||||||
@@ -50,8 +50,7 @@ type ResolverRoot interface {
|
|||||||
SubCluster() SubClusterResolver
|
SubCluster() SubClusterResolver
|
||||||
}
|
}
|
||||||
|
|
||||||
type DirectiveRoot struct {
|
type DirectiveRoot struct{}
|
||||||
}
|
|
||||||
|
|
||||||
type ComplexityRoot struct {
|
type ComplexityRoot struct {
|
||||||
Accelerator struct {
|
Accelerator struct {
|
||||||
@@ -288,6 +287,7 @@ type ComplexityRoot struct {
|
|||||||
NodeMetrics struct {
|
NodeMetrics struct {
|
||||||
Host func(childComplexity int) int
|
Host func(childComplexity int) int
|
||||||
Metrics func(childComplexity int) int
|
Metrics func(childComplexity int) int
|
||||||
|
State func(childComplexity int) int
|
||||||
SubCluster func(childComplexity int) int
|
SubCluster func(childComplexity int) int
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -302,10 +302,9 @@ type ComplexityRoot struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
NodeStatesTimed struct {
|
NodeStatesTimed struct {
|
||||||
Count func(childComplexity int) int
|
Counts func(childComplexity int) int
|
||||||
State func(childComplexity int) int
|
State func(childComplexity int) int
|
||||||
Time func(childComplexity int) int
|
Times func(childComplexity int) int
|
||||||
Type func(childComplexity int) int
|
|
||||||
}
|
}
|
||||||
|
|
||||||
NodesResultList struct {
|
NodesResultList struct {
|
||||||
@@ -330,9 +329,9 @@ type ComplexityRoot struct {
|
|||||||
JobsStatistics func(childComplexity int, filter []*model.JobFilter, metrics []string, page *model.PageRequest, sortBy *model.SortByAggregate, groupBy *model.Aggregate, numDurationBins *string, numMetricBins *int) int
|
JobsStatistics func(childComplexity int, filter []*model.JobFilter, metrics []string, page *model.PageRequest, sortBy *model.SortByAggregate, groupBy *model.Aggregate, numDurationBins *string, numMetricBins *int) int
|
||||||
Node func(childComplexity int, id string) int
|
Node func(childComplexity int, id string) int
|
||||||
NodeMetrics func(childComplexity int, cluster string, nodes []string, scopes []schema.MetricScope, metrics []string, from time.Time, to time.Time) int
|
NodeMetrics func(childComplexity int, cluster string, nodes []string, scopes []schema.MetricScope, metrics []string, from time.Time, to time.Time) int
|
||||||
NodeMetricsList func(childComplexity int, cluster string, subCluster string, nodeFilter string, scopes []schema.MetricScope, metrics []string, from time.Time, to time.Time, page *model.PageRequest, resolution *int) int
|
NodeMetricsList func(childComplexity int, cluster string, subCluster string, stateFilter string, nodeFilter string, scopes []schema.MetricScope, metrics []string, from time.Time, to time.Time, page *model.PageRequest, resolution *int) int
|
||||||
NodeStates func(childComplexity int, filter []*model.NodeFilter) int
|
NodeStates func(childComplexity int, filter []*model.NodeFilter) int
|
||||||
NodeStatesTimed func(childComplexity int, filter []*model.NodeFilter) int
|
NodeStatesTimed func(childComplexity int, filter []*model.NodeFilter, typeArg string) int
|
||||||
Nodes func(childComplexity int, filter []*model.NodeFilter, order *model.OrderByInput) int
|
Nodes func(childComplexity int, filter []*model.NodeFilter, order *model.OrderByInput) int
|
||||||
RooflineHeatmap func(childComplexity int, filter []*model.JobFilter, rows int, cols int, minX float64, minY float64, maxX float64, maxY float64) int
|
RooflineHeatmap func(childComplexity int, filter []*model.JobFilter, rows int, cols int, minX float64, minY float64, maxX float64, maxY float64) int
|
||||||
ScopedJobStats func(childComplexity int, id string, metrics []string, scopes []schema.MetricScope) int
|
ScopedJobStats func(childComplexity int, id string, metrics []string, scopes []schema.MetricScope) int
|
||||||
@@ -473,7 +472,7 @@ type QueryResolver interface {
|
|||||||
Node(ctx context.Context, id string) (*schema.Node, error)
|
Node(ctx context.Context, id string) (*schema.Node, error)
|
||||||
Nodes(ctx context.Context, filter []*model.NodeFilter, order *model.OrderByInput) (*model.NodeStateResultList, error)
|
Nodes(ctx context.Context, filter []*model.NodeFilter, order *model.OrderByInput) (*model.NodeStateResultList, error)
|
||||||
NodeStates(ctx context.Context, filter []*model.NodeFilter) ([]*model.NodeStates, error)
|
NodeStates(ctx context.Context, filter []*model.NodeFilter) ([]*model.NodeStates, error)
|
||||||
NodeStatesTimed(ctx context.Context, filter []*model.NodeFilter) ([]*model.NodeStatesTimed, error)
|
NodeStatesTimed(ctx context.Context, filter []*model.NodeFilter, typeArg string) ([]*model.NodeStatesTimed, error)
|
||||||
Job(ctx context.Context, id string) (*schema.Job, error)
|
Job(ctx context.Context, id string) (*schema.Job, error)
|
||||||
JobMetrics(ctx context.Context, id string, metrics []string, scopes []schema.MetricScope, resolution *int) ([]*model.JobMetricWithName, error)
|
JobMetrics(ctx context.Context, id string, metrics []string, scopes []schema.MetricScope, resolution *int) ([]*model.JobMetricWithName, error)
|
||||||
JobStats(ctx context.Context, id string, metrics []string) ([]*model.NamedStats, error)
|
JobStats(ctx context.Context, id string, metrics []string) ([]*model.NamedStats, error)
|
||||||
@@ -484,7 +483,7 @@ type QueryResolver interface {
|
|||||||
JobsFootprints(ctx context.Context, filter []*model.JobFilter, metrics []string) (*model.Footprints, error)
|
JobsFootprints(ctx context.Context, filter []*model.JobFilter, metrics []string) (*model.Footprints, error)
|
||||||
RooflineHeatmap(ctx context.Context, filter []*model.JobFilter, rows int, cols int, minX float64, minY float64, maxX float64, maxY float64) ([][]float64, error)
|
RooflineHeatmap(ctx context.Context, filter []*model.JobFilter, rows int, cols int, minX float64, minY float64, maxX float64, maxY float64) ([][]float64, error)
|
||||||
NodeMetrics(ctx context.Context, cluster string, nodes []string, scopes []schema.MetricScope, metrics []string, from time.Time, to time.Time) ([]*model.NodeMetrics, error)
|
NodeMetrics(ctx context.Context, cluster string, nodes []string, scopes []schema.MetricScope, metrics []string, from time.Time, to time.Time) ([]*model.NodeMetrics, error)
|
||||||
NodeMetricsList(ctx context.Context, cluster string, subCluster string, nodeFilter string, scopes []schema.MetricScope, metrics []string, from time.Time, to time.Time, page *model.PageRequest, resolution *int) (*model.NodesResultList, error)
|
NodeMetricsList(ctx context.Context, cluster string, subCluster string, stateFilter string, nodeFilter string, scopes []schema.MetricScope, metrics []string, from time.Time, to time.Time, page *model.PageRequest, resolution *int) (*model.NodesResultList, error)
|
||||||
}
|
}
|
||||||
type SubClusterResolver interface {
|
type SubClusterResolver interface {
|
||||||
NumberOfNodes(ctx context.Context, obj *schema.SubCluster) (int, error)
|
NumberOfNodes(ctx context.Context, obj *schema.SubCluster) (int, error)
|
||||||
@@ -1455,12 +1454,21 @@ func (e *executableSchema) Complexity(ctx context.Context, typeName, field strin
|
|||||||
}
|
}
|
||||||
|
|
||||||
return e.complexity.NodeMetrics.Host(childComplexity), true
|
return e.complexity.NodeMetrics.Host(childComplexity), true
|
||||||
|
|
||||||
case "NodeMetrics.metrics":
|
case "NodeMetrics.metrics":
|
||||||
if e.complexity.NodeMetrics.Metrics == nil {
|
if e.complexity.NodeMetrics.Metrics == nil {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
return e.complexity.NodeMetrics.Metrics(childComplexity), true
|
return e.complexity.NodeMetrics.Metrics(childComplexity), true
|
||||||
|
|
||||||
|
case "NodeMetrics.state":
|
||||||
|
if e.complexity.NodeMetrics.State == nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
return e.complexity.NodeMetrics.State(childComplexity), true
|
||||||
|
|
||||||
case "NodeMetrics.subCluster":
|
case "NodeMetrics.subCluster":
|
||||||
if e.complexity.NodeMetrics.SubCluster == nil {
|
if e.complexity.NodeMetrics.SubCluster == nil {
|
||||||
break
|
break
|
||||||
@@ -1494,30 +1502,26 @@ func (e *executableSchema) Complexity(ctx context.Context, typeName, field strin
|
|||||||
|
|
||||||
return e.complexity.NodeStates.State(childComplexity), true
|
return e.complexity.NodeStates.State(childComplexity), true
|
||||||
|
|
||||||
case "NodeStatesTimed.count":
|
case "NodeStatesTimed.counts":
|
||||||
if e.complexity.NodeStatesTimed.Count == nil {
|
if e.complexity.NodeStatesTimed.Counts == nil {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
return e.complexity.NodeStatesTimed.Count(childComplexity), true
|
return e.complexity.NodeStatesTimed.Counts(childComplexity), true
|
||||||
|
|
||||||
case "NodeStatesTimed.state":
|
case "NodeStatesTimed.state":
|
||||||
if e.complexity.NodeStatesTimed.State == nil {
|
if e.complexity.NodeStatesTimed.State == nil {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
return e.complexity.NodeStatesTimed.State(childComplexity), true
|
return e.complexity.NodeStatesTimed.State(childComplexity), true
|
||||||
case "NodeStatesTimed.time":
|
|
||||||
if e.complexity.NodeStatesTimed.Time == nil {
|
case "NodeStatesTimed.times":
|
||||||
|
if e.complexity.NodeStatesTimed.Times == nil {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
return e.complexity.NodeStatesTimed.Time(childComplexity), true
|
return e.complexity.NodeStatesTimed.Times(childComplexity), true
|
||||||
case "NodeStatesTimed.type":
|
|
||||||
if e.complexity.NodeStatesTimed.Type == nil {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
return e.complexity.NodeStatesTimed.Type(childComplexity), true
|
|
||||||
|
|
||||||
case "NodesResultList.count":
|
case "NodesResultList.count":
|
||||||
if e.complexity.NodesResultList.Count == nil {
|
if e.complexity.NodesResultList.Count == nil {
|
||||||
@@ -1688,7 +1692,8 @@ func (e *executableSchema) Complexity(ctx context.Context, typeName, field strin
|
|||||||
return 0, false
|
return 0, false
|
||||||
}
|
}
|
||||||
|
|
||||||
return e.complexity.Query.NodeMetricsList(childComplexity, args["cluster"].(string), args["subCluster"].(string), args["nodeFilter"].(string), args["scopes"].([]schema.MetricScope), args["metrics"].([]string), args["from"].(time.Time), args["to"].(time.Time), args["page"].(*model.PageRequest), args["resolution"].(*int)), true
|
return e.complexity.Query.NodeMetricsList(childComplexity, args["cluster"].(string), args["subCluster"].(string), args["stateFilter"].(string), args["nodeFilter"].(string), args["scopes"].([]schema.MetricScope), args["metrics"].([]string), args["from"].(time.Time), args["to"].(time.Time), args["page"].(*model.PageRequest), args["resolution"].(*int)), true
|
||||||
|
|
||||||
case "Query.nodeStates":
|
case "Query.nodeStates":
|
||||||
if e.complexity.Query.NodeStates == nil {
|
if e.complexity.Query.NodeStates == nil {
|
||||||
break
|
break
|
||||||
@@ -1710,7 +1715,8 @@ func (e *executableSchema) Complexity(ctx context.Context, typeName, field strin
|
|||||||
return 0, false
|
return 0, false
|
||||||
}
|
}
|
||||||
|
|
||||||
return e.complexity.Query.NodeStatesTimed(childComplexity, args["filter"].([]*model.NodeFilter)), true
|
return e.complexity.Query.NodeStatesTimed(childComplexity, args["filter"].([]*model.NodeFilter), args["type"].(string)), true
|
||||||
|
|
||||||
case "Query.nodes":
|
case "Query.nodes":
|
||||||
if e.complexity.Query.Nodes == nil {
|
if e.complexity.Query.Nodes == nil {
|
||||||
break
|
break
|
||||||
@@ -2248,9 +2254,8 @@ type NodeStates {
|
|||||||
|
|
||||||
type NodeStatesTimed {
|
type NodeStatesTimed {
|
||||||
state: String!
|
state: String!
|
||||||
type: String!
|
counts: [Int!]!
|
||||||
count: Int!
|
times: [Int!]!
|
||||||
time: Int!
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type Job {
|
type Job {
|
||||||
@@ -2483,6 +2488,7 @@ enum SortByAggregate {
|
|||||||
|
|
||||||
type NodeMetrics {
|
type NodeMetrics {
|
||||||
host: String!
|
host: String!
|
||||||
|
state: String!
|
||||||
subCluster: String!
|
subCluster: String!
|
||||||
metrics: [JobMetricWithName!]!
|
metrics: [JobMetricWithName!]!
|
||||||
}
|
}
|
||||||
@@ -2537,7 +2543,7 @@ type Query {
|
|||||||
node(id: ID!): Node
|
node(id: ID!): Node
|
||||||
nodes(filter: [NodeFilter!], order: OrderByInput): NodeStateResultList!
|
nodes(filter: [NodeFilter!], order: OrderByInput): NodeStateResultList!
|
||||||
nodeStates(filter: [NodeFilter!]): [NodeStates!]!
|
nodeStates(filter: [NodeFilter!]): [NodeStates!]!
|
||||||
nodeStatesTimed(filter: [NodeFilter!]): [NodeStatesTimed!]!
|
nodeStatesTimed(filter: [NodeFilter!], type: String!): [NodeStatesTimed!]!
|
||||||
|
|
||||||
job(id: ID!): Job
|
job(id: ID!): Job
|
||||||
jobMetrics(
|
jobMetrics(
|
||||||
@@ -2596,6 +2602,7 @@ type Query {
|
|||||||
nodeMetricsList(
|
nodeMetricsList(
|
||||||
cluster: String!
|
cluster: String!
|
||||||
subCluster: String!
|
subCluster: String!
|
||||||
|
stateFilter: String!
|
||||||
nodeFilter: String!
|
nodeFilter: String!
|
||||||
scopes: [MetricScope!]
|
scopes: [MetricScope!]
|
||||||
metrics: [String!]
|
metrics: [String!]
|
||||||
@@ -3040,41 +3047,46 @@ func (ec *executionContext) field_Query_nodeMetricsList_args(ctx context.Context
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
args["subCluster"] = arg1
|
args["subCluster"] = arg1
|
||||||
arg2, err := graphql.ProcessArgField(ctx, rawArgs, "nodeFilter", ec.unmarshalNString2string)
|
arg2, err := graphql.ProcessArgField(ctx, rawArgs, "stateFilter", ec.unmarshalNString2string)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
args["nodeFilter"] = arg2
|
args["stateFilter"] = arg2
|
||||||
arg3, err := graphql.ProcessArgField(ctx, rawArgs, "scopes", ec.unmarshalOMetricScope2ᚕgithubᚗcomᚋClusterCockpitᚋccᚑlibᚋschemaᚐMetricScopeᚄ)
|
arg3, err := graphql.ProcessArgField(ctx, rawArgs, "nodeFilter", ec.unmarshalNString2string)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
args["scopes"] = arg3
|
args["nodeFilter"] = arg3
|
||||||
arg4, err := graphql.ProcessArgField(ctx, rawArgs, "metrics", ec.unmarshalOString2ᚕstringᚄ)
|
arg4, err := graphql.ProcessArgField(ctx, rawArgs, "scopes", ec.unmarshalOMetricScope2ᚕgithubᚗcomᚋClusterCockpitᚋccᚑlibᚋschemaᚐMetricScopeᚄ)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
args["metrics"] = arg4
|
args["scopes"] = arg4
|
||||||
arg5, err := graphql.ProcessArgField(ctx, rawArgs, "from", ec.unmarshalNTime2timeᚐTime)
|
arg5, err := graphql.ProcessArgField(ctx, rawArgs, "metrics", ec.unmarshalOString2ᚕstringᚄ)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
args["from"] = arg5
|
args["metrics"] = arg5
|
||||||
arg6, err := graphql.ProcessArgField(ctx, rawArgs, "to", ec.unmarshalNTime2timeᚐTime)
|
arg6, err := graphql.ProcessArgField(ctx, rawArgs, "from", ec.unmarshalNTime2timeᚐTime)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
args["to"] = arg6
|
args["from"] = arg6
|
||||||
arg7, err := graphql.ProcessArgField(ctx, rawArgs, "page", ec.unmarshalOPageRequest2ᚖgithubᚗcomᚋClusterCockpitᚋccᚑbackendᚋinternalᚋgraphᚋmodelᚐPageRequest)
|
arg7, err := graphql.ProcessArgField(ctx, rawArgs, "to", ec.unmarshalNTime2timeᚐTime)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
args["page"] = arg7
|
args["to"] = arg7
|
||||||
arg8, err := graphql.ProcessArgField(ctx, rawArgs, "resolution", ec.unmarshalOInt2ᚖint)
|
arg8, err := graphql.ProcessArgField(ctx, rawArgs, "page", ec.unmarshalOPageRequest2ᚖgithubᚗcomᚋClusterCockpitᚋccᚑbackendᚋinternalᚋgraphᚋmodelᚐPageRequest)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
args["resolution"] = arg8
|
args["page"] = arg8
|
||||||
|
arg9, err := graphql.ProcessArgField(ctx, rawArgs, "resolution", ec.unmarshalOInt2ᚖint)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
args["resolution"] = arg9
|
||||||
return args, nil
|
return args, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3122,6 +3134,11 @@ func (ec *executionContext) field_Query_nodeStatesTimed_args(ctx context.Context
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
args["filter"] = arg0
|
args["filter"] = arg0
|
||||||
|
arg1, err := graphql.ProcessArgField(ctx, rawArgs, "type", ec.unmarshalNString2string)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
args["type"] = arg1
|
||||||
return args, nil
|
return args, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -7976,6 +7993,50 @@ func (ec *executionContext) fieldContext_NodeMetrics_host(_ context.Context, fie
|
|||||||
return fc, nil
|
return fc, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (ec *executionContext) _NodeMetrics_state(ctx context.Context, field graphql.CollectedField, obj *model.NodeMetrics) (ret graphql.Marshaler) {
|
||||||
|
fc, err := ec.fieldContext_NodeMetrics_state(ctx, field)
|
||||||
|
if err != nil {
|
||||||
|
return graphql.Null
|
||||||
|
}
|
||||||
|
ctx = graphql.WithFieldContext(ctx, fc)
|
||||||
|
defer func() {
|
||||||
|
if r := recover(); r != nil {
|
||||||
|
ec.Error(ctx, ec.Recover(ctx, r))
|
||||||
|
ret = graphql.Null
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (any, error) {
|
||||||
|
ctx = rctx // use context from middleware stack in children
|
||||||
|
return obj.State, nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
ec.Error(ctx, err)
|
||||||
|
return graphql.Null
|
||||||
|
}
|
||||||
|
if resTmp == nil {
|
||||||
|
if !graphql.HasFieldError(ctx, fc) {
|
||||||
|
ec.Errorf(ctx, "must not be null")
|
||||||
|
}
|
||||||
|
return graphql.Null
|
||||||
|
}
|
||||||
|
res := resTmp.(string)
|
||||||
|
fc.Result = res
|
||||||
|
return ec.marshalNString2string(ctx, field.Selections, res)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ec *executionContext) fieldContext_NodeMetrics_state(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) {
|
||||||
|
fc = &graphql.FieldContext{
|
||||||
|
Object: "NodeMetrics",
|
||||||
|
Field: field,
|
||||||
|
IsMethod: false,
|
||||||
|
IsResolver: false,
|
||||||
|
Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) {
|
||||||
|
return nil, errors.New("field of type String does not have child fields")
|
||||||
|
},
|
||||||
|
}
|
||||||
|
return fc, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (ec *executionContext) _NodeMetrics_subCluster(ctx context.Context, field graphql.CollectedField, obj *model.NodeMetrics) (ret graphql.Marshaler) {
|
func (ec *executionContext) _NodeMetrics_subCluster(ctx context.Context, field graphql.CollectedField, obj *model.NodeMetrics) (ret graphql.Marshaler) {
|
||||||
return graphql.ResolveField(
|
return graphql.ResolveField(
|
||||||
ctx,
|
ctx,
|
||||||
@@ -8211,52 +8272,38 @@ func (ec *executionContext) fieldContext_NodeStatesTimed_state(_ context.Context
|
|||||||
return fc, nil
|
return fc, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (ec *executionContext) _NodeStatesTimed_type(ctx context.Context, field graphql.CollectedField, obj *model.NodeStatesTimed) (ret graphql.Marshaler) {
|
func (ec *executionContext) _NodeStatesTimed_counts(ctx context.Context, field graphql.CollectedField, obj *model.NodeStatesTimed) (ret graphql.Marshaler) {
|
||||||
return graphql.ResolveField(
|
fc, err := ec.fieldContext_NodeStatesTimed_counts(ctx, field)
|
||||||
ctx,
|
if err != nil {
|
||||||
ec.OperationContext,
|
return graphql.Null
|
||||||
field,
|
|
||||||
ec.fieldContext_NodeStatesTimed_type,
|
|
||||||
func(ctx context.Context) (any, error) {
|
|
||||||
return obj.Type, nil
|
|
||||||
},
|
|
||||||
nil,
|
|
||||||
ec.marshalNString2string,
|
|
||||||
true,
|
|
||||||
true,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (ec *executionContext) fieldContext_NodeStatesTimed_type(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) {
|
|
||||||
fc = &graphql.FieldContext{
|
|
||||||
Object: "NodeStatesTimed",
|
|
||||||
Field: field,
|
|
||||||
IsMethod: false,
|
|
||||||
IsResolver: false,
|
|
||||||
Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) {
|
|
||||||
return nil, errors.New("field of type String does not have child fields")
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
return fc, nil
|
ctx = graphql.WithFieldContext(ctx, fc)
|
||||||
|
defer func() {
|
||||||
|
if r := recover(); r != nil {
|
||||||
|
ec.Error(ctx, ec.Recover(ctx, r))
|
||||||
|
ret = graphql.Null
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (any, error) {
|
||||||
|
ctx = rctx // use context from middleware stack in children
|
||||||
|
return obj.Counts, nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
ec.Error(ctx, err)
|
||||||
|
return graphql.Null
|
||||||
|
}
|
||||||
|
if resTmp == nil {
|
||||||
|
if !graphql.HasFieldError(ctx, fc) {
|
||||||
|
ec.Errorf(ctx, "must not be null")
|
||||||
|
}
|
||||||
|
return graphql.Null
|
||||||
|
}
|
||||||
|
res := resTmp.([]int)
|
||||||
|
fc.Result = res
|
||||||
|
return ec.marshalNInt2ᚕintᚄ(ctx, field.Selections, res)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (ec *executionContext) _NodeStatesTimed_count(ctx context.Context, field graphql.CollectedField, obj *model.NodeStatesTimed) (ret graphql.Marshaler) {
|
func (ec *executionContext) fieldContext_NodeStatesTimed_counts(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) {
|
||||||
return graphql.ResolveField(
|
|
||||||
ctx,
|
|
||||||
ec.OperationContext,
|
|
||||||
field,
|
|
||||||
ec.fieldContext_NodeStatesTimed_count,
|
|
||||||
func(ctx context.Context) (any, error) {
|
|
||||||
return obj.Count, nil
|
|
||||||
},
|
|
||||||
nil,
|
|
||||||
ec.marshalNInt2int,
|
|
||||||
true,
|
|
||||||
true,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (ec *executionContext) fieldContext_NodeStatesTimed_count(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) {
|
|
||||||
fc = &graphql.FieldContext{
|
fc = &graphql.FieldContext{
|
||||||
Object: "NodeStatesTimed",
|
Object: "NodeStatesTimed",
|
||||||
Field: field,
|
Field: field,
|
||||||
@@ -8269,23 +8316,38 @@ func (ec *executionContext) fieldContext_NodeStatesTimed_count(_ context.Context
|
|||||||
return fc, nil
|
return fc, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (ec *executionContext) _NodeStatesTimed_time(ctx context.Context, field graphql.CollectedField, obj *model.NodeStatesTimed) (ret graphql.Marshaler) {
|
func (ec *executionContext) _NodeStatesTimed_times(ctx context.Context, field graphql.CollectedField, obj *model.NodeStatesTimed) (ret graphql.Marshaler) {
|
||||||
return graphql.ResolveField(
|
fc, err := ec.fieldContext_NodeStatesTimed_times(ctx, field)
|
||||||
ctx,
|
if err != nil {
|
||||||
ec.OperationContext,
|
return graphql.Null
|
||||||
field,
|
}
|
||||||
ec.fieldContext_NodeStatesTimed_time,
|
ctx = graphql.WithFieldContext(ctx, fc)
|
||||||
func(ctx context.Context) (any, error) {
|
defer func() {
|
||||||
return obj.Time, nil
|
if r := recover(); r != nil {
|
||||||
},
|
ec.Error(ctx, ec.Recover(ctx, r))
|
||||||
nil,
|
ret = graphql.Null
|
||||||
ec.marshalNInt2int,
|
}
|
||||||
true,
|
}()
|
||||||
true,
|
resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (any, error) {
|
||||||
)
|
ctx = rctx // use context from middleware stack in children
|
||||||
|
return obj.Times, nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
ec.Error(ctx, err)
|
||||||
|
return graphql.Null
|
||||||
|
}
|
||||||
|
if resTmp == nil {
|
||||||
|
if !graphql.HasFieldError(ctx, fc) {
|
||||||
|
ec.Errorf(ctx, "must not be null")
|
||||||
|
}
|
||||||
|
return graphql.Null
|
||||||
|
}
|
||||||
|
res := resTmp.([]int)
|
||||||
|
fc.Result = res
|
||||||
|
return ec.marshalNInt2ᚕintᚄ(ctx, field.Selections, res)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (ec *executionContext) fieldContext_NodeStatesTimed_time(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) {
|
func (ec *executionContext) fieldContext_NodeStatesTimed_times(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) {
|
||||||
fc = &graphql.FieldContext{
|
fc = &graphql.FieldContext{
|
||||||
Object: "NodeStatesTimed",
|
Object: "NodeStatesTimed",
|
||||||
Field: field,
|
Field: field,
|
||||||
@@ -8324,6 +8386,8 @@ func (ec *executionContext) fieldContext_NodesResultList_items(_ context.Context
|
|||||||
switch field.Name {
|
switch field.Name {
|
||||||
case "host":
|
case "host":
|
||||||
return ec.fieldContext_NodeMetrics_host(ctx, field)
|
return ec.fieldContext_NodeMetrics_host(ctx, field)
|
||||||
|
case "state":
|
||||||
|
return ec.fieldContext_NodeMetrics_state(ctx, field)
|
||||||
case "subCluster":
|
case "subCluster":
|
||||||
return ec.fieldContext_NodeMetrics_subCluster(ctx, field)
|
return ec.fieldContext_NodeMetrics_subCluster(ctx, field)
|
||||||
case "metrics":
|
case "metrics":
|
||||||
@@ -8853,20 +8917,34 @@ func (ec *executionContext) fieldContext_Query_nodeStates(ctx context.Context, f
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (ec *executionContext) _Query_nodeStatesTimed(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) {
|
func (ec *executionContext) _Query_nodeStatesTimed(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) {
|
||||||
return graphql.ResolveField(
|
fc, err := ec.fieldContext_Query_nodeStatesTimed(ctx, field)
|
||||||
ctx,
|
if err != nil {
|
||||||
ec.OperationContext,
|
return graphql.Null
|
||||||
field,
|
}
|
||||||
ec.fieldContext_Query_nodeStatesTimed,
|
ctx = graphql.WithFieldContext(ctx, fc)
|
||||||
func(ctx context.Context) (any, error) {
|
defer func() {
|
||||||
fc := graphql.GetFieldContext(ctx)
|
if r := recover(); r != nil {
|
||||||
return ec.resolvers.Query().NodeStatesTimed(ctx, fc.Args["filter"].([]*model.NodeFilter))
|
ec.Error(ctx, ec.Recover(ctx, r))
|
||||||
},
|
ret = graphql.Null
|
||||||
nil,
|
}
|
||||||
ec.marshalNNodeStatesTimed2ᚕᚖgithubᚗcomᚋClusterCockpitᚋccᚑbackendᚋinternalᚋgraphᚋmodelᚐNodeStatesTimedᚄ,
|
}()
|
||||||
true,
|
resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (any, error) {
|
||||||
true,
|
ctx = rctx // use context from middleware stack in children
|
||||||
)
|
return ec.resolvers.Query().NodeStatesTimed(rctx, fc.Args["filter"].([]*model.NodeFilter), fc.Args["type"].(string))
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
ec.Error(ctx, err)
|
||||||
|
return graphql.Null
|
||||||
|
}
|
||||||
|
if resTmp == nil {
|
||||||
|
if !graphql.HasFieldError(ctx, fc) {
|
||||||
|
ec.Errorf(ctx, "must not be null")
|
||||||
|
}
|
||||||
|
return graphql.Null
|
||||||
|
}
|
||||||
|
res := resTmp.([]*model.NodeStatesTimed)
|
||||||
|
fc.Result = res
|
||||||
|
return ec.marshalNNodeStatesTimed2ᚕᚖgithubᚗcomᚋClusterCockpitᚋccᚑbackendᚋinternalᚋgraphᚋmodelᚐNodeStatesTimedᚄ(ctx, field.Selections, res)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (ec *executionContext) fieldContext_Query_nodeStatesTimed(ctx context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) {
|
func (ec *executionContext) fieldContext_Query_nodeStatesTimed(ctx context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) {
|
||||||
@@ -8879,12 +8957,10 @@ func (ec *executionContext) fieldContext_Query_nodeStatesTimed(ctx context.Conte
|
|||||||
switch field.Name {
|
switch field.Name {
|
||||||
case "state":
|
case "state":
|
||||||
return ec.fieldContext_NodeStatesTimed_state(ctx, field)
|
return ec.fieldContext_NodeStatesTimed_state(ctx, field)
|
||||||
case "type":
|
case "counts":
|
||||||
return ec.fieldContext_NodeStatesTimed_type(ctx, field)
|
return ec.fieldContext_NodeStatesTimed_counts(ctx, field)
|
||||||
case "count":
|
case "times":
|
||||||
return ec.fieldContext_NodeStatesTimed_count(ctx, field)
|
return ec.fieldContext_NodeStatesTimed_times(ctx, field)
|
||||||
case "time":
|
|
||||||
return ec.fieldContext_NodeStatesTimed_time(ctx, field)
|
|
||||||
}
|
}
|
||||||
return nil, fmt.Errorf("no field named %q was found under type NodeStatesTimed", field.Name)
|
return nil, fmt.Errorf("no field named %q was found under type NodeStatesTimed", field.Name)
|
||||||
},
|
},
|
||||||
@@ -9453,6 +9529,8 @@ func (ec *executionContext) fieldContext_Query_nodeMetrics(ctx context.Context,
|
|||||||
switch field.Name {
|
switch field.Name {
|
||||||
case "host":
|
case "host":
|
||||||
return ec.fieldContext_NodeMetrics_host(ctx, field)
|
return ec.fieldContext_NodeMetrics_host(ctx, field)
|
||||||
|
case "state":
|
||||||
|
return ec.fieldContext_NodeMetrics_state(ctx, field)
|
||||||
case "subCluster":
|
case "subCluster":
|
||||||
return ec.fieldContext_NodeMetrics_subCluster(ctx, field)
|
return ec.fieldContext_NodeMetrics_subCluster(ctx, field)
|
||||||
case "metrics":
|
case "metrics":
|
||||||
@@ -9476,20 +9554,34 @@ func (ec *executionContext) fieldContext_Query_nodeMetrics(ctx context.Context,
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (ec *executionContext) _Query_nodeMetricsList(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) {
|
func (ec *executionContext) _Query_nodeMetricsList(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) {
|
||||||
return graphql.ResolveField(
|
fc, err := ec.fieldContext_Query_nodeMetricsList(ctx, field)
|
||||||
ctx,
|
if err != nil {
|
||||||
ec.OperationContext,
|
return graphql.Null
|
||||||
field,
|
}
|
||||||
ec.fieldContext_Query_nodeMetricsList,
|
ctx = graphql.WithFieldContext(ctx, fc)
|
||||||
func(ctx context.Context) (any, error) {
|
defer func() {
|
||||||
fc := graphql.GetFieldContext(ctx)
|
if r := recover(); r != nil {
|
||||||
return ec.resolvers.Query().NodeMetricsList(ctx, fc.Args["cluster"].(string), fc.Args["subCluster"].(string), fc.Args["nodeFilter"].(string), fc.Args["scopes"].([]schema.MetricScope), fc.Args["metrics"].([]string), fc.Args["from"].(time.Time), fc.Args["to"].(time.Time), fc.Args["page"].(*model.PageRequest), fc.Args["resolution"].(*int))
|
ec.Error(ctx, ec.Recover(ctx, r))
|
||||||
},
|
ret = graphql.Null
|
||||||
nil,
|
}
|
||||||
ec.marshalNNodesResultList2ᚖgithubᚗcomᚋClusterCockpitᚋccᚑbackendᚋinternalᚋgraphᚋmodelᚐNodesResultList,
|
}()
|
||||||
true,
|
resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (any, error) {
|
||||||
true,
|
ctx = rctx // use context from middleware stack in children
|
||||||
)
|
return ec.resolvers.Query().NodeMetricsList(rctx, fc.Args["cluster"].(string), fc.Args["subCluster"].(string), fc.Args["stateFilter"].(string), fc.Args["nodeFilter"].(string), fc.Args["scopes"].([]schema.MetricScope), fc.Args["metrics"].([]string), fc.Args["from"].(time.Time), fc.Args["to"].(time.Time), fc.Args["page"].(*model.PageRequest), fc.Args["resolution"].(*int))
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
ec.Error(ctx, err)
|
||||||
|
return graphql.Null
|
||||||
|
}
|
||||||
|
if resTmp == nil {
|
||||||
|
if !graphql.HasFieldError(ctx, fc) {
|
||||||
|
ec.Errorf(ctx, "must not be null")
|
||||||
|
}
|
||||||
|
return graphql.Null
|
||||||
|
}
|
||||||
|
res := resTmp.(*model.NodesResultList)
|
||||||
|
fc.Result = res
|
||||||
|
return ec.marshalNNodesResultList2ᚖgithubᚗcomᚋClusterCockpitᚋccᚑbackendᚋinternalᚋgraphᚋmodelᚐNodesResultList(ctx, field.Selections, res)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (ec *executionContext) fieldContext_Query_nodeMetricsList(ctx context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) {
|
func (ec *executionContext) fieldContext_Query_nodeMetricsList(ctx context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) {
|
||||||
@@ -15315,6 +15407,11 @@ func (ec *executionContext) _NodeMetrics(ctx context.Context, sel ast.SelectionS
|
|||||||
if out.Values[i] == graphql.Null {
|
if out.Values[i] == graphql.Null {
|
||||||
out.Invalids++
|
out.Invalids++
|
||||||
}
|
}
|
||||||
|
case "state":
|
||||||
|
out.Values[i] = ec._NodeMetrics_state(ctx, field, obj)
|
||||||
|
if out.Values[i] == graphql.Null {
|
||||||
|
out.Invalids++
|
||||||
|
}
|
||||||
case "subCluster":
|
case "subCluster":
|
||||||
out.Values[i] = ec._NodeMetrics_subCluster(ctx, field, obj)
|
out.Values[i] = ec._NodeMetrics_subCluster(ctx, field, obj)
|
||||||
if out.Values[i] == graphql.Null {
|
if out.Values[i] == graphql.Null {
|
||||||
@@ -15449,18 +15546,13 @@ func (ec *executionContext) _NodeStatesTimed(ctx context.Context, sel ast.Select
|
|||||||
if out.Values[i] == graphql.Null {
|
if out.Values[i] == graphql.Null {
|
||||||
out.Invalids++
|
out.Invalids++
|
||||||
}
|
}
|
||||||
case "type":
|
case "counts":
|
||||||
out.Values[i] = ec._NodeStatesTimed_type(ctx, field, obj)
|
out.Values[i] = ec._NodeStatesTimed_counts(ctx, field, obj)
|
||||||
if out.Values[i] == graphql.Null {
|
if out.Values[i] == graphql.Null {
|
||||||
out.Invalids++
|
out.Invalids++
|
||||||
}
|
}
|
||||||
case "count":
|
case "times":
|
||||||
out.Values[i] = ec._NodeStatesTimed_count(ctx, field, obj)
|
out.Values[i] = ec._NodeStatesTimed_times(ctx, field, obj)
|
||||||
if out.Values[i] == graphql.Null {
|
|
||||||
out.Invalids++
|
|
||||||
}
|
|
||||||
case "time":
|
|
||||||
out.Values[i] = ec._NodeStatesTimed_time(ctx, field, obj)
|
|
||||||
if out.Values[i] == graphql.Null {
|
if out.Values[i] == graphql.Null {
|
||||||
out.Invalids++
|
out.Invalids++
|
||||||
}
|
}
|
||||||
@@ -19234,7 +19326,7 @@ func (ec *executionContext) unmarshalOAggregate2ᚖgithubᚗcomᚋClusterCockpit
|
|||||||
if v == nil {
|
if v == nil {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
var res = new(model.Aggregate)
|
res := new(model.Aggregate)
|
||||||
err := res.UnmarshalGQL(v)
|
err := res.UnmarshalGQL(v)
|
||||||
return res, graphql.ErrorOnPath(ctx, err)
|
return res, graphql.ErrorOnPath(ctx, err)
|
||||||
}
|
}
|
||||||
@@ -19900,7 +19992,7 @@ func (ec *executionContext) unmarshalOSortByAggregate2ᚖgithubᚗcomᚋClusterC
|
|||||||
if v == nil {
|
if v == nil {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
var res = new(model.SortByAggregate)
|
res := new(model.SortByAggregate)
|
||||||
err := res.UnmarshalGQL(v)
|
err := res.UnmarshalGQL(v)
|
||||||
return res, graphql.ErrorOnPath(ctx, err)
|
return res, graphql.ErrorOnPath(ctx, err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -181,6 +181,7 @@ type NodeFilter struct {
|
|||||||
|
|
||||||
type NodeMetrics struct {
|
type NodeMetrics struct {
|
||||||
Host string `json:"host"`
|
Host string `json:"host"`
|
||||||
|
State string `json:"state"`
|
||||||
SubCluster string `json:"subCluster"`
|
SubCluster string `json:"subCluster"`
|
||||||
Metrics []*JobMetricWithName `json:"metrics"`
|
Metrics []*JobMetricWithName `json:"metrics"`
|
||||||
}
|
}
|
||||||
@@ -196,10 +197,9 @@ type NodeStates struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type NodeStatesTimed struct {
|
type NodeStatesTimed struct {
|
||||||
State string `json:"state"`
|
State string `json:"state"`
|
||||||
Type string `json:"type"`
|
Counts []int `json:"counts"`
|
||||||
Count int `json:"count"`
|
Times []int `json:"times"`
|
||||||
Time int `json:"time"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type NodesResultList struct {
|
type NodesResultList struct {
|
||||||
|
|||||||
@@ -312,7 +312,11 @@ func (r *nodeResolver) ID(ctx context.Context, obj *schema.Node) (string, error)
|
|||||||
|
|
||||||
// SchedulerState is the resolver for the schedulerState field.
|
// SchedulerState is the resolver for the schedulerState field.
|
||||||
func (r *nodeResolver) SchedulerState(ctx context.Context, obj *schema.Node) (schema.SchedulerState, error) {
|
func (r *nodeResolver) SchedulerState(ctx context.Context, obj *schema.Node) (schema.SchedulerState, error) {
|
||||||
panic(fmt.Errorf("not implemented: SchedulerState - schedulerState"))
|
if obj.NodeState != "" {
|
||||||
|
return obj.NodeState, nil
|
||||||
|
} else {
|
||||||
|
return "", fmt.Errorf("No SchedulerState (NodeState) on Object")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// HealthState is the resolver for the healthState field.
|
// HealthState is the resolver for the healthState field.
|
||||||
@@ -378,7 +382,7 @@ func (r *queryResolver) Node(ctx context.Context, id string) (*schema.Node, erro
|
|||||||
// Nodes is the resolver for the nodes field.
|
// Nodes is the resolver for the nodes field.
|
||||||
func (r *queryResolver) Nodes(ctx context.Context, filter []*model.NodeFilter, order *model.OrderByInput) (*model.NodeStateResultList, error) {
|
func (r *queryResolver) Nodes(ctx context.Context, filter []*model.NodeFilter, order *model.OrderByInput) (*model.NodeStateResultList, error) {
|
||||||
repo := repository.GetNodeRepository()
|
repo := repository.GetNodeRepository()
|
||||||
nodes, err := repo.QueryNodes(ctx, filter, order)
|
nodes, err := repo.QueryNodes(ctx, filter, nil, order) // Ignore Paging, Order Unused
|
||||||
count := len(nodes)
|
count := len(nodes)
|
||||||
return &model.NodeStateResultList{Items: nodes, Count: &count}, err
|
return &model.NodeStateResultList{Items: nodes, Count: &count}, err
|
||||||
}
|
}
|
||||||
@@ -387,13 +391,13 @@ func (r *queryResolver) Nodes(ctx context.Context, filter []*model.NodeFilter, o
|
|||||||
func (r *queryResolver) NodeStates(ctx context.Context, filter []*model.NodeFilter) ([]*model.NodeStates, error) {
|
func (r *queryResolver) NodeStates(ctx context.Context, filter []*model.NodeFilter) ([]*model.NodeStates, error) {
|
||||||
repo := repository.GetNodeRepository()
|
repo := repository.GetNodeRepository()
|
||||||
|
|
||||||
stateCounts, serr := repo.CountNodeStates(ctx, filter)
|
stateCounts, serr := repo.CountStates(ctx, filter, "node_state")
|
||||||
if serr != nil {
|
if serr != nil {
|
||||||
cclog.Warnf("Error while counting nodeStates: %s", serr.Error())
|
cclog.Warnf("Error while counting nodeStates: %s", serr.Error())
|
||||||
return nil, serr
|
return nil, serr
|
||||||
}
|
}
|
||||||
|
|
||||||
healthCounts, herr := repo.CountHealthStates(ctx, filter)
|
healthCounts, herr := repo.CountStates(ctx, filter, "health_state")
|
||||||
if herr != nil {
|
if herr != nil {
|
||||||
cclog.Warnf("Error while counting healthStates: %s", herr.Error())
|
cclog.Warnf("Error while counting healthStates: %s", herr.Error())
|
||||||
return nil, herr
|
return nil, herr
|
||||||
@@ -406,26 +410,28 @@ func (r *queryResolver) NodeStates(ctx context.Context, filter []*model.NodeFilt
|
|||||||
}
|
}
|
||||||
|
|
||||||
// NodeStatesTimed is the resolver for the nodeStatesTimed field.
|
// NodeStatesTimed is the resolver for the nodeStatesTimed field.
|
||||||
func (r *queryResolver) NodeStatesTimed(ctx context.Context, filter []*model.NodeFilter) ([]*model.NodeStatesTimed, error) {
|
func (r *queryResolver) NodeStatesTimed(ctx context.Context, filter []*model.NodeFilter, typeArg string) ([]*model.NodeStatesTimed, error) {
|
||||||
panic(fmt.Errorf("not implemented: NodeStatesTimed - NodeStatesTimed"))
|
repo := repository.GetNodeRepository()
|
||||||
// repo := repository.GetNodeRepository()
|
|
||||||
|
|
||||||
// stateCounts, serr := repo.CountNodeStates(ctx, filter)
|
if typeArg == "node" {
|
||||||
// if serr != nil {
|
stateCounts, serr := repo.CountStatesTimed(ctx, filter, "node_state")
|
||||||
// cclog.Warnf("Error while counting nodeStates: %s", serr.Error())
|
if serr != nil {
|
||||||
// return nil, serr
|
cclog.Warnf("Error while counting nodeStates in time: %s", serr.Error())
|
||||||
// }
|
return nil, serr
|
||||||
|
}
|
||||||
|
return stateCounts, nil
|
||||||
|
}
|
||||||
|
|
||||||
// healthCounts, herr := repo.CountHealthStates(ctx, filter)
|
if typeArg == "health" {
|
||||||
// if herr != nil {
|
healthCounts, herr := repo.CountStatesTimed(ctx, filter, "health_state")
|
||||||
// cclog.Warnf("Error while counting healthStates: %s", herr.Error())
|
if herr != nil {
|
||||||
// return nil, herr
|
cclog.Warnf("Error while counting healthStates in time: %s", herr.Error())
|
||||||
// }
|
return nil, herr
|
||||||
|
}
|
||||||
|
return healthCounts, nil
|
||||||
|
}
|
||||||
|
|
||||||
// allCounts := make([]*model.NodeStates, 0)
|
return nil, errors.New("Unknown Node State Query Type")
|
||||||
// allCounts = append(stateCounts, healthCounts...)
|
|
||||||
|
|
||||||
// return allCounts, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Job is the resolver for the job field.
|
// Job is the resolver for the job field.
|
||||||
@@ -750,10 +756,14 @@ func (r *queryResolver) NodeMetrics(ctx context.Context, cluster string, nodes [
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
nodeRepo := repository.GetNodeRepository()
|
||||||
|
stateMap, _ := nodeRepo.MapNodes(cluster)
|
||||||
|
|
||||||
nodeMetrics := make([]*model.NodeMetrics, 0, len(data))
|
nodeMetrics := make([]*model.NodeMetrics, 0, len(data))
|
||||||
for hostname, metrics := range data {
|
for hostname, metrics := range data {
|
||||||
host := &model.NodeMetrics{
|
host := &model.NodeMetrics{
|
||||||
Host: hostname,
|
Host: hostname,
|
||||||
|
State: stateMap[hostname],
|
||||||
Metrics: make([]*model.JobMetricWithName, 0, len(metrics)*len(scopes)),
|
Metrics: make([]*model.JobMetricWithName, 0, len(metrics)*len(scopes)),
|
||||||
}
|
}
|
||||||
host.SubCluster, err = archive.GetSubClusterByNode(cluster, hostname)
|
host.SubCluster, err = archive.GetSubClusterByNode(cluster, hostname)
|
||||||
@@ -778,7 +788,7 @@ func (r *queryResolver) NodeMetrics(ctx context.Context, cluster string, nodes [
|
|||||||
}
|
}
|
||||||
|
|
||||||
// NodeMetricsList is the resolver for the nodeMetricsList field.
|
// NodeMetricsList is the resolver for the nodeMetricsList field.
|
||||||
func (r *queryResolver) NodeMetricsList(ctx context.Context, cluster string, subCluster string, nodeFilter string, scopes []schema.MetricScope, metrics []string, from time.Time, to time.Time, page *model.PageRequest, resolution *int) (*model.NodesResultList, error) {
|
func (r *queryResolver) NodeMetricsList(ctx context.Context, cluster string, subCluster string, stateFilter string, nodeFilter string, scopes []schema.MetricScope, metrics []string, from time.Time, to time.Time, page *model.PageRequest, resolution *int) (*model.NodesResultList, error) {
|
||||||
if resolution == nil { // Load from Config
|
if resolution == nil { // Load from Config
|
||||||
if config.Keys.EnableResampling != nil {
|
if config.Keys.EnableResampling != nil {
|
||||||
defaultRes := slices.Max(config.Keys.EnableResampling.Resolutions)
|
defaultRes := slices.Max(config.Keys.EnableResampling.Resolutions)
|
||||||
@@ -800,16 +810,139 @@ func (r *queryResolver) NodeMetricsList(ctx context.Context, cluster string, sub
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
data, totalNodes, hasNextPage, err := metricDataDispatcher.LoadNodeListData(cluster, subCluster, nodeFilter, metrics, scopes, *resolution, from, to, page, ctx)
|
// Build Filters
|
||||||
|
queryFilters := make([]*model.NodeFilter, 0)
|
||||||
|
if cluster != "" {
|
||||||
|
queryFilters = append(queryFilters, &model.NodeFilter{Cluster: &model.StringInput{Eq: &cluster}})
|
||||||
|
}
|
||||||
|
if subCluster != "" {
|
||||||
|
queryFilters = append(queryFilters, &model.NodeFilter{Subcluster: &model.StringInput{Eq: &subCluster}})
|
||||||
|
}
|
||||||
|
if nodeFilter != "" && stateFilter != "notindb" {
|
||||||
|
queryFilters = append(queryFilters, &model.NodeFilter{Hostname: &model.StringInput{Contains: &nodeFilter}})
|
||||||
|
}
|
||||||
|
if stateFilter != "all" && stateFilter != "notindb" {
|
||||||
|
var queryState schema.SchedulerState = schema.SchedulerState(stateFilter)
|
||||||
|
queryFilters = append(queryFilters, &model.NodeFilter{SchedulerState: &queryState})
|
||||||
|
}
|
||||||
|
// if healthFilter != "all" {
|
||||||
|
// filters = append(filters, &model.NodeFilter{HealthState: &healthFilter})
|
||||||
|
// }
|
||||||
|
|
||||||
|
// Special Case: Disable Paging for missing nodes filter, save IPP for later
|
||||||
|
var backupItems int
|
||||||
|
if stateFilter == "notindb" {
|
||||||
|
backupItems = page.ItemsPerPage
|
||||||
|
page.ItemsPerPage = -1
|
||||||
|
}
|
||||||
|
|
||||||
|
// Query Nodes From DB
|
||||||
|
nodeRepo := repository.GetNodeRepository()
|
||||||
|
rawNodes, serr := nodeRepo.QueryNodes(ctx, queryFilters, page, nil) // Order not Used
|
||||||
|
if serr != nil {
|
||||||
|
cclog.Warn("error while loading node database data (Resolver.NodeMetricsList)")
|
||||||
|
return nil, serr
|
||||||
|
}
|
||||||
|
|
||||||
|
// Intermediate Node Result Info
|
||||||
|
nodes := make([]string, 0)
|
||||||
|
stateMap := make(map[string]string)
|
||||||
|
for _, node := range rawNodes {
|
||||||
|
nodes = append(nodes, node.Hostname)
|
||||||
|
stateMap[node.Hostname] = string(node.NodeState)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Setup Vars
|
||||||
|
var countNodes int
|
||||||
|
var cerr error
|
||||||
|
var hasNextPage bool
|
||||||
|
|
||||||
|
// Special Case: Find Nodes not in DB node table but in metricStore only
|
||||||
|
if stateFilter == "notindb" {
|
||||||
|
// Reapply Original Paging
|
||||||
|
page.ItemsPerPage = backupItems
|
||||||
|
// Get Nodes From Topology
|
||||||
|
var topoNodes []string
|
||||||
|
if subCluster != "" {
|
||||||
|
scNodes := archive.NodeLists[cluster][subCluster]
|
||||||
|
topoNodes = scNodes.PrintList()
|
||||||
|
} else {
|
||||||
|
subClusterNodeLists := archive.NodeLists[cluster]
|
||||||
|
for _, nodeList := range subClusterNodeLists {
|
||||||
|
topoNodes = append(topoNodes, nodeList.PrintList()...)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Compare to all nodes from cluster/subcluster in DB
|
||||||
|
var missingNodes []string
|
||||||
|
for _, scanNode := range topoNodes {
|
||||||
|
if !slices.Contains(nodes, scanNode) {
|
||||||
|
missingNodes = append(missingNodes, scanNode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Filter nodes by name
|
||||||
|
if nodeFilter != "" {
|
||||||
|
filteredNodesByName := []string{}
|
||||||
|
for _, missingNode := range missingNodes {
|
||||||
|
if strings.Contains(missingNode, nodeFilter) {
|
||||||
|
filteredNodesByName = append(filteredNodesByName, missingNode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
missingNodes = filteredNodesByName
|
||||||
|
}
|
||||||
|
// Sort Missing Nodes Alphanumerically
|
||||||
|
slices.Sort(missingNodes)
|
||||||
|
// Total Missing
|
||||||
|
countNodes = len(missingNodes)
|
||||||
|
// Apply paging
|
||||||
|
if countNodes > page.ItemsPerPage {
|
||||||
|
start := (page.Page - 1) * page.ItemsPerPage
|
||||||
|
end := start + page.ItemsPerPage
|
||||||
|
if end > countNodes {
|
||||||
|
end = countNodes
|
||||||
|
hasNextPage = false
|
||||||
|
} else {
|
||||||
|
hasNextPage = true
|
||||||
|
}
|
||||||
|
nodes = missingNodes[start:end]
|
||||||
|
} else {
|
||||||
|
nodes = missingNodes
|
||||||
|
}
|
||||||
|
|
||||||
|
} else {
|
||||||
|
// DB Nodes: Count and Find Next Page
|
||||||
|
countNodes, cerr = nodeRepo.CountNodes(ctx, queryFilters)
|
||||||
|
if cerr != nil {
|
||||||
|
cclog.Warn("error while counting node database data (Resolver.NodeMetricsList)")
|
||||||
|
return nil, cerr
|
||||||
|
}
|
||||||
|
|
||||||
|
// Example Page 4 @ 10 IpP : Does item 41 exist?
|
||||||
|
// Minimal Page 41 @ 1 IpP : If len(result) is 1, Page 5 exists.
|
||||||
|
nextPage := &model.PageRequest{
|
||||||
|
ItemsPerPage: 1,
|
||||||
|
Page: ((page.Page * page.ItemsPerPage) + 1),
|
||||||
|
}
|
||||||
|
nextNodes, err := nodeRepo.QueryNodes(ctx, queryFilters, nextPage, nil) // Order not Used
|
||||||
|
if err != nil {
|
||||||
|
cclog.Warn("Error while querying next nodes")
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
hasNextPage = len(nextNodes) == 1
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load Metric Data For Specified Nodes Only
|
||||||
|
data, err := metricDataDispatcher.LoadNodeListData(cluster, subCluster, nodes, metrics, scopes, *resolution, from, to, ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
cclog.Warn("error while loading node data")
|
cclog.Warn("error while loading node data (Resolver.NodeMetricsList")
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Build Result
|
||||||
nodeMetricsList := make([]*model.NodeMetrics, 0, len(data))
|
nodeMetricsList := make([]*model.NodeMetrics, 0, len(data))
|
||||||
for hostname, metrics := range data {
|
for hostname, metrics := range data {
|
||||||
host := &model.NodeMetrics{
|
host := &model.NodeMetrics{
|
||||||
Host: hostname,
|
Host: hostname,
|
||||||
|
State: stateMap[hostname],
|
||||||
Metrics: make([]*model.JobMetricWithName, 0, len(metrics)*len(scopes)),
|
Metrics: make([]*model.JobMetricWithName, 0, len(metrics)*len(scopes)),
|
||||||
}
|
}
|
||||||
host.SubCluster, err = archive.GetSubClusterByNode(cluster, hostname)
|
host.SubCluster, err = archive.GetSubClusterByNode(cluster, hostname)
|
||||||
@@ -830,9 +963,10 @@ func (r *queryResolver) NodeMetricsList(ctx context.Context, cluster string, sub
|
|||||||
nodeMetricsList = append(nodeMetricsList, host)
|
nodeMetricsList = append(nodeMetricsList, host)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Final Return
|
||||||
nodeMetricsListResult := &model.NodesResultList{
|
nodeMetricsListResult := &model.NodesResultList{
|
||||||
Items: nodeMetricsList,
|
Items: nodeMetricsList,
|
||||||
TotalNodes: &totalNodes,
|
TotalNodes: &countNodes,
|
||||||
HasNextPage: &hasNextPage,
|
HasNextPage: &hasNextPage,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -81,14 +81,14 @@ func setup(t *testing.T) *repository.JobRepository {
|
|||||||
tmpdir := t.TempDir()
|
tmpdir := t.TempDir()
|
||||||
|
|
||||||
jobarchive := filepath.Join(tmpdir, "job-archive")
|
jobarchive := filepath.Join(tmpdir, "job-archive")
|
||||||
if err := os.Mkdir(jobarchive, 0777); err != nil {
|
if err := os.Mkdir(jobarchive, 0o777); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if err := os.WriteFile(filepath.Join(jobarchive, "version.txt"), []byte(fmt.Sprintf("%d", 2)), 0666); err != nil {
|
if err := os.WriteFile(filepath.Join(jobarchive, "version.txt"), fmt.Appendf(nil, "%d", 3), 0o666); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
fritzArchive := filepath.Join(tmpdir, "job-archive", "fritz")
|
fritzArchive := filepath.Join(tmpdir, "job-archive", "fritz")
|
||||||
if err := os.Mkdir(fritzArchive, 0777); err != nil {
|
if err := os.Mkdir(fritzArchive, 0o777); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if err := copyFile(filepath.Join("testdata", "cluster-fritz.json"),
|
if err := copyFile(filepath.Join("testdata", "cluster-fritz.json"),
|
||||||
@@ -103,7 +103,7 @@ func setup(t *testing.T) *repository.JobRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
cfgFilePath := filepath.Join(tmpdir, "config.json")
|
cfgFilePath := filepath.Join(tmpdir, "config.json")
|
||||||
if err := os.WriteFile(cfgFilePath, []byte(testconfig), 0666); err != nil {
|
if err := os.WriteFile(cfgFilePath, []byte(testconfig), 0o666); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -55,7 +55,18 @@ func InitDB() error {
|
|||||||
|
|
||||||
// Bundle 100 inserts into one transaction for better performance
|
// Bundle 100 inserts into one transaction for better performance
|
||||||
if i%100 == 0 {
|
if i%100 == 0 {
|
||||||
r.TransactionCommit(t)
|
if i > 0 {
|
||||||
|
if err := t.Commit(); err != nil {
|
||||||
|
cclog.Errorf("transaction commit error: %v", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// Start a new transaction for the next batch
|
||||||
|
t, err = r.TransactionInit()
|
||||||
|
if err != nil {
|
||||||
|
cclog.Errorf("transaction init error: %v", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
fmt.Printf("%d jobs inserted...\r", i)
|
fmt.Printf("%d jobs inserted...\r", i)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -75,10 +75,10 @@ func ArchiveCheckpoints(checkpointsDir, archiveDir string, from int64, deleteIns
|
|||||||
|
|
||||||
var wg sync.WaitGroup
|
var wg sync.WaitGroup
|
||||||
n, errs := int32(0), int32(0)
|
n, errs := int32(0), int32(0)
|
||||||
work := make(chan workItem, NumWorkers)
|
work := make(chan workItem, Keys.NumWorkers)
|
||||||
|
|
||||||
wg.Add(NumWorkers)
|
wg.Add(Keys.NumWorkers)
|
||||||
for worker := 0; worker < NumWorkers; worker++ {
|
for worker := 0; worker < Keys.NumWorkers; worker++ {
|
||||||
go func() {
|
go func() {
|
||||||
defer wg.Done()
|
defer wg.Done()
|
||||||
for workItem := range work {
|
for workItem := range work {
|
||||||
@@ -116,7 +116,7 @@ func ArchiveCheckpoints(checkpointsDir, archiveDir string, from int64, deleteIns
|
|||||||
}
|
}
|
||||||
|
|
||||||
if errs > 0 {
|
if errs > 0 {
|
||||||
return int(n), fmt.Errorf("%d errors happend while archiving (%d successes)", errs, n)
|
return int(n), fmt.Errorf("%d errors happened while archiving (%d successes)", errs, n)
|
||||||
}
|
}
|
||||||
return int(n), nil
|
return int(n), nil
|
||||||
}
|
}
|
||||||
@@ -147,11 +147,11 @@ func archiveCheckpoints(dir string, archiveDir string, from int64, deleteInstead
|
|||||||
}
|
}
|
||||||
|
|
||||||
filename := filepath.Join(archiveDir, fmt.Sprintf("%d.zip", from))
|
filename := filepath.Join(archiveDir, fmt.Sprintf("%d.zip", from))
|
||||||
f, err := os.OpenFile(filename, os.O_CREATE|os.O_WRONLY, 0o644)
|
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, 0o755)
|
err = os.MkdirAll(archiveDir, CheckpointDirPerms)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
f, err = os.OpenFile(filename, os.O_CREATE|os.O_WRONLY, 0o644)
|
f, err = os.OpenFile(filename, os.O_CREATE|os.O_WRONLY, CheckpointFilePerms)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -105,46 +105,6 @@ func (b *buffer) firstWrite() int64 {
|
|||||||
|
|
||||||
func (b *buffer) close() {}
|
func (b *buffer) close() {}
|
||||||
|
|
||||||
/*
|
|
||||||
func (b *buffer) close() {
|
|
||||||
if b.closed {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
b.closed = true
|
|
||||||
n, sum, min, max := 0, 0., math.MaxFloat64, -math.MaxFloat64
|
|
||||||
for _, x := range b.data {
|
|
||||||
if x.IsNaN() {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
n += 1
|
|
||||||
f := float64(x)
|
|
||||||
sum += f
|
|
||||||
min = math.Min(min, f)
|
|
||||||
max = math.Max(max, f)
|
|
||||||
}
|
|
||||||
|
|
||||||
b.statisticts.samples = n
|
|
||||||
if n > 0 {
|
|
||||||
b.statisticts.avg = Float(sum / float64(n))
|
|
||||||
b.statisticts.min = Float(min)
|
|
||||||
b.statisticts.max = Float(max)
|
|
||||||
} else {
|
|
||||||
b.statisticts.avg = NaN
|
|
||||||
b.statisticts.min = NaN
|
|
||||||
b.statisticts.max = NaN
|
|
||||||
}
|
|
||||||
}
|
|
||||||
*/
|
|
||||||
|
|
||||||
// func interpolate(idx int, data []Float) Float {
|
|
||||||
// if idx == 0 || idx+1 == len(data) {
|
|
||||||
// return NaN
|
|
||||||
// }
|
|
||||||
// return (data[idx-1] + data[idx+1]) / 2.0
|
|
||||||
// }
|
|
||||||
|
|
||||||
// Return all known values from `from` to `to`. Gaps of information are represented as NaN.
|
// 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.
|
// 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
|
// If values at the start or end are missing, instead of NaN values, the second and thrid
|
||||||
|
|||||||
@@ -28,6 +28,17 @@ import (
|
|||||||
"github.com/linkedin/goavro/v2"
|
"github.com/linkedin/goavro/v2"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// File operation constants
|
||||||
|
const (
|
||||||
|
// CheckpointFilePerms defines default permissions for checkpoint files
|
||||||
|
CheckpointFilePerms = 0o644
|
||||||
|
// CheckpointDirPerms defines default permissions for checkpoint directories
|
||||||
|
CheckpointDirPerms = 0o755
|
||||||
|
// GCTriggerInterval determines how often GC is forced during checkpoint loading
|
||||||
|
// GC is triggered every GCTriggerInterval*NumWorkers loaded hosts
|
||||||
|
GCTriggerInterval = 100
|
||||||
|
)
|
||||||
|
|
||||||
// Whenever changed, update MarshalJSON as well!
|
// Whenever changed, update MarshalJSON as well!
|
||||||
type CheckpointMetrics struct {
|
type CheckpointMetrics struct {
|
||||||
Data []schema.Float `json:"data"`
|
Data []schema.Float `json:"data"`
|
||||||
@@ -171,9 +182,9 @@ func (m *MemoryStore) ToCheckpoint(dir string, from, to int64) (int, error) {
|
|||||||
n, errs := int32(0), int32(0)
|
n, errs := int32(0), int32(0)
|
||||||
|
|
||||||
var wg sync.WaitGroup
|
var wg sync.WaitGroup
|
||||||
wg.Add(NumWorkers)
|
wg.Add(Keys.NumWorkers)
|
||||||
work := make(chan workItem, NumWorkers*2)
|
work := make(chan workItem, Keys.NumWorkers*2)
|
||||||
for worker := 0; worker < NumWorkers; worker++ {
|
for worker := 0; worker < Keys.NumWorkers; worker++ {
|
||||||
go func() {
|
go func() {
|
||||||
defer wg.Done()
|
defer wg.Done()
|
||||||
|
|
||||||
@@ -205,7 +216,7 @@ func (m *MemoryStore) ToCheckpoint(dir string, from, to int64) (int, error) {
|
|||||||
wg.Wait()
|
wg.Wait()
|
||||||
|
|
||||||
if errs > 0 {
|
if errs > 0 {
|
||||||
return int(n), fmt.Errorf("[METRICSTORE]> %d errors happend while creating checkpoints (%d successes)", errs, n)
|
return int(n), fmt.Errorf("[METRICSTORE]> %d errors happened while creating checkpoints (%d successes)", errs, n)
|
||||||
}
|
}
|
||||||
return int(n), nil
|
return int(n), nil
|
||||||
}
|
}
|
||||||
@@ -285,11 +296,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, 0o644)
|
f, err := os.OpenFile(filepath, os.O_CREATE|os.O_WRONLY, CheckpointFilePerms)
|
||||||
if err != nil && os.IsNotExist(err) {
|
if err != nil && os.IsNotExist(err) {
|
||||||
err = os.MkdirAll(dir, 0o755)
|
err = os.MkdirAll(dir, CheckpointDirPerms)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
f, err = os.OpenFile(filepath, os.O_CREATE|os.O_WRONLY, 0o644)
|
f, err = os.OpenFile(filepath, os.O_CREATE|os.O_WRONLY, CheckpointFilePerms)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -307,11 +318,11 @@ func (l *Level) toCheckpoint(dir string, from, to int64, m *MemoryStore) error {
|
|||||||
|
|
||||||
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, NumWorkers)
|
work := make(chan [2]string, Keys.NumWorkers)
|
||||||
n, errs := int32(0), int32(0)
|
n, errs := int32(0), int32(0)
|
||||||
|
|
||||||
wg.Add(NumWorkers)
|
wg.Add(Keys.NumWorkers)
|
||||||
for worker := 0; worker < NumWorkers; worker++ {
|
for worker := 0; worker < Keys.NumWorkers; worker++ {
|
||||||
go func() {
|
go func() {
|
||||||
defer wg.Done()
|
defer wg.Done()
|
||||||
for host := range work {
|
for host := range work {
|
||||||
@@ -347,7 +358,7 @@ func (m *MemoryStore) FromCheckpoint(dir string, from int64, extension string) (
|
|||||||
}
|
}
|
||||||
|
|
||||||
i++
|
i++
|
||||||
if i%NumWorkers == 0 && i > 100 {
|
if i%Keys.NumWorkers == 0 && i > GCTriggerInterval {
|
||||||
// Forcing garbage collection runs here regulary during the loading of checkpoints
|
// 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.
|
// 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
|
// While loading data, the heap will grow fast, so the GC target size will double
|
||||||
@@ -368,7 +379,7 @@ done:
|
|||||||
}
|
}
|
||||||
|
|
||||||
if errs > 0 {
|
if errs > 0 {
|
||||||
return int(n), fmt.Errorf("[METRICSTORE]> %d errors happend while creating checkpoints (%d successes)", errs, n)
|
return int(n), fmt.Errorf("[METRICSTORE]> %d errors happened while creating checkpoints (%d successes)", errs, n)
|
||||||
}
|
}
|
||||||
return int(n), nil
|
return int(n), nil
|
||||||
}
|
}
|
||||||
@@ -379,7 +390,7 @@ done:
|
|||||||
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()
|
||||||
err := os.MkdirAll(dir, 0o755) // 0755 sets the permissions for the directory
|
err := os.MkdirAll(dir, CheckpointDirPerms) // CheckpointDirPerms sets the permissions for the directory
|
||||||
if err != nil {
|
if err != nil {
|
||||||
cclog.Fatalf("[METRICSTORE]> Error creating directory: %#v\n", err)
|
cclog.Fatalf("[METRICSTORE]> Error creating directory: %#v\n", err)
|
||||||
}
|
}
|
||||||
@@ -464,7 +475,7 @@ func (l *Level) loadAvroFile(m *MemoryStore, f *os.File, from int64) error {
|
|||||||
// Create a new OCF reader from the buffered reader
|
// Create a new OCF reader from the buffered reader
|
||||||
ocfReader, err := goavro.NewOCFReader(br)
|
ocfReader, err := goavro.NewOCFReader(br)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
panic(err)
|
return fmt.Errorf("[METRICSTORE]> error creating OCF reader: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
metricsData := make(map[string]schema.FloatArray)
|
metricsData := make(map[string]schema.FloatArray)
|
||||||
@@ -477,7 +488,7 @@ func (l *Level) loadAvroFile(m *MemoryStore, f *os.File, from int64) error {
|
|||||||
|
|
||||||
record, ok := datum.(map[string]any)
|
record, ok := datum.(map[string]any)
|
||||||
if !ok {
|
if !ok {
|
||||||
panic("[METRICSTORE]> failed to assert datum as map[string]interface{}")
|
return fmt.Errorf("[METRICSTORE]> failed to assert datum as map[string]interface{}")
|
||||||
}
|
}
|
||||||
|
|
||||||
for key, value := range record {
|
for key, value := range record {
|
||||||
@@ -559,7 +570,7 @@ func (l *Level) createBuffer(m *MemoryStore, metricName string, floatArray schem
|
|||||||
l.metrics[minfo.offset] = b
|
l.metrics[minfo.offset] = b
|
||||||
} else {
|
} else {
|
||||||
if prev.start > b.start {
|
if prev.start > b.start {
|
||||||
return errors.New("wooops")
|
return fmt.Errorf("[METRICSTORE]> buffer start time %d is before previous buffer start %d", b.start, prev.start)
|
||||||
}
|
}
|
||||||
|
|
||||||
b.prev = prev
|
b.prev = prev
|
||||||
@@ -623,7 +634,7 @@ func (l *Level) loadFile(cf *CheckpointFile, m *MemoryStore) error {
|
|||||||
l.metrics[minfo.offset] = b
|
l.metrics[minfo.offset] = b
|
||||||
} else {
|
} else {
|
||||||
if prev.start > b.start {
|
if prev.start > b.start {
|
||||||
return errors.New("wooops")
|
return fmt.Errorf("[METRICSTORE]> buffer start time %d is before previous buffer start %d", b.start, prev.start)
|
||||||
}
|
}
|
||||||
|
|
||||||
b.prev = prev
|
b.prev = prev
|
||||||
@@ -700,13 +711,17 @@ func (l *Level) fromCheckpoint(m *MemoryStore, dir string, from int64, extension
|
|||||||
loader := loaders[extension]
|
loader := loaders[extension]
|
||||||
|
|
||||||
for _, filename := range files {
|
for _, filename := range files {
|
||||||
f, err := os.Open(path.Join(dir, filename))
|
// Use a closure to ensure file is closed immediately after use
|
||||||
if err != nil {
|
err := func() error {
|
||||||
return filesLoaded, err
|
f, err := os.Open(path.Join(dir, filename))
|
||||||
}
|
if err != nil {
|
||||||
defer f.Close()
|
return err
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
|
||||||
if err = loader(m, f, from); err != nil {
|
return loader(m, f, from)
|
||||||
|
}()
|
||||||
|
if err != nil {
|
||||||
return filesLoaded, err
|
return filesLoaded, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,9 @@ import (
|
|||||||
var InternalCCMSFlag bool = false
|
var InternalCCMSFlag bool = false
|
||||||
|
|
||||||
type MetricStoreConfig struct {
|
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"`
|
||||||
Checkpoints struct {
|
Checkpoints struct {
|
||||||
FileFormat string `json:"file-format"`
|
FileFormat string `json:"file-format"`
|
||||||
Interval string `json:"interval"`
|
Interval string `json:"interval"`
|
||||||
@@ -62,7 +65,7 @@ const (
|
|||||||
AvgAggregation
|
AvgAggregation
|
||||||
)
|
)
|
||||||
|
|
||||||
func AssignAggregationStratergy(str string) (AggregationStrategy, error) {
|
func AssignAggregationStrategy(str string) (AggregationStrategy, error) {
|
||||||
switch str {
|
switch str {
|
||||||
case "":
|
case "":
|
||||||
return NoAggregation, nil
|
return NoAggregation, nil
|
||||||
|
|||||||
@@ -51,35 +51,41 @@ const configSchema = `{
|
|||||||
},
|
},
|
||||||
"nats": {
|
"nats": {
|
||||||
"description": "Configuration for accepting published data through NATS.",
|
"description": "Configuration for accepting published data through NATS.",
|
||||||
"type": "object",
|
"type": "array",
|
||||||
"properties": {
|
"items": {
|
||||||
"address": {
|
"type": "object",
|
||||||
"description": "Address of the NATS server.",
|
"properties": {
|
||||||
"type": "string"
|
"address": {
|
||||||
},
|
"description": "Address of the NATS server.",
|
||||||
"username": {
|
"type": "string"
|
||||||
"description": "Optional: If configured with username/password method.",
|
},
|
||||||
"type": "string"
|
"username": {
|
||||||
},
|
"description": "Optional: If configured with username/password method.",
|
||||||
"password": {
|
"type": "string"
|
||||||
"description": "Optional: If configured with username/password method.",
|
},
|
||||||
"type": "string"
|
"password": {
|
||||||
},
|
"description": "Optional: If configured with username/password method.",
|
||||||
"creds-file-path": {
|
"type": "string"
|
||||||
"description": "Optional: If configured with Credential File method. Path to your NATS cred file.",
|
},
|
||||||
"type": "string"
|
"creds-file-path": {
|
||||||
},
|
"description": "Optional: If configured with Credential File method. Path to your NATS cred file.",
|
||||||
"subscriptions": {
|
"type": "string"
|
||||||
"description": "Array of various subscriptions. Allows to subscibe to different subjects and publishers.",
|
},
|
||||||
"type": "object",
|
"subscriptions": {
|
||||||
"properties": {
|
"description": "Array of various subscriptions. Allows to subscibe to different subjects and publishers.",
|
||||||
"subscribe-to": {
|
"type": "array",
|
||||||
"description": "Channel name",
|
"items": {
|
||||||
"type": "string"
|
"type": "object",
|
||||||
},
|
"properties": {
|
||||||
"cluster-tag": {
|
"subscribe-to": {
|
||||||
"description": "Optional: Allow lines without a cluster tag, use this as default",
|
"description": "Channel name",
|
||||||
"type": "string"
|
"type": "string"
|
||||||
|
},
|
||||||
|
"cluster-tag": {
|
||||||
|
"description": "Optional: Allow lines without a cluster tag, use this as default",
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ func (l *Level) findLevelOrCreate(selector []string, nMetrics int) *Level {
|
|||||||
// Children map needs to be created...
|
// Children map needs to be created...
|
||||||
l.lock.RUnlock()
|
l.lock.RUnlock()
|
||||||
} else {
|
} else {
|
||||||
child, ok := l.children[selector[0]]
|
child, ok = l.children[selector[0]]
|
||||||
l.lock.RUnlock()
|
l.lock.RUnlock()
|
||||||
if ok {
|
if ok {
|
||||||
return child.findLevelOrCreate(selector[1:], nMetrics)
|
return child.findLevelOrCreate(selector[1:], nMetrics)
|
||||||
|
|||||||
@@ -3,6 +3,20 @@
|
|||||||
// 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.
|
||||||
|
|
||||||
|
// Package memorystore provides an efficient in-memory time-series metric storage system
|
||||||
|
// with support for hierarchical data organization, checkpointing, and archiving.
|
||||||
|
//
|
||||||
|
// The package organizes metrics in a tree structure (cluster → host → component) and
|
||||||
|
// provides concurrent read/write access to metric data with configurable aggregation strategies.
|
||||||
|
// Background goroutines handle periodic checkpointing (JSON or Avro format), archiving old data,
|
||||||
|
// and enforcing retention policies.
|
||||||
|
//
|
||||||
|
// Key features:
|
||||||
|
// - In-memory metric storage with configurable retention
|
||||||
|
// - Hierarchical data organization (selectors)
|
||||||
|
// - Concurrent checkpoint/archive workers
|
||||||
|
// - Support for sum and average aggregation
|
||||||
|
// - NATS integration for metric ingestion
|
||||||
package memorystore
|
package memorystore
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -10,18 +24,14 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"os"
|
|
||||||
"os/signal"
|
|
||||||
"runtime"
|
"runtime"
|
||||||
"sync"
|
"sync"
|
||||||
"syscall"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/ClusterCockpit/cc-backend/internal/config"
|
"github.com/ClusterCockpit/cc-backend/internal/config"
|
||||||
"github.com/ClusterCockpit/cc-backend/pkg/archive"
|
"github.com/ClusterCockpit/cc-backend/pkg/archive"
|
||||||
cclog "github.com/ClusterCockpit/cc-lib/ccLogger"
|
cclog "github.com/ClusterCockpit/cc-lib/ccLogger"
|
||||||
"github.com/ClusterCockpit/cc-lib/resampler"
|
"github.com/ClusterCockpit/cc-lib/resampler"
|
||||||
"github.com/ClusterCockpit/cc-lib/runtimeEnv"
|
|
||||||
"github.com/ClusterCockpit/cc-lib/schema"
|
"github.com/ClusterCockpit/cc-lib/schema"
|
||||||
"github.com/ClusterCockpit/cc-lib/util"
|
"github.com/ClusterCockpit/cc-lib/util"
|
||||||
)
|
)
|
||||||
@@ -29,14 +39,12 @@ import (
|
|||||||
var (
|
var (
|
||||||
singleton sync.Once
|
singleton sync.Once
|
||||||
msInstance *MemoryStore
|
msInstance *MemoryStore
|
||||||
|
// shutdownFunc stores the context cancellation function created in Init
|
||||||
|
// and is called during Shutdown to cancel all background goroutines
|
||||||
|
shutdownFunc context.CancelFunc
|
||||||
)
|
)
|
||||||
|
|
||||||
var NumWorkers int = 4
|
|
||||||
|
|
||||||
func init() {
|
|
||||||
maxWorkers := 10
|
|
||||||
NumWorkers = min(runtime.NumCPU()/2+1, maxWorkers)
|
|
||||||
}
|
|
||||||
|
|
||||||
type Metric struct {
|
type Metric struct {
|
||||||
Name string
|
Name string
|
||||||
@@ -61,30 +69,34 @@ func Init(rawConfig json.RawMessage, wg *sync.WaitGroup) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Set NumWorkers from config or use default
|
||||||
|
if Keys.NumWorkers <= 0 {
|
||||||
|
maxWorkers := 10
|
||||||
|
Keys.NumWorkers = min(runtime.NumCPU()/2+1, maxWorkers)
|
||||||
|
}
|
||||||
|
cclog.Debugf("[METRICSTORE]> Using %d workers for checkpoint/archive operations\n", Keys.NumWorkers)
|
||||||
|
|
||||||
|
// Helper function to add metric configuration
|
||||||
|
addMetricConfig := func(mc schema.MetricConfig) {
|
||||||
|
agg, err := AssignAggregationStrategy(mc.Aggregation)
|
||||||
|
if err != nil {
|
||||||
|
cclog.Warnf("Could not find aggregation strategy for metric config '%s': %s", mc.Name, err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
AddMetric(mc.Name, MetricConfig{
|
||||||
|
Frequency: int64(mc.Timestep),
|
||||||
|
Aggregation: agg,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
for _, c := range archive.Clusters {
|
for _, c := range archive.Clusters {
|
||||||
for _, mc := range c.MetricConfig {
|
for _, mc := range c.MetricConfig {
|
||||||
agg, err := AssignAggregationStratergy(mc.Aggregation)
|
addMetricConfig(*mc)
|
||||||
if err != nil {
|
|
||||||
cclog.Warnf("Could not find aggregation stratergy for metric config '%s': %s", mc.Name, err.Error())
|
|
||||||
}
|
|
||||||
|
|
||||||
AddMetric(mc.Name, MetricConfig{
|
|
||||||
Frequency: int64(mc.Timestep),
|
|
||||||
Aggregation: agg,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, sc := range c.SubClusters {
|
for _, sc := range c.SubClusters {
|
||||||
for _, mc := range sc.MetricConfig {
|
for _, mc := range sc.MetricConfig {
|
||||||
agg, err := AssignAggregationStratergy(mc.Aggregation)
|
addMetricConfig(mc)
|
||||||
if err != nil {
|
|
||||||
cclog.Warnf("Could not find aggregation stratergy for metric config '%s': %s", mc.Name, err.Error())
|
|
||||||
}
|
|
||||||
|
|
||||||
AddMetric(mc.Name, MetricConfig{
|
|
||||||
Frequency: int64(mc.Timestep),
|
|
||||||
Aggregation: agg,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -126,15 +138,11 @@ func Init(rawConfig json.RawMessage, wg *sync.WaitGroup) {
|
|||||||
Archiving(wg, ctx)
|
Archiving(wg, ctx)
|
||||||
DataStaging(wg, ctx)
|
DataStaging(wg, ctx)
|
||||||
|
|
||||||
wg.Add(1)
|
// Note: Signal handling has been removed from this function.
|
||||||
sigs := make(chan os.Signal, 1)
|
// The caller is responsible for handling shutdown signals and calling
|
||||||
signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM)
|
// the shutdown() function when appropriate.
|
||||||
go func() {
|
// Store the shutdown function for later use by Shutdown()
|
||||||
defer wg.Done()
|
shutdownFunc = shutdown
|
||||||
<-sigs
|
|
||||||
runtimeEnv.SystemdNotifiy(false, "[METRICSTORE]> Shutting down ...")
|
|
||||||
shutdown()
|
|
||||||
}()
|
|
||||||
|
|
||||||
if Keys.Nats != nil {
|
if Keys.Nats != nil {
|
||||||
for _, natsConf := range Keys.Nats {
|
for _, natsConf := range Keys.Nats {
|
||||||
@@ -190,6 +198,11 @@ func GetMemoryStore() *MemoryStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func Shutdown() {
|
func Shutdown() {
|
||||||
|
// Cancel the context to signal all background goroutines to stop
|
||||||
|
if shutdownFunc != nil {
|
||||||
|
shutdownFunc()
|
||||||
|
}
|
||||||
|
|
||||||
cclog.Infof("[METRICSTORE]> Writing to '%s'...\n", Keys.Checkpoints.RootDir)
|
cclog.Infof("[METRICSTORE]> Writing to '%s'...\n", Keys.Checkpoints.RootDir)
|
||||||
var files int
|
var files int
|
||||||
var err error
|
var err error
|
||||||
@@ -207,70 +220,8 @@ func Shutdown() {
|
|||||||
cclog.Errorf("[METRICSTORE]> Writing checkpoint failed: %s\n", err.Error())
|
cclog.Errorf("[METRICSTORE]> Writing checkpoint failed: %s\n", err.Error())
|
||||||
}
|
}
|
||||||
cclog.Infof("[METRICSTORE]> Done! (%d files written)\n", files)
|
cclog.Infof("[METRICSTORE]> Done! (%d files written)\n", files)
|
||||||
|
|
||||||
// ms.PrintHeirarchy()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// func (m *MemoryStore) PrintHeirarchy() {
|
|
||||||
// m.root.lock.Lock()
|
|
||||||
// defer m.root.lock.Unlock()
|
|
||||||
|
|
||||||
// fmt.Printf("Root : \n")
|
|
||||||
|
|
||||||
// for lvl1, sel1 := range m.root.children {
|
|
||||||
// fmt.Printf("\t%s\n", lvl1)
|
|
||||||
// for lvl2, sel2 := range sel1.children {
|
|
||||||
// fmt.Printf("\t\t%s\n", lvl2)
|
|
||||||
// if lvl1 == "fritz" && lvl2 == "f0201" {
|
|
||||||
|
|
||||||
// for name, met := range m.Metrics {
|
|
||||||
// mt := sel2.metrics[met.Offset]
|
|
||||||
|
|
||||||
// fmt.Printf("\t\t\t\t%s\n", name)
|
|
||||||
// fmt.Printf("\t\t\t\t")
|
|
||||||
|
|
||||||
// for mt != nil {
|
|
||||||
// // if name == "cpu_load" {
|
|
||||||
// fmt.Printf("%d(%d) -> %#v", mt.start, len(mt.data), mt.data)
|
|
||||||
// // }
|
|
||||||
// mt = mt.prev
|
|
||||||
// }
|
|
||||||
// fmt.Printf("\n")
|
|
||||||
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
// for lvl3, sel3 := range sel2.children {
|
|
||||||
// if lvl1 == "fritz" && lvl2 == "f0201" && lvl3 == "hwthread70" {
|
|
||||||
|
|
||||||
// fmt.Printf("\t\t\t\t\t%s\n", lvl3)
|
|
||||||
|
|
||||||
// for name, met := range m.Metrics {
|
|
||||||
// mt := sel3.metrics[met.Offset]
|
|
||||||
|
|
||||||
// fmt.Printf("\t\t\t\t\t\t%s\n", name)
|
|
||||||
|
|
||||||
// fmt.Printf("\t\t\t\t\t\t")
|
|
||||||
|
|
||||||
// for mt != nil {
|
|
||||||
// // if name == "clock" {
|
|
||||||
// fmt.Printf("%d(%d) -> %#v", mt.start, len(mt.data), mt.data)
|
|
||||||
|
|
||||||
// mt = mt.prev
|
|
||||||
// }
|
|
||||||
// fmt.Printf("\n")
|
|
||||||
|
|
||||||
// }
|
|
||||||
|
|
||||||
// // for i, _ := range sel3.metrics {
|
|
||||||
// // fmt.Printf("\t\t\t\t\t%s\n", getName(configmetrics, i))
|
|
||||||
// // }
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
|
|
||||||
// }
|
|
||||||
|
|
||||||
func getName(m *MemoryStore, i int) string {
|
func getName(m *MemoryStore, i int) string {
|
||||||
for key, val := range m.Metrics {
|
for key, val := range m.Metrics {
|
||||||
if val.offset == i {
|
if val.offset == i {
|
||||||
|
|||||||
@@ -0,0 +1,156 @@
|
|||||||
|
// 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 memorystore
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/ClusterCockpit/cc-lib/schema"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestAssignAggregationStrategy(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
input string
|
||||||
|
expected AggregationStrategy
|
||||||
|
wantErr bool
|
||||||
|
}{
|
||||||
|
{"empty string", "", NoAggregation, false},
|
||||||
|
{"sum", "sum", SumAggregation, false},
|
||||||
|
{"avg", "avg", AvgAggregation, false},
|
||||||
|
{"invalid", "invalid", NoAggregation, true},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
result, err := AssignAggregationStrategy(tt.input)
|
||||||
|
if (err != nil) != tt.wantErr {
|
||||||
|
t.Errorf("AssignAggregationStrategy(%q) error = %v, wantErr %v", tt.input, err, tt.wantErr)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if result != tt.expected {
|
||||||
|
t.Errorf("AssignAggregationStrategy(%q) = %v, want %v", tt.input, result, tt.expected)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAddMetric(t *testing.T) {
|
||||||
|
// Reset Metrics before test
|
||||||
|
Metrics = make(map[string]MetricConfig)
|
||||||
|
|
||||||
|
err := AddMetric("test_metric", MetricConfig{
|
||||||
|
Frequency: 60,
|
||||||
|
Aggregation: SumAggregation,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("AddMetric() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, ok := Metrics["test_metric"]; !ok {
|
||||||
|
t.Error("AddMetric() did not add metric to Metrics map")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test updating with higher frequency
|
||||||
|
err = AddMetric("test_metric", MetricConfig{
|
||||||
|
Frequency: 120,
|
||||||
|
Aggregation: SumAggregation,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("AddMetric() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if Metrics["test_metric"].Frequency != 120 {
|
||||||
|
t.Errorf("AddMetric() frequency = %d, want 120", Metrics["test_metric"].Frequency)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test updating with lower frequency (should not update)
|
||||||
|
err = AddMetric("test_metric", MetricConfig{
|
||||||
|
Frequency: 30,
|
||||||
|
Aggregation: SumAggregation,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("AddMetric() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if Metrics["test_metric"].Frequency != 120 {
|
||||||
|
t.Errorf("AddMetric() frequency = %d, want 120 (should not downgrade)", Metrics["test_metric"].Frequency)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetMetricFrequency(t *testing.T) {
|
||||||
|
// Reset Metrics before test
|
||||||
|
Metrics = map[string]MetricConfig{
|
||||||
|
"test_metric": {
|
||||||
|
Frequency: 60,
|
||||||
|
Aggregation: SumAggregation,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
freq, err := GetMetricFrequency("test_metric")
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("GetMetricFrequency() error = %v", err)
|
||||||
|
}
|
||||||
|
if freq != 60 {
|
||||||
|
t.Errorf("GetMetricFrequency() = %d, want 60", freq)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = GetMetricFrequency("nonexistent")
|
||||||
|
if err == nil {
|
||||||
|
t.Error("GetMetricFrequency() expected error for nonexistent metric")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBufferWrite(t *testing.T) {
|
||||||
|
b := newBuffer(100, 10)
|
||||||
|
|
||||||
|
// Test writing value
|
||||||
|
nb, err := b.write(100, schema.Float(42.0))
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("buffer.write() error = %v", err)
|
||||||
|
}
|
||||||
|
if nb != b {
|
||||||
|
t.Error("buffer.write() created new buffer unexpectedly")
|
||||||
|
}
|
||||||
|
if len(b.data) != 1 {
|
||||||
|
t.Errorf("buffer.write() len(data) = %d, want 1", len(b.data))
|
||||||
|
}
|
||||||
|
if b.data[0] != schema.Float(42.0) {
|
||||||
|
t.Errorf("buffer.write() data[0] = %v, want 42.0", b.data[0])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test writing value from past (should error)
|
||||||
|
_, err = b.write(50, schema.Float(10.0))
|
||||||
|
if err == nil {
|
||||||
|
t.Error("buffer.write() expected error for past timestamp")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBufferRead(t *testing.T) {
|
||||||
|
b := newBuffer(100, 10)
|
||||||
|
|
||||||
|
// Write some test data
|
||||||
|
b.write(100, schema.Float(1.0))
|
||||||
|
b.write(110, schema.Float(2.0))
|
||||||
|
b.write(120, schema.Float(3.0))
|
||||||
|
|
||||||
|
// Read data
|
||||||
|
data := make([]schema.Float, 3)
|
||||||
|
result, from, to, err := b.read(100, 130, data)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("buffer.read() error = %v", err)
|
||||||
|
}
|
||||||
|
// Buffer read should return from as firstWrite (start + freq/2)
|
||||||
|
if from != 100 {
|
||||||
|
t.Errorf("buffer.read() from = %d, want 100", from)
|
||||||
|
}
|
||||||
|
if to != 130 {
|
||||||
|
t.Errorf("buffer.read() to = %d, want 130", to)
|
||||||
|
}
|
||||||
|
if len(result) != 3 {
|
||||||
|
t.Errorf("buffer.read() len(result) = %d, want 3", len(result))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,7 +11,6 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/ClusterCockpit/cc-backend/internal/config"
|
"github.com/ClusterCockpit/cc-backend/internal/config"
|
||||||
"github.com/ClusterCockpit/cc-backend/internal/graph/model"
|
|
||||||
"github.com/ClusterCockpit/cc-backend/internal/metricdata"
|
"github.com/ClusterCockpit/cc-backend/internal/metricdata"
|
||||||
"github.com/ClusterCockpit/cc-backend/pkg/archive"
|
"github.com/ClusterCockpit/cc-backend/pkg/archive"
|
||||||
cclog "github.com/ClusterCockpit/cc-lib/ccLogger"
|
cclog "github.com/ClusterCockpit/cc-lib/ccLogger"
|
||||||
@@ -332,17 +331,17 @@ func LoadNodeData(
|
|||||||
}
|
}
|
||||||
|
|
||||||
func LoadNodeListData(
|
func LoadNodeListData(
|
||||||
cluster, subCluster, nodeFilter string,
|
cluster, subCluster string,
|
||||||
|
nodes []string,
|
||||||
metrics []string,
|
metrics []string,
|
||||||
scopes []schema.MetricScope,
|
scopes []schema.MetricScope,
|
||||||
resolution int,
|
resolution int,
|
||||||
from, to time.Time,
|
from, to time.Time,
|
||||||
page *model.PageRequest,
|
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
) (map[string]schema.JobData, int, bool, error) {
|
) (map[string]schema.JobData, error) {
|
||||||
repo, err := metricdata.GetMetricDataRepo(cluster)
|
repo, err := metricdata.GetMetricDataRepo(cluster)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, 0, false, fmt.Errorf("METRICDATA/METRICDATA > no metric data repository configured for '%s'", cluster)
|
return nil, fmt.Errorf("METRICDATA/METRICDATA > no metric data repository configured for '%s'", cluster)
|
||||||
}
|
}
|
||||||
|
|
||||||
if metrics == nil {
|
if metrics == nil {
|
||||||
@@ -351,13 +350,13 @@ func LoadNodeListData(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
data, totalNodes, hasNextPage, err := repo.LoadNodeListData(cluster, subCluster, nodeFilter, metrics, scopes, resolution, from, to, page, ctx)
|
data, err := repo.LoadNodeListData(cluster, subCluster, nodes, metrics, scopes, resolution, from, to, ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if len(data) != 0 {
|
if len(data) != 0 {
|
||||||
cclog.Warnf("partial error: %s", err.Error())
|
cclog.Warnf("partial error: %s", err.Error())
|
||||||
} else {
|
} else {
|
||||||
cclog.Error("Error while loading node data from metric repository")
|
cclog.Error("Error while loading node data from metric repository")
|
||||||
return nil, totalNodes, hasNextPage, err
|
return nil, err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -375,8 +374,8 @@ func LoadNodeListData(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if data == nil {
|
if data == nil {
|
||||||
return nil, totalNodes, hasNextPage, fmt.Errorf("METRICDATA/METRICDATA > the metric data repository for '%s' does not support this query", cluster)
|
return nil, fmt.Errorf("METRICDATA/METRICDATA > the metric data repository for '%s' does not support this query", cluster)
|
||||||
}
|
}
|
||||||
|
|
||||||
return data, totalNodes, hasNextPage, nil
|
return data, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,12 +9,10 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"sort"
|
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/ClusterCockpit/cc-backend/internal/graph/model"
|
|
||||||
"github.com/ClusterCockpit/cc-backend/internal/memorystore"
|
"github.com/ClusterCockpit/cc-backend/internal/memorystore"
|
||||||
"github.com/ClusterCockpit/cc-backend/pkg/archive"
|
"github.com/ClusterCockpit/cc-backend/pkg/archive"
|
||||||
cclog "github.com/ClusterCockpit/cc-lib/ccLogger"
|
cclog "github.com/ClusterCockpit/cc-lib/ccLogger"
|
||||||
@@ -677,64 +675,20 @@ func (ccms *CCMetricStoreInternal) LoadNodeData(
|
|||||||
|
|
||||||
// Used for Systems-View Node-List
|
// Used for Systems-View Node-List
|
||||||
func (ccms *CCMetricStoreInternal) LoadNodeListData(
|
func (ccms *CCMetricStoreInternal) LoadNodeListData(
|
||||||
cluster, subCluster, nodeFilter string,
|
cluster, subCluster string,
|
||||||
|
nodes []string,
|
||||||
metrics []string,
|
metrics []string,
|
||||||
scopes []schema.MetricScope,
|
scopes []schema.MetricScope,
|
||||||
resolution int,
|
resolution int,
|
||||||
from, to time.Time,
|
from, to time.Time,
|
||||||
page *model.PageRequest,
|
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
) (map[string]schema.JobData, int, bool, error) {
|
) (map[string]schema.JobData, error) {
|
||||||
// 0) Init additional vars
|
|
||||||
var totalNodes int = 0
|
|
||||||
var hasNextPage bool = false
|
|
||||||
|
|
||||||
// 1) Get list of all nodes
|
|
||||||
var nodes []string
|
|
||||||
if subCluster != "" {
|
|
||||||
scNodes := archive.NodeLists[cluster][subCluster]
|
|
||||||
nodes = scNodes.PrintList()
|
|
||||||
} else {
|
|
||||||
subClusterNodeLists := archive.NodeLists[cluster]
|
|
||||||
for _, nodeList := range subClusterNodeLists {
|
|
||||||
nodes = append(nodes, nodeList.PrintList()...)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2) Filter nodes
|
|
||||||
if nodeFilter != "" {
|
|
||||||
filteredNodes := []string{}
|
|
||||||
for _, node := range nodes {
|
|
||||||
if strings.Contains(node, nodeFilter) {
|
|
||||||
filteredNodes = append(filteredNodes, node)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
nodes = filteredNodes
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2.1) Count total nodes && Sort nodes -> Sorting invalidated after ccms return ...
|
|
||||||
totalNodes = len(nodes)
|
|
||||||
sort.Strings(nodes)
|
|
||||||
|
|
||||||
// 3) Apply paging
|
|
||||||
if len(nodes) > page.ItemsPerPage {
|
|
||||||
start := (page.Page - 1) * page.ItemsPerPage
|
|
||||||
end := start + page.ItemsPerPage
|
|
||||||
if end >= len(nodes) {
|
|
||||||
end = len(nodes)
|
|
||||||
hasNextPage = false
|
|
||||||
} else {
|
|
||||||
hasNextPage = true
|
|
||||||
}
|
|
||||||
nodes = nodes[start:end]
|
|
||||||
}
|
|
||||||
|
|
||||||
// Note: Order of node data is not guaranteed after this point, but contents match page and filter criteria
|
|
||||||
|
|
||||||
|
// Note: Order of node data is not guaranteed after this point
|
||||||
queries, assignedScope, err := ccms.buildNodeQueries(cluster, subCluster, nodes, metrics, scopes, int64(resolution))
|
queries, assignedScope, err := ccms.buildNodeQueries(cluster, subCluster, nodes, metrics, scopes, int64(resolution))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
cclog.Errorf("Error while building node queries for Cluster %s, SubCLuster %s, Metrics %v, Scopes %v: %s", cluster, subCluster, metrics, scopes, err.Error())
|
cclog.Errorf("Error while building node queries for Cluster %s, SubCLuster %s, Metrics %v, Scopes %v: %s", cluster, subCluster, metrics, scopes, err.Error())
|
||||||
return nil, totalNodes, hasNextPage, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
req := memorystore.APIQueryRequest{
|
req := memorystore.APIQueryRequest{
|
||||||
@@ -749,7 +703,7 @@ func (ccms *CCMetricStoreInternal) LoadNodeListData(
|
|||||||
resBody, err := memorystore.FetchData(req)
|
resBody, err := memorystore.FetchData(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
cclog.Errorf("Error while fetching data : %s", err.Error())
|
cclog.Errorf("Error while fetching data : %s", err.Error())
|
||||||
return nil, totalNodes, hasNextPage, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
var errors []string
|
var errors []string
|
||||||
@@ -829,10 +783,10 @@ func (ccms *CCMetricStoreInternal) LoadNodeListData(
|
|||||||
|
|
||||||
if len(errors) != 0 {
|
if len(errors) != 0 {
|
||||||
/* Returns list of "partial errors" */
|
/* Returns list of "partial errors" */
|
||||||
return data, totalNodes, hasNextPage, fmt.Errorf("METRICDATA/CCMS > Errors: %s", strings.Join(errors, ", "))
|
return data, fmt.Errorf("METRICDATA/CCMS > Errors: %s", strings.Join(errors, ", "))
|
||||||
}
|
}
|
||||||
|
|
||||||
return data, totalNodes, hasNextPage, nil
|
return data, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (ccms *CCMetricStoreInternal) buildNodeQueries(
|
func (ccms *CCMetricStoreInternal) buildNodeQueries(
|
||||||
|
|||||||
@@ -11,11 +11,9 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"sort"
|
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/ClusterCockpit/cc-backend/internal/graph/model"
|
|
||||||
"github.com/ClusterCockpit/cc-backend/pkg/archive"
|
"github.com/ClusterCockpit/cc-backend/pkg/archive"
|
||||||
cclog "github.com/ClusterCockpit/cc-lib/ccLogger"
|
cclog "github.com/ClusterCockpit/cc-lib/ccLogger"
|
||||||
"github.com/ClusterCockpit/cc-lib/schema"
|
"github.com/ClusterCockpit/cc-lib/schema"
|
||||||
@@ -799,65 +797,20 @@ func (ccms *CCMetricStore) LoadNodeData(
|
|||||||
|
|
||||||
// Used for Systems-View Node-List
|
// Used for Systems-View Node-List
|
||||||
func (ccms *CCMetricStore) LoadNodeListData(
|
func (ccms *CCMetricStore) LoadNodeListData(
|
||||||
cluster, subCluster, nodeFilter string,
|
cluster, subCluster string,
|
||||||
|
nodes []string,
|
||||||
metrics []string,
|
metrics []string,
|
||||||
scopes []schema.MetricScope,
|
scopes []schema.MetricScope,
|
||||||
resolution int,
|
resolution int,
|
||||||
from, to time.Time,
|
from, to time.Time,
|
||||||
page *model.PageRequest,
|
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
) (map[string]schema.JobData, int, bool, error) {
|
) (map[string]schema.JobData, error) {
|
||||||
|
|
||||||
// 0) Init additional vars
|
|
||||||
var totalNodes int = 0
|
|
||||||
var hasNextPage bool = false
|
|
||||||
|
|
||||||
// 1) Get list of all nodes
|
|
||||||
var nodes []string
|
|
||||||
if subCluster != "" {
|
|
||||||
scNodes := archive.NodeLists[cluster][subCluster]
|
|
||||||
nodes = scNodes.PrintList()
|
|
||||||
} else {
|
|
||||||
subClusterNodeLists := archive.NodeLists[cluster]
|
|
||||||
for _, nodeList := range subClusterNodeLists {
|
|
||||||
nodes = append(nodes, nodeList.PrintList()...)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2) Filter nodes
|
|
||||||
if nodeFilter != "" {
|
|
||||||
filteredNodes := []string{}
|
|
||||||
for _, node := range nodes {
|
|
||||||
if strings.Contains(node, nodeFilter) {
|
|
||||||
filteredNodes = append(filteredNodes, node)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
nodes = filteredNodes
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2.1) Count total nodes && Sort nodes -> Sorting invalidated after ccms return ...
|
|
||||||
totalNodes = len(nodes)
|
|
||||||
sort.Strings(nodes)
|
|
||||||
|
|
||||||
// 3) Apply paging
|
|
||||||
if len(nodes) > page.ItemsPerPage {
|
|
||||||
start := (page.Page - 1) * page.ItemsPerPage
|
|
||||||
end := start + page.ItemsPerPage
|
|
||||||
if end > len(nodes) {
|
|
||||||
end = len(nodes)
|
|
||||||
hasNextPage = false
|
|
||||||
} else {
|
|
||||||
hasNextPage = true
|
|
||||||
}
|
|
||||||
nodes = nodes[start:end]
|
|
||||||
}
|
|
||||||
|
|
||||||
// Note: Order of node data is not guaranteed after this point, but contents match page and filter criteria
|
|
||||||
|
|
||||||
|
// Note: Order of node data is not guaranteed after this point
|
||||||
queries, assignedScope, err := ccms.buildNodeQueries(cluster, subCluster, nodes, metrics, scopes, resolution)
|
queries, assignedScope, err := ccms.buildNodeQueries(cluster, subCluster, nodes, metrics, scopes, resolution)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
cclog.Errorf("Error while building node queries for Cluster %s, SubCLuster %s, Metrics %v, Scopes %v: %s", cluster, subCluster, metrics, scopes, err.Error())
|
cclog.Errorf("Error while building node queries for Cluster %s, SubCLuster %s, Metrics %v, Scopes %v: %s", cluster, subCluster, metrics, scopes, err.Error())
|
||||||
return nil, totalNodes, hasNextPage, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
req := ApiQueryRequest{
|
req := ApiQueryRequest{
|
||||||
@@ -872,7 +825,7 @@ func (ccms *CCMetricStore) LoadNodeListData(
|
|||||||
resBody, err := ccms.doRequest(ctx, &req)
|
resBody, err := ccms.doRequest(ctx, &req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
cclog.Errorf("Error while performing request: %s", err.Error())
|
cclog.Errorf("Error while performing request: %s", err.Error())
|
||||||
return nil, totalNodes, hasNextPage, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
var errors []string
|
var errors []string
|
||||||
@@ -952,10 +905,10 @@ func (ccms *CCMetricStore) LoadNodeListData(
|
|||||||
|
|
||||||
if len(errors) != 0 {
|
if len(errors) != 0 {
|
||||||
/* Returns list of "partial errors" */
|
/* Returns list of "partial errors" */
|
||||||
return data, totalNodes, hasNextPage, fmt.Errorf("METRICDATA/CCMS > Errors: %s", strings.Join(errors, ", "))
|
return data, fmt.Errorf("METRICDATA/CCMS > Errors: %s", strings.Join(errors, ", "))
|
||||||
}
|
}
|
||||||
|
|
||||||
return data, totalNodes, hasNextPage, nil
|
return data, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (ccms *CCMetricStore) buildNodeQueries(
|
func (ccms *CCMetricStore) buildNodeQueries(
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/ClusterCockpit/cc-backend/internal/config"
|
"github.com/ClusterCockpit/cc-backend/internal/config"
|
||||||
"github.com/ClusterCockpit/cc-backend/internal/graph/model"
|
|
||||||
"github.com/ClusterCockpit/cc-backend/internal/memorystore"
|
"github.com/ClusterCockpit/cc-backend/internal/memorystore"
|
||||||
cclog "github.com/ClusterCockpit/cc-lib/ccLogger"
|
cclog "github.com/ClusterCockpit/cc-lib/ccLogger"
|
||||||
"github.com/ClusterCockpit/cc-lib/schema"
|
"github.com/ClusterCockpit/cc-lib/schema"
|
||||||
@@ -36,7 +35,7 @@ type MetricDataRepository interface {
|
|||||||
LoadNodeData(cluster string, metrics, nodes []string, scopes []schema.MetricScope, from, to time.Time, ctx context.Context) (map[string]map[string][]*schema.JobMetric, error)
|
LoadNodeData(cluster string, metrics, nodes []string, scopes []schema.MetricScope, from, to time.Time, ctx context.Context) (map[string]map[string][]*schema.JobMetric, error)
|
||||||
|
|
||||||
// Return a map of hosts to a map of metrics to a map of scopes for multiple nodes.
|
// Return a map of hosts to a map of metrics to a map of scopes for multiple nodes.
|
||||||
LoadNodeListData(cluster, subCluster, nodeFilter string, metrics []string, scopes []schema.MetricScope, resolution int, from, to time.Time, page *model.PageRequest, ctx context.Context) (map[string]schema.JobData, int, bool, error)
|
LoadNodeListData(cluster, subCluster string, nodes, metrics []string, scopes []schema.MetricScope, resolution int, from, to time.Time, ctx context.Context) (map[string]schema.JobData, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
var metricDataRepos map[string]MetricDataRepository = map[string]MetricDataRepository{}
|
var metricDataRepos map[string]MetricDataRepository = map[string]MetricDataRepository{}
|
||||||
|
|||||||
@@ -20,7 +20,6 @@ import (
|
|||||||
"text/template"
|
"text/template"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/ClusterCockpit/cc-backend/internal/graph/model"
|
|
||||||
"github.com/ClusterCockpit/cc-backend/pkg/archive"
|
"github.com/ClusterCockpit/cc-backend/pkg/archive"
|
||||||
cclog "github.com/ClusterCockpit/cc-lib/ccLogger"
|
cclog "github.com/ClusterCockpit/cc-lib/ccLogger"
|
||||||
"github.com/ClusterCockpit/cc-lib/schema"
|
"github.com/ClusterCockpit/cc-lib/schema"
|
||||||
@@ -494,62 +493,17 @@ func (pdb *PrometheusDataRepository) LoadScopedStats(
|
|||||||
|
|
||||||
// Implemented by NHR@FAU; Used in NodeList-View
|
// Implemented by NHR@FAU; Used in NodeList-View
|
||||||
func (pdb *PrometheusDataRepository) LoadNodeListData(
|
func (pdb *PrometheusDataRepository) LoadNodeListData(
|
||||||
cluster, subCluster, nodeFilter string,
|
cluster, subCluster string,
|
||||||
|
nodes []string,
|
||||||
metrics []string,
|
metrics []string,
|
||||||
scopes []schema.MetricScope,
|
scopes []schema.MetricScope,
|
||||||
resolution int,
|
resolution int,
|
||||||
from, to time.Time,
|
from, to time.Time,
|
||||||
page *model.PageRequest,
|
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
) (map[string]schema.JobData, int, bool, error) {
|
) (map[string]schema.JobData, error) {
|
||||||
// Assumption: pdb.loadData() only returns series node-scope - use node scope for NodeList
|
// Assumption: pdb.loadData() only returns series node-scope - use node scope for NodeList
|
||||||
|
|
||||||
// 0) Init additional vars
|
// Fetch Data, based on pdb.LoadNodeData()
|
||||||
var totalNodes int = 0
|
|
||||||
var hasNextPage bool = false
|
|
||||||
|
|
||||||
// 1) Get list of all nodes
|
|
||||||
var nodes []string
|
|
||||||
if subCluster != "" {
|
|
||||||
scNodes := archive.NodeLists[cluster][subCluster]
|
|
||||||
nodes = scNodes.PrintList()
|
|
||||||
} else {
|
|
||||||
subClusterNodeLists := archive.NodeLists[cluster]
|
|
||||||
for _, nodeList := range subClusterNodeLists {
|
|
||||||
nodes = append(nodes, nodeList.PrintList()...)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2) Filter nodes
|
|
||||||
if nodeFilter != "" {
|
|
||||||
filteredNodes := []string{}
|
|
||||||
for _, node := range nodes {
|
|
||||||
if strings.Contains(node, nodeFilter) {
|
|
||||||
filteredNodes = append(filteredNodes, node)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
nodes = filteredNodes
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2.1) Count total nodes && Sort nodes -> Sorting invalidated after return ...
|
|
||||||
totalNodes = len(nodes)
|
|
||||||
sort.Strings(nodes)
|
|
||||||
|
|
||||||
// 3) Apply paging
|
|
||||||
if len(nodes) > page.ItemsPerPage {
|
|
||||||
start := (page.Page - 1) * page.ItemsPerPage
|
|
||||||
end := start + page.ItemsPerPage
|
|
||||||
if end >= len(nodes) {
|
|
||||||
end = len(nodes)
|
|
||||||
hasNextPage = false
|
|
||||||
} else {
|
|
||||||
hasNextPage = true
|
|
||||||
}
|
|
||||||
nodes = nodes[start:end]
|
|
||||||
}
|
|
||||||
|
|
||||||
// 4) Fetch Data, based on pdb.LoadNodeData()
|
|
||||||
|
|
||||||
t0 := time.Now()
|
t0 := time.Now()
|
||||||
// Map of hosts of jobData
|
// Map of hosts of jobData
|
||||||
data := make(map[string]schema.JobData)
|
data := make(map[string]schema.JobData)
|
||||||
@@ -572,12 +526,12 @@ func (pdb *PrometheusDataRepository) LoadNodeListData(
|
|||||||
metricConfig := archive.GetMetricConfig(cluster, metric)
|
metricConfig := archive.GetMetricConfig(cluster, metric)
|
||||||
if metricConfig == nil {
|
if metricConfig == nil {
|
||||||
cclog.Warnf("Error in LoadNodeListData: Metric %s for cluster %s not configured", metric, cluster)
|
cclog.Warnf("Error in LoadNodeListData: Metric %s for cluster %s not configured", metric, cluster)
|
||||||
return nil, totalNodes, hasNextPage, errors.New("Prometheus config error")
|
return nil, errors.New("Prometheus config error")
|
||||||
}
|
}
|
||||||
query, err := pdb.FormatQuery(metric, scope, nodes, cluster)
|
query, err := pdb.FormatQuery(metric, scope, nodes, cluster)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
cclog.Warn("Error while formatting prometheus query")
|
cclog.Warn("Error while formatting prometheus query")
|
||||||
return nil, totalNodes, hasNextPage, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// ranged query over all nodes
|
// ranged query over all nodes
|
||||||
@@ -589,7 +543,7 @@ func (pdb *PrometheusDataRepository) LoadNodeListData(
|
|||||||
result, warnings, err := pdb.queryClient.QueryRange(ctx, query, r)
|
result, warnings, err := pdb.queryClient.QueryRange(ctx, query, r)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
cclog.Errorf("Prometheus query error in LoadNodeData: %v\n", err)
|
cclog.Errorf("Prometheus query error in LoadNodeData: %v\n", err)
|
||||||
return nil, totalNodes, hasNextPage, errors.New("Prometheus query error")
|
return nil, errors.New("Prometheus query error")
|
||||||
}
|
}
|
||||||
if len(warnings) > 0 {
|
if len(warnings) > 0 {
|
||||||
cclog.Warnf("Warnings: %v\n", warnings)
|
cclog.Warnf("Warnings: %v\n", warnings)
|
||||||
@@ -629,5 +583,5 @@ func (pdb *PrometheusDataRepository) LoadNodeListData(
|
|||||||
}
|
}
|
||||||
t1 := time.Since(t0)
|
t1 := time.Since(t0)
|
||||||
cclog.Debugf("LoadNodeListData of %v nodes took %s", len(data), t1)
|
cclog.Debugf("LoadNodeListData of %v nodes took %s", len(data), t1)
|
||||||
return data, totalNodes, hasNextPage, nil
|
return data, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/ClusterCockpit/cc-backend/internal/graph/model"
|
|
||||||
"github.com/ClusterCockpit/cc-lib/schema"
|
"github.com/ClusterCockpit/cc-lib/schema"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -63,14 +62,14 @@ func (tmdr *TestMetricDataRepository) LoadNodeData(
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (tmdr *TestMetricDataRepository) LoadNodeListData(
|
func (tmdr *TestMetricDataRepository) LoadNodeListData(
|
||||||
cluster, subCluster, nodeFilter string,
|
cluster, subCluster string,
|
||||||
|
nodes []string,
|
||||||
metrics []string,
|
metrics []string,
|
||||||
scopes []schema.MetricScope,
|
scopes []schema.MetricScope,
|
||||||
resolution int,
|
resolution int,
|
||||||
from, to time.Time,
|
from, to time.Time,
|
||||||
page *model.PageRequest,
|
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
) (map[string]schema.JobData, int, bool, error) {
|
) (map[string]schema.JobData, error) {
|
||||||
panic("TODO")
|
panic("TODO")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
// 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 repository
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
// RepositoryConfig holds configuration for repository operations.
|
||||||
|
// All fields have sensible defaults, so this configuration is optional.
|
||||||
|
type RepositoryConfig struct {
|
||||||
|
// CacheSize is the LRU cache size in bytes for job metadata and energy footprints.
|
||||||
|
// Default: 1MB (1024 * 1024 bytes)
|
||||||
|
CacheSize int
|
||||||
|
|
||||||
|
// MaxOpenConnections is the maximum number of open database connections.
|
||||||
|
// Default: 4
|
||||||
|
MaxOpenConnections int
|
||||||
|
|
||||||
|
// MaxIdleConnections is the maximum number of idle database connections.
|
||||||
|
// Default: 4
|
||||||
|
MaxIdleConnections int
|
||||||
|
|
||||||
|
// ConnectionMaxLifetime is the maximum amount of time a connection may be reused.
|
||||||
|
// Default: 1 hour
|
||||||
|
ConnectionMaxLifetime time.Duration
|
||||||
|
|
||||||
|
// ConnectionMaxIdleTime is the maximum amount of time a connection may be idle.
|
||||||
|
// Default: 1 hour
|
||||||
|
ConnectionMaxIdleTime time.Duration
|
||||||
|
|
||||||
|
// MinRunningJobDuration is the minimum duration in seconds for a job to be
|
||||||
|
// considered in "running jobs" queries. This filters out very short jobs.
|
||||||
|
// Default: 600 seconds (10 minutes)
|
||||||
|
MinRunningJobDuration int
|
||||||
|
}
|
||||||
|
|
||||||
|
// DefaultConfig returns the default repository configuration.
|
||||||
|
// These values are optimized for typical deployments.
|
||||||
|
func DefaultConfig() *RepositoryConfig {
|
||||||
|
return &RepositoryConfig{
|
||||||
|
CacheSize: 1 * 1024 * 1024, // 1MB
|
||||||
|
MaxOpenConnections: 4,
|
||||||
|
MaxIdleConnections: 4,
|
||||||
|
ConnectionMaxLifetime: time.Hour,
|
||||||
|
ConnectionMaxIdleTime: time.Hour,
|
||||||
|
MinRunningJobDuration: 600, // 10 minutes
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// repoConfig is the package-level configuration instance.
|
||||||
|
// It is initialized with defaults and can be overridden via SetConfig.
|
||||||
|
var repoConfig *RepositoryConfig = DefaultConfig()
|
||||||
|
|
||||||
|
// SetConfig sets the repository configuration.
|
||||||
|
// This must be called before any repository initialization (Connect, GetJobRepository, etc.).
|
||||||
|
// If not called, default values from DefaultConfig() are used.
|
||||||
|
func SetConfig(cfg *RepositoryConfig) {
|
||||||
|
if cfg != nil {
|
||||||
|
repoConfig = cfg
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetConfig returns the current repository configuration.
|
||||||
|
func GetConfig() *RepositoryConfig {
|
||||||
|
return repoConfig
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
// All rights reserved. This file is part of cc-backend.
|
// All rights reserved. This file is part of cc-backend.
|
||||||
// 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.
|
||||||
|
|
||||||
package repository
|
package repository
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -35,21 +36,15 @@ type DatabaseOptions struct {
|
|||||||
ConnectionMaxIdleTime time.Duration
|
ConnectionMaxIdleTime time.Duration
|
||||||
}
|
}
|
||||||
|
|
||||||
func setupSqlite(db *sql.DB) (err error) {
|
func setupSqlite(db *sql.DB) error {
|
||||||
pragmas := []string{
|
pragmas := []string{
|
||||||
// "journal_mode = WAL",
|
|
||||||
// "busy_timeout = 5000",
|
|
||||||
// "synchronous = NORMAL",
|
|
||||||
// "cache_size = 1000000000", // 1GB
|
|
||||||
// "foreign_keys = true",
|
|
||||||
"temp_store = memory",
|
"temp_store = memory",
|
||||||
// "mmap_size = 3000000000",
|
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, pragma := range pragmas {
|
for _, pragma := range pragmas {
|
||||||
_, err = db.Exec("PRAGMA " + pragma)
|
_, err := db.Exec("PRAGMA " + pragma)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -63,24 +58,24 @@ func Connect(driver string, db string) {
|
|||||||
dbConnOnce.Do(func() {
|
dbConnOnce.Do(func() {
|
||||||
opts := DatabaseOptions{
|
opts := DatabaseOptions{
|
||||||
URL: db,
|
URL: db,
|
||||||
MaxOpenConnections: 4,
|
MaxOpenConnections: repoConfig.MaxOpenConnections,
|
||||||
MaxIdleConnections: 4,
|
MaxIdleConnections: repoConfig.MaxIdleConnections,
|
||||||
ConnectionMaxLifetime: time.Hour,
|
ConnectionMaxLifetime: repoConfig.ConnectionMaxLifetime,
|
||||||
ConnectionMaxIdleTime: time.Hour,
|
ConnectionMaxIdleTime: repoConfig.ConnectionMaxIdleTime,
|
||||||
}
|
}
|
||||||
|
|
||||||
switch driver {
|
switch driver {
|
||||||
case "sqlite3":
|
case "sqlite3":
|
||||||
// TODO: Have separate DB handles for Writes and Reads
|
// TODO: Have separate DB handles for Writes and Reads
|
||||||
// Optimize SQLite connection: https://kerkour.com/sqlite-for-servers
|
// Optimize SQLite connection: https://kerkour.com/sqlite-for-servers
|
||||||
connectionUrlParams := make(url.Values)
|
connectionURLParams := make(url.Values)
|
||||||
connectionUrlParams.Add("_txlock", "immediate")
|
connectionURLParams.Add("_txlock", "immediate")
|
||||||
connectionUrlParams.Add("_journal_mode", "WAL")
|
connectionURLParams.Add("_journal_mode", "WAL")
|
||||||
connectionUrlParams.Add("_busy_timeout", "5000")
|
connectionURLParams.Add("_busy_timeout", "5000")
|
||||||
connectionUrlParams.Add("_synchronous", "NORMAL")
|
connectionURLParams.Add("_synchronous", "NORMAL")
|
||||||
connectionUrlParams.Add("_cache_size", "1000000000")
|
connectionURLParams.Add("_cache_size", "1000000000")
|
||||||
connectionUrlParams.Add("_foreign_keys", "true")
|
connectionURLParams.Add("_foreign_keys", "true")
|
||||||
opts.URL = fmt.Sprintf("file:%s?%s", opts.URL, connectionUrlParams.Encode())
|
opts.URL = fmt.Sprintf("file:%s?%s", opts.URL, connectionURLParams.Encode())
|
||||||
|
|
||||||
if cclog.Loglevel() == "debug" {
|
if cclog.Loglevel() == "debug" {
|
||||||
sql.Register("sqlite3WithHooks", sqlhooks.Wrap(&sqlite3.SQLiteDriver{}, &Hooks{}))
|
sql.Register("sqlite3WithHooks", sqlhooks.Wrap(&sqlite3.SQLiteDriver{}, &Hooks{}))
|
||||||
@@ -89,7 +84,10 @@ func Connect(driver string, db string) {
|
|||||||
dbHandle, err = sqlx.Open("sqlite3", opts.URL)
|
dbHandle, err = sqlx.Open("sqlite3", opts.URL)
|
||||||
}
|
}
|
||||||
|
|
||||||
setupSqlite(dbHandle.DB)
|
err = setupSqlite(dbHandle.DB)
|
||||||
|
if err != nil {
|
||||||
|
cclog.Abortf("Failed sqlite db setup.\nError: %s\n", err.Error())
|
||||||
|
}
|
||||||
case "mysql":
|
case "mysql":
|
||||||
opts.URL += "?multiStatements=true"
|
opts.URL += "?multiStatements=true"
|
||||||
dbHandle, err = sqlx.Open("mysql", opts.URL)
|
dbHandle, err = sqlx.Open("mysql", opts.URL)
|
||||||
|
|||||||
+99
-11
@@ -2,6 +2,63 @@
|
|||||||
// All rights reserved. This file is part of cc-backend.
|
// All rights reserved. This file is part of cc-backend.
|
||||||
// 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.
|
||||||
|
|
||||||
|
// Package repository provides the data access layer for cc-backend using the repository pattern.
|
||||||
|
//
|
||||||
|
// The repository pattern abstracts database operations and provides a clean interface for
|
||||||
|
// data access. Each major entity (Job, User, Node, Tag) has its own repository with CRUD
|
||||||
|
// operations and specialized queries.
|
||||||
|
//
|
||||||
|
// # Database Connection
|
||||||
|
//
|
||||||
|
// Initialize the database connection before using any repository:
|
||||||
|
//
|
||||||
|
// repository.Connect("sqlite3", "./var/job.db")
|
||||||
|
// // or for MySQL:
|
||||||
|
// repository.Connect("mysql", "user:password@tcp(localhost:3306)/dbname")
|
||||||
|
//
|
||||||
|
// # Configuration
|
||||||
|
//
|
||||||
|
// Optional: Configure repository settings before initialization:
|
||||||
|
//
|
||||||
|
// repository.SetConfig(&repository.RepositoryConfig{
|
||||||
|
// CacheSize: 2 * 1024 * 1024, // 2MB cache
|
||||||
|
// MaxOpenConnections: 8, // Connection pool size
|
||||||
|
// MinRunningJobDuration: 300, // Filter threshold
|
||||||
|
// })
|
||||||
|
//
|
||||||
|
// If not configured, sensible defaults are used automatically.
|
||||||
|
//
|
||||||
|
// # Repositories
|
||||||
|
//
|
||||||
|
// - JobRepository: Job lifecycle management and querying
|
||||||
|
// - UserRepository: User management and authentication
|
||||||
|
// - NodeRepository: Cluster node state tracking
|
||||||
|
// - Tags: Job tagging and categorization
|
||||||
|
//
|
||||||
|
// # Caching
|
||||||
|
//
|
||||||
|
// Repositories use LRU caching to improve performance. Cache keys are constructed
|
||||||
|
// as "type:id" (e.g., "metadata:123"). Cache is automatically invalidated on
|
||||||
|
// mutations to maintain consistency.
|
||||||
|
//
|
||||||
|
// # Transaction Support
|
||||||
|
//
|
||||||
|
// For batch operations, use transactions:
|
||||||
|
//
|
||||||
|
// t, err := jobRepo.TransactionInit()
|
||||||
|
// if err != nil {
|
||||||
|
// return err
|
||||||
|
// }
|
||||||
|
// defer t.Rollback() // Rollback if not committed
|
||||||
|
//
|
||||||
|
// // Perform operations...
|
||||||
|
// jobRepo.TransactionAdd(t, query, args...)
|
||||||
|
//
|
||||||
|
// // Commit when done
|
||||||
|
// if err := t.Commit(); err != nil {
|
||||||
|
// return err
|
||||||
|
// }
|
||||||
package repository
|
package repository
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -45,7 +102,7 @@ func GetJobRepository() *JobRepository {
|
|||||||
driver: db.Driver,
|
driver: db.Driver,
|
||||||
|
|
||||||
stmtCache: sq.NewStmtCache(db.DB),
|
stmtCache: sq.NewStmtCache(db.DB),
|
||||||
cache: lrucache.New(1024 * 1024),
|
cache: lrucache.New(repoConfig.CacheSize),
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
return jobRepoInstance
|
return jobRepoInstance
|
||||||
@@ -267,7 +324,31 @@ func (r *JobRepository) FetchEnergyFootprint(job *schema.Job) (map[string]float6
|
|||||||
func (r *JobRepository) DeleteJobsBefore(startTime int64) (int, error) {
|
func (r *JobRepository) DeleteJobsBefore(startTime int64) (int, error) {
|
||||||
var cnt int
|
var cnt int
|
||||||
q := sq.Select("count(*)").From("job").Where("job.start_time < ?", startTime)
|
q := sq.Select("count(*)").From("job").Where("job.start_time < ?", startTime)
|
||||||
q.RunWith(r.DB).QueryRow().Scan(cnt)
|
if err := q.RunWith(r.DB).QueryRow().Scan(&cnt); err != nil {
|
||||||
|
cclog.Errorf("Error counting jobs before %d: %v", startTime, err)
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Invalidate cache for jobs being deleted (get job IDs first)
|
||||||
|
if cnt > 0 {
|
||||||
|
var jobIds []int64
|
||||||
|
rows, err := sq.Select("id").From("job").Where("job.start_time < ?", startTime).RunWith(r.DB).Query()
|
||||||
|
if err == nil {
|
||||||
|
defer rows.Close()
|
||||||
|
for rows.Next() {
|
||||||
|
var id int64
|
||||||
|
if err := rows.Scan(&id); err == nil {
|
||||||
|
jobIds = append(jobIds, id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Invalidate cache entries
|
||||||
|
for _, id := range jobIds {
|
||||||
|
r.cache.Del(fmt.Sprintf("metadata:%d", id))
|
||||||
|
r.cache.Del(fmt.Sprintf("energyFootprint:%d", id))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
qd := sq.Delete("job").Where("job.start_time < ?", startTime)
|
qd := sq.Delete("job").Where("job.start_time < ?", startTime)
|
||||||
_, err := qd.RunWith(r.DB).Exec()
|
_, err := qd.RunWith(r.DB).Exec()
|
||||||
|
|
||||||
@@ -281,6 +362,10 @@ func (r *JobRepository) DeleteJobsBefore(startTime int64) (int, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (r *JobRepository) DeleteJobById(id int64) error {
|
func (r *JobRepository) DeleteJobById(id int64) error {
|
||||||
|
// Invalidate cache entries before deletion
|
||||||
|
r.cache.Del(fmt.Sprintf("metadata:%d", id))
|
||||||
|
r.cache.Del(fmt.Sprintf("energyFootprint:%d", id))
|
||||||
|
|
||||||
qd := sq.Delete("job").Where("job.id = ?", id)
|
qd := sq.Delete("job").Where("job.id = ?", id)
|
||||||
_, err := qd.RunWith(r.DB).Exec()
|
_, err := qd.RunWith(r.DB).Exec()
|
||||||
|
|
||||||
@@ -450,13 +535,14 @@ func (r *JobRepository) AllocatedNodes(cluster string) (map[string]map[string]in
|
|||||||
// FIXME: Set duration to requested walltime?
|
// FIXME: Set duration to requested walltime?
|
||||||
func (r *JobRepository) StopJobsExceedingWalltimeBy(seconds int) error {
|
func (r *JobRepository) StopJobsExceedingWalltimeBy(seconds int) error {
|
||||||
start := time.Now()
|
start := time.Now()
|
||||||
|
currentTime := time.Now().Unix()
|
||||||
res, err := sq.Update("job").
|
res, err := sq.Update("job").
|
||||||
Set("monitoring_status", schema.MonitoringStatusArchivingFailed).
|
Set("monitoring_status", schema.MonitoringStatusArchivingFailed).
|
||||||
Set("duration", 0).
|
Set("duration", 0).
|
||||||
Set("job_state", schema.JobStateFailed).
|
Set("job_state", schema.JobStateFailed).
|
||||||
Where("job.job_state = 'running'").
|
Where("job.job_state = 'running'").
|
||||||
Where("job.walltime > 0").
|
Where("job.walltime > 0").
|
||||||
Where(fmt.Sprintf("(%d - job.start_time) > (job.walltime + %d)", time.Now().Unix(), seconds)).
|
Where("(? - job.start_time) > (job.walltime + ?)", currentTime, seconds).
|
||||||
RunWith(r.DB).Exec()
|
RunWith(r.DB).Exec()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
cclog.Warn("Error while stopping jobs exceeding walltime")
|
cclog.Warn("Error while stopping jobs exceeding walltime")
|
||||||
@@ -505,21 +591,21 @@ func (r *JobRepository) FindJobIdsByTag(tagId int64) ([]int64, error) {
|
|||||||
// FIXME: Reconsider filtering short jobs with harcoded threshold
|
// FIXME: Reconsider filtering short jobs with harcoded threshold
|
||||||
func (r *JobRepository) FindRunningJobs(cluster string) ([]*schema.Job, error) {
|
func (r *JobRepository) FindRunningJobs(cluster string) ([]*schema.Job, error) {
|
||||||
query := sq.Select(jobColumns...).From("job").
|
query := sq.Select(jobColumns...).From("job").
|
||||||
Where(fmt.Sprintf("job.cluster = '%s'", cluster)).
|
Where("job.cluster = ?", cluster).
|
||||||
Where("job.job_state = 'running'").
|
Where("job.job_state = 'running'").
|
||||||
Where("job.duration > 600")
|
Where("job.duration > ?", repoConfig.MinRunningJobDuration)
|
||||||
|
|
||||||
rows, err := query.RunWith(r.stmtCache).Query()
|
rows, err := query.RunWith(r.stmtCache).Query()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
cclog.Error("Error while running query")
|
cclog.Error("Error while running query")
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
jobs := make([]*schema.Job, 0, 50)
|
jobs := make([]*schema.Job, 0, 50)
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
job, err := scanJob(rows)
|
job, err := scanJob(rows)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
rows.Close()
|
|
||||||
cclog.Warn("Error while scanning rows")
|
cclog.Warn("Error while scanning rows")
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -552,12 +638,10 @@ func (r *JobRepository) FindJobsBetween(startTimeBegin int64, startTimeEnd int64
|
|||||||
|
|
||||||
if startTimeBegin == 0 {
|
if startTimeBegin == 0 {
|
||||||
cclog.Infof("Find jobs before %d", startTimeEnd)
|
cclog.Infof("Find jobs before %d", startTimeEnd)
|
||||||
query = sq.Select(jobColumns...).From("job").Where(fmt.Sprintf(
|
query = sq.Select(jobColumns...).From("job").Where("job.start_time < ?", startTimeEnd)
|
||||||
"job.start_time < %d", startTimeEnd))
|
|
||||||
} else {
|
} else {
|
||||||
cclog.Infof("Find jobs between %d and %d", startTimeBegin, startTimeEnd)
|
cclog.Infof("Find jobs between %d and %d", startTimeBegin, startTimeEnd)
|
||||||
query = sq.Select(jobColumns...).From("job").Where(fmt.Sprintf(
|
query = sq.Select(jobColumns...).From("job").Where("job.start_time BETWEEN ? AND ?", startTimeBegin, startTimeEnd)
|
||||||
"job.start_time BETWEEN %d AND %d", startTimeBegin, startTimeEnd))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
rows, err := query.RunWith(r.stmtCache).Query()
|
rows, err := query.RunWith(r.stmtCache).Query()
|
||||||
@@ -565,12 +649,12 @@ func (r *JobRepository) FindJobsBetween(startTimeBegin int64, startTimeEnd int64
|
|||||||
cclog.Error("Error while running query")
|
cclog.Error("Error while running query")
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
jobs := make([]*schema.Job, 0, 50)
|
jobs := make([]*schema.Job, 0, 50)
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
job, err := scanJob(rows)
|
job, err := scanJob(rows)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
rows.Close()
|
|
||||||
cclog.Warn("Error while scanning rows")
|
cclog.Warn("Error while scanning rows")
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -582,6 +666,10 @@ func (r *JobRepository) FindJobsBetween(startTimeBegin int64, startTimeEnd int64
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (r *JobRepository) UpdateMonitoringStatus(job int64, monitoringStatus int32) (err error) {
|
func (r *JobRepository) UpdateMonitoringStatus(job int64, monitoringStatus int32) (err error) {
|
||||||
|
// Invalidate cache entries as monitoring status affects job state
|
||||||
|
r.cache.Del(fmt.Sprintf("metadata:%d", job))
|
||||||
|
r.cache.Del(fmt.Sprintf("energyFootprint:%d", job))
|
||||||
|
|
||||||
stmt := sq.Update("job").
|
stmt := sq.Update("job").
|
||||||
Set("monitoring_status", monitoringStatus).
|
Set("monitoring_status", monitoringStatus).
|
||||||
Where("job.id = ?", job)
|
Where("job.id = ?", job)
|
||||||
|
|||||||
@@ -31,8 +31,9 @@ const NamedJobInsert string = `INSERT INTO job (
|
|||||||
|
|
||||||
func (r *JobRepository) InsertJob(job *schema.Job) (int64, error) {
|
func (r *JobRepository) InsertJob(job *schema.Job) (int64, error) {
|
||||||
r.Mutex.Lock()
|
r.Mutex.Lock()
|
||||||
|
defer r.Mutex.Unlock()
|
||||||
|
|
||||||
res, err := r.DB.NamedExec(NamedJobCacheInsert, job)
|
res, err := r.DB.NamedExec(NamedJobCacheInsert, job)
|
||||||
r.Mutex.Unlock()
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
cclog.Warn("Error while NamedJobInsert")
|
cclog.Warn("Error while NamedJobInsert")
|
||||||
return 0, err
|
return 0, err
|
||||||
@@ -57,12 +58,12 @@ func (r *JobRepository) SyncJobs() ([]*schema.Job, error) {
|
|||||||
cclog.Errorf("Error while running query %v", err)
|
cclog.Errorf("Error while running query %v", err)
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
jobs := make([]*schema.Job, 0, 50)
|
jobs := make([]*schema.Job, 0, 50)
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
job, err := scanJob(rows)
|
job, err := scanJob(rows)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
rows.Close()
|
|
||||||
cclog.Warn("Error while scanning rows")
|
cclog.Warn("Error while scanning rows")
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -113,6 +114,10 @@ func (r *JobRepository) Stop(
|
|||||||
state schema.JobState,
|
state schema.JobState,
|
||||||
monitoringStatus int32,
|
monitoringStatus int32,
|
||||||
) (err error) {
|
) (err error) {
|
||||||
|
// Invalidate cache entries as job state is changing
|
||||||
|
r.cache.Del(fmt.Sprintf("metadata:%d", jobId))
|
||||||
|
r.cache.Del(fmt.Sprintf("energyFootprint:%d", jobId))
|
||||||
|
|
||||||
stmt := sq.Update("job").
|
stmt := sq.Update("job").
|
||||||
Set("job_state", state).
|
Set("job_state", state).
|
||||||
Set("duration", duration).
|
Set("duration", duration).
|
||||||
@@ -129,11 +134,13 @@ func (r *JobRepository) StopCached(
|
|||||||
state schema.JobState,
|
state schema.JobState,
|
||||||
monitoringStatus int32,
|
monitoringStatus int32,
|
||||||
) (err error) {
|
) (err error) {
|
||||||
|
// Note: StopCached updates job_cache table, not the main job table
|
||||||
|
// Cache invalidation happens when job is synced to main table
|
||||||
stmt := sq.Update("job_cache").
|
stmt := sq.Update("job_cache").
|
||||||
Set("job_state", state).
|
Set("job_state", state).
|
||||||
Set("duration", duration).
|
Set("duration", duration).
|
||||||
Set("monitoring_status", monitoringStatus).
|
Set("monitoring_status", monitoringStatus).
|
||||||
Where("job.id = ?", jobId)
|
Where("job_cache.id = ?", jobId)
|
||||||
|
|
||||||
_, err = stmt.RunWith(r.stmtCache).Exec()
|
_, err = stmt.RunWith(r.stmtCache).Exec()
|
||||||
return err
|
return err
|
||||||
|
|||||||
@@ -89,6 +89,7 @@ func (r *JobRepository) FindAll(
|
|||||||
cclog.Error("Error while running query")
|
cclog.Error("Error while running query")
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
jobs := make([]*schema.Job, 0, 10)
|
jobs := make([]*schema.Job, 0, 10)
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
@@ -103,25 +104,31 @@ func (r *JobRepository) FindAll(
|
|||||||
return jobs, nil
|
return jobs, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get complete joblist only consisting of db ids.
|
// GetJobList returns job IDs for non-running jobs.
|
||||||
// This is useful to process large job counts and intended to be used
|
// This is useful to process large job counts and intended to be used
|
||||||
// together with FindById to process jobs one by one
|
// together with FindById to process jobs one by one.
|
||||||
func (r *JobRepository) GetJobList() ([]int64, error) {
|
// Use limit and offset for pagination. Use limit=0 to get all results (not recommended for large datasets).
|
||||||
|
func (r *JobRepository) GetJobList(limit int, offset int) ([]int64, error) {
|
||||||
query := sq.Select("id").From("job").
|
query := sq.Select("id").From("job").
|
||||||
Where("job.job_state != 'running'")
|
Where("job.job_state != 'running'")
|
||||||
|
|
||||||
|
// Add pagination if limit is specified
|
||||||
|
if limit > 0 {
|
||||||
|
query = query.Limit(uint64(limit)).Offset(uint64(offset))
|
||||||
|
}
|
||||||
|
|
||||||
rows, err := query.RunWith(r.stmtCache).Query()
|
rows, err := query.RunWith(r.stmtCache).Query()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
cclog.Error("Error while running query")
|
cclog.Error("Error while running query")
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
jl := make([]int64, 0, 1000)
|
jl := make([]int64, 0, 1000)
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var id int64
|
var id int64
|
||||||
err := rows.Scan(&id)
|
err := rows.Scan(&id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
rows.Close()
|
|
||||||
cclog.Warn("Error while scanning rows")
|
cclog.Warn("Error while scanning rows")
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -256,6 +263,7 @@ func (r *JobRepository) FindConcurrentJobs(
|
|||||||
cclog.Errorf("Error while running query: %v", err)
|
cclog.Errorf("Error while running query: %v", err)
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
items := make([]*model.JobLink, 0, 10)
|
items := make([]*model.JobLink, 0, 10)
|
||||||
queryString := fmt.Sprintf("cluster=%s", job.Cluster)
|
queryString := fmt.Sprintf("cluster=%s", job.Cluster)
|
||||||
@@ -283,6 +291,7 @@ func (r *JobRepository) FindConcurrentJobs(
|
|||||||
cclog.Errorf("Error while running query: %v", err)
|
cclog.Errorf("Error while running query: %v", err)
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var id, jobId, startTime sql.NullInt64
|
var id, jobId, startTime sql.NullInt64
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ CREATE TABLE "job_cache" (
|
|||||||
job_state VARCHAR(255) NOT NULL
|
job_state VARCHAR(255) NOT NULL
|
||||||
CHECK (job_state IN (
|
CHECK (job_state IN (
|
||||||
'boot_fail', 'cancelled', 'completed', 'deadline',
|
'boot_fail', 'cancelled', 'completed', 'deadline',
|
||||||
'failed', 'node_fail', 'out-of-memory', 'pending',
|
'failed', 'node_fail', 'out_of_memory', 'pending',
|
||||||
'preempted', 'running', 'suspended', 'timeout'
|
'preempted', 'running', 'suspended', 'timeout'
|
||||||
)),
|
)),
|
||||||
meta_data TEXT, -- JSON
|
meta_data TEXT, -- JSON
|
||||||
@@ -49,7 +49,7 @@ CREATE TABLE "job_new" (
|
|||||||
job_state TEXT NOT NULL
|
job_state TEXT NOT NULL
|
||||||
CHECK (job_state IN (
|
CHECK (job_state IN (
|
||||||
'boot_fail', 'cancelled', 'completed', 'deadline',
|
'boot_fail', 'cancelled', 'completed', 'deadline',
|
||||||
'failed', 'node_fail', 'out-of-memory', 'pending',
|
'failed', 'node_fail', 'out_of_memory', 'pending',
|
||||||
'preempted', 'running', 'suspended', 'timeout'
|
'preempted', 'running', 'suspended', 'timeout'
|
||||||
)),
|
)),
|
||||||
meta_data TEXT, -- JSON
|
meta_data TEXT, -- JSON
|
||||||
@@ -115,5 +115,117 @@ INSERT INTO job_new (
|
|||||||
FROM job;
|
FROM job;
|
||||||
|
|
||||||
DROP TABLE lookup_exclusive;
|
DROP TABLE lookup_exclusive;
|
||||||
DROP TABLE job;
|
DROP TABLE job; -- Deletes All Existing 'job' Indices; Recreate after Renaming
|
||||||
ALTER TABLE job_new RENAME TO job;
|
ALTER TABLE job_new RENAME TO job;
|
||||||
|
|
||||||
|
-- Recreate Indices from 08_add-footprint, include new submit_time indices
|
||||||
|
-- Cluster Filter
|
||||||
|
CREATE INDEX IF NOT EXISTS jobs_cluster ON job (cluster);
|
||||||
|
CREATE INDEX IF NOT EXISTS jobs_cluster_user ON job (cluster, hpc_user);
|
||||||
|
CREATE INDEX IF NOT EXISTS jobs_cluster_project ON job (cluster, project);
|
||||||
|
CREATE INDEX IF NOT EXISTS jobs_cluster_subcluster ON job (cluster, subcluster);
|
||||||
|
-- Cluster Filter Sorting
|
||||||
|
CREATE INDEX IF NOT EXISTS jobs_cluster_starttime ON job (cluster, start_time);
|
||||||
|
CREATE INDEX IF NOT EXISTS jobs_cluster_submittime ON job (cluster, submit_time);
|
||||||
|
CREATE INDEX IF NOT EXISTS jobs_cluster_duration ON job (cluster, duration);
|
||||||
|
CREATE INDEX IF NOT EXISTS jobs_cluster_numnodes ON job (cluster, num_nodes);
|
||||||
|
CREATE INDEX IF NOT EXISTS jobs_cluster_numhwthreads ON job (cluster, num_hwthreads);
|
||||||
|
CREATE INDEX IF NOT EXISTS jobs_cluster_numacc ON job (cluster, num_acc);
|
||||||
|
CREATE INDEX IF NOT EXISTS jobs_cluster_energy ON job (cluster, energy);
|
||||||
|
|
||||||
|
-- Cluster+Partition Filter
|
||||||
|
CREATE INDEX IF NOT EXISTS jobs_cluster_partition ON job (cluster, cluster_partition);
|
||||||
|
-- Cluster+Partition Filter Sorting
|
||||||
|
CREATE INDEX IF NOT EXISTS jobs_cluster_partition_starttime ON job (cluster, cluster_partition, start_time);
|
||||||
|
CREATE INDEX IF NOT EXISTS jobs_cluster_partition_submittime ON job (cluster, cluster_partition, submit_time);
|
||||||
|
CREATE INDEX IF NOT EXISTS jobs_cluster_partition_duration ON job (cluster, cluster_partition, duration);
|
||||||
|
CREATE INDEX IF NOT EXISTS jobs_cluster_partition_numnodes ON job (cluster, cluster_partition, num_nodes);
|
||||||
|
CREATE INDEX IF NOT EXISTS jobs_cluster_partition_numhwthreads ON job (cluster, cluster_partition, num_hwthreads);
|
||||||
|
CREATE INDEX IF NOT EXISTS jobs_cluster_partition_numacc ON job (cluster, cluster_partition, num_acc);
|
||||||
|
CREATE INDEX IF NOT EXISTS jobs_cluster_partition_energy ON job (cluster, cluster_partition, energy);
|
||||||
|
|
||||||
|
-- Cluster+Partition+Jobstate Filter
|
||||||
|
CREATE INDEX IF NOT EXISTS jobs_cluster_partition_jobstate ON job (cluster, cluster_partition, job_state);
|
||||||
|
CREATE INDEX IF NOT EXISTS jobs_cluster_partition_jobstate_user ON job (cluster, cluster_partition, job_state, hpc_user);
|
||||||
|
CREATE INDEX IF NOT EXISTS jobs_cluster_partition_jobstate_project ON job (cluster, cluster_partition, job_state, project);
|
||||||
|
-- Cluster+Partition+Jobstate Filter Sorting
|
||||||
|
CREATE INDEX IF NOT EXISTS jobs_cluster_partition_jobstate_starttime ON job (cluster, cluster_partition, job_state, start_time);
|
||||||
|
CREATE INDEX IF NOT EXISTS jobs_cluster_partition_jobstate_submittime ON job (cluster, cluster_partition, job_state, submit_time);
|
||||||
|
CREATE INDEX IF NOT EXISTS jobs_cluster_partition_jobstate_duration ON job (cluster, cluster_partition, job_state, duration);
|
||||||
|
CREATE INDEX IF NOT EXISTS jobs_cluster_partition_jobstate_numnodes ON job (cluster, cluster_partition, job_state, num_nodes);
|
||||||
|
CREATE INDEX IF NOT EXISTS jobs_cluster_partition_jobstate_numhwthreads ON job (cluster, cluster_partition, job_state, num_hwthreads);
|
||||||
|
CREATE INDEX IF NOT EXISTS jobs_cluster_partition_jobstate_numacc ON job (cluster, cluster_partition, job_state, num_acc);
|
||||||
|
CREATE INDEX IF NOT EXISTS jobs_cluster_partition_jobstate_energy ON job (cluster, cluster_partition, job_state, energy);
|
||||||
|
|
||||||
|
-- Cluster+JobState Filter
|
||||||
|
CREATE INDEX IF NOT EXISTS jobs_cluster_jobstate ON job (cluster, job_state);
|
||||||
|
CREATE INDEX IF NOT EXISTS jobs_cluster_jobstate_user ON job (cluster, job_state, hpc_user);
|
||||||
|
CREATE INDEX IF NOT EXISTS jobs_cluster_jobstate_project ON job (cluster, job_state, project);
|
||||||
|
-- Cluster+JobState Filter Sorting
|
||||||
|
CREATE INDEX IF NOT EXISTS jobs_cluster_jobstate_starttime ON job (cluster, job_state, start_time);
|
||||||
|
CREATE INDEX IF NOT EXISTS jobs_cluster_jobstate_submittime ON job (cluster, job_state, submit_time);
|
||||||
|
CREATE INDEX IF NOT EXISTS jobs_cluster_jobstate_duration ON job (cluster, job_state, duration);
|
||||||
|
CREATE INDEX IF NOT EXISTS jobs_cluster_jobstate_numnodes ON job (cluster, job_state, num_nodes);
|
||||||
|
CREATE INDEX IF NOT EXISTS jobs_cluster_jobstate_numhwthreads ON job (cluster, job_state, num_hwthreads);
|
||||||
|
CREATE INDEX IF NOT EXISTS jobs_cluster_jobstate_numacc ON job (cluster, job_state, num_acc);
|
||||||
|
CREATE INDEX IF NOT EXISTS jobs_cluster_jobstate_energy ON job (cluster, job_state, energy);
|
||||||
|
|
||||||
|
-- User Filter
|
||||||
|
CREATE INDEX IF NOT EXISTS jobs_user ON job (hpc_user);
|
||||||
|
-- User Filter Sorting
|
||||||
|
CREATE INDEX IF NOT EXISTS jobs_user_starttime ON job (hpc_user, start_time);
|
||||||
|
CREATE INDEX IF NOT EXISTS jobs_user_duration ON job (hpc_user, duration);
|
||||||
|
CREATE INDEX IF NOT EXISTS jobs_user_numnodes ON job (hpc_user, num_nodes);
|
||||||
|
CREATE INDEX IF NOT EXISTS jobs_user_numhwthreads ON job (hpc_user, num_hwthreads);
|
||||||
|
CREATE INDEX IF NOT EXISTS jobs_user_numacc ON job (hpc_user, num_acc);
|
||||||
|
CREATE INDEX IF NOT EXISTS jobs_user_energy ON job (hpc_user, energy);
|
||||||
|
|
||||||
|
-- Project Filter
|
||||||
|
CREATE INDEX IF NOT EXISTS jobs_project ON job (project);
|
||||||
|
CREATE INDEX IF NOT EXISTS jobs_project_user ON job (project, hpc_user);
|
||||||
|
-- Project Filter Sorting
|
||||||
|
CREATE INDEX IF NOT EXISTS jobs_project_starttime ON job (project, start_time);
|
||||||
|
CREATE INDEX IF NOT EXISTS jobs_project_duration ON job (project, duration);
|
||||||
|
CREATE INDEX IF NOT EXISTS jobs_project_numnodes ON job (project, num_nodes);
|
||||||
|
CREATE INDEX IF NOT EXISTS jobs_project_numhwthreads ON job (project, num_hwthreads);
|
||||||
|
CREATE INDEX IF NOT EXISTS jobs_project_numacc ON job (project, num_acc);
|
||||||
|
CREATE INDEX IF NOT EXISTS jobs_project_energy ON job (project, energy);
|
||||||
|
|
||||||
|
-- JobState Filter
|
||||||
|
CREATE INDEX IF NOT EXISTS jobs_jobstate ON job (job_state);
|
||||||
|
CREATE INDEX IF NOT EXISTS jobs_jobstate_user ON job (job_state, hpc_user);
|
||||||
|
CREATE INDEX IF NOT EXISTS jobs_jobstate_project ON job (job_state, project);
|
||||||
|
CREATE INDEX IF NOT EXISTS jobs_jobstate_cluster ON job (job_state, cluster);
|
||||||
|
-- JobState Filter Sorting
|
||||||
|
CREATE INDEX IF NOT EXISTS jobs_jobstate_starttime ON job (job_state, start_time);
|
||||||
|
CREATE INDEX IF NOT EXISTS jobs_jobstate_duration ON job (job_state, duration);
|
||||||
|
CREATE INDEX IF NOT EXISTS jobs_jobstate_numnodes ON job (job_state, num_nodes);
|
||||||
|
CREATE INDEX IF NOT EXISTS jobs_jobstate_numhwthreads ON job (job_state, num_hwthreads);
|
||||||
|
CREATE INDEX IF NOT EXISTS jobs_jobstate_numacc ON job (job_state, num_acc);
|
||||||
|
CREATE INDEX IF NOT EXISTS jobs_jobstate_energy ON job (job_state, energy);
|
||||||
|
|
||||||
|
-- ArrayJob Filter
|
||||||
|
CREATE INDEX IF NOT EXISTS jobs_arrayjobid_starttime ON job (array_job_id, start_time);
|
||||||
|
CREATE INDEX IF NOT EXISTS jobs_cluster_arrayjobid_starttime ON job (cluster, array_job_id, start_time);
|
||||||
|
|
||||||
|
-- Sorting without active filters
|
||||||
|
CREATE INDEX IF NOT EXISTS jobs_starttime ON job (start_time);
|
||||||
|
CREATE INDEX IF NOT EXISTS jobs_duration ON job (duration);
|
||||||
|
CREATE INDEX IF NOT EXISTS jobs_numnodes ON job (num_nodes);
|
||||||
|
CREATE INDEX IF NOT EXISTS jobs_numhwthreads ON job (num_hwthreads);
|
||||||
|
CREATE INDEX IF NOT EXISTS jobs_numacc ON job (num_acc);
|
||||||
|
CREATE INDEX IF NOT EXISTS jobs_energy ON job (energy);
|
||||||
|
|
||||||
|
-- Single filters with default starttime sorting
|
||||||
|
CREATE INDEX IF NOT EXISTS jobs_duration_starttime ON job (duration, start_time);
|
||||||
|
CREATE INDEX IF NOT EXISTS jobs_numnodes_starttime ON job (num_nodes, start_time);
|
||||||
|
CREATE INDEX IF NOT EXISTS jobs_numhwthreads_starttime ON job (num_hwthreads, start_time);
|
||||||
|
CREATE INDEX IF NOT EXISTS jobs_numacc_starttime ON job (num_acc, start_time);
|
||||||
|
CREATE INDEX IF NOT EXISTS jobs_energy_starttime ON job (energy, start_time);
|
||||||
|
|
||||||
|
-- Optimize DB index usage
|
||||||
|
PRAGMA optimize;
|
||||||
|
|
||||||
|
-- Optimize DB size: https://sqlite.org/lang_vacuum.html
|
||||||
|
-- Not allowed within a migration transaction; Keep command here for documentation and recommendation
|
||||||
|
-- Command: 'VACUUM;'
|
||||||
@@ -27,24 +27,22 @@ CREATE TABLE "node_state" (
|
|||||||
FOREIGN KEY (node_id) REFERENCES node (id)
|
FOREIGN KEY (node_id) REFERENCES node (id)
|
||||||
);
|
);
|
||||||
|
|
||||||
-- Add NEW Indices For New Job Table Columns
|
|
||||||
CREATE INDEX IF NOT EXISTS jobs_cluster_submittime ON job (cluster, submit_time);
|
|
||||||
CREATE INDEX IF NOT EXISTS jobs_cluster_partition_submittime ON job (cluster, cluster_partition, submit_time);
|
|
||||||
CREATE INDEX IF NOT EXISTS jobs_cluster_partition_jobstate_submittime ON job (
|
|
||||||
cluster, cluster_partition, job_state, submit_time
|
|
||||||
);
|
|
||||||
CREATE INDEX IF NOT EXISTS jobs_cluster_jobstate_submittime ON job (cluster, job_state, submit_time);
|
|
||||||
|
|
||||||
-- Add NEW Indices For New Node Table VARCHAR Fields
|
-- Add NEW Indices For New Node Table VARCHAR Fields
|
||||||
CREATE INDEX IF NOT EXISTS nodes_cluster ON node (cluster);
|
CREATE INDEX IF NOT EXISTS nodes_cluster ON node (cluster);
|
||||||
CREATE INDEX IF NOT EXISTS nodes_cluster_subcluster ON node (cluster, subcluster);
|
CREATE INDEX IF NOT EXISTS nodes_cluster_subcluster ON node (cluster, subcluster);
|
||||||
|
|
||||||
-- Add NEW Indices For New Node_State Table Fields
|
-- Add NEW Indices For New Node_State Table Fields
|
||||||
|
CREATE INDEX IF NOT EXISTS nodestates_timestamp ON node_state (time_stamp);
|
||||||
CREATE INDEX IF NOT EXISTS nodestates_state ON node_state (node_state);
|
CREATE INDEX IF NOT EXISTS nodestates_state ON node_state (node_state);
|
||||||
CREATE INDEX IF NOT EXISTS nodestates_health ON node_state (health_state);
|
CREATE INDEX IF NOT EXISTS nodestates_health ON node_state (health_state);
|
||||||
|
CREATE INDEX IF NOT EXISTS nodestates_state_timestamp ON node_state (node_state, time_stamp);
|
||||||
|
CREATE INDEX IF NOT EXISTS nodestates_health_timestamp ON node_state (health_state, time_stamp);
|
||||||
CREATE INDEX IF NOT EXISTS nodestates_nodeid_state ON node_state (node_id, node_state);
|
CREATE INDEX IF NOT EXISTS nodestates_nodeid_state ON node_state (node_id, node_state);
|
||||||
CREATE INDEX IF NOT EXISTS nodestates_nodeid_health ON node_state (node_id, health_state);
|
CREATE INDEX IF NOT EXISTS nodestates_nodeid_health ON node_state (node_id, health_state);
|
||||||
|
|
||||||
-- Add NEW Indices For Increased Amounts of Tags
|
-- Add NEW Indices For Increased Amounts of Tags
|
||||||
CREATE INDEX IF NOT EXISTS tags_jobid ON jobtag (job_id);
|
CREATE INDEX IF NOT EXISTS tags_jobid ON jobtag (job_id);
|
||||||
CREATE INDEX IF NOT EXISTS tags_tagid ON jobtag (tag_id);
|
CREATE INDEX IF NOT EXISTS tags_tagid ON jobtag (tag_id);
|
||||||
|
|
||||||
|
-- Optimize DB index usage
|
||||||
|
PRAGMA optimize;
|
||||||
+161
-83
@@ -43,7 +43,7 @@ func GetNodeRepository() *NodeRepository {
|
|||||||
driver: db.Driver,
|
driver: db.Driver,
|
||||||
|
|
||||||
stmtCache: sq.NewStmtCache(db.DB),
|
stmtCache: sq.NewStmtCache(db.DB),
|
||||||
cache: lrucache.New(1024 * 1024),
|
cache: lrucache.New(repoConfig.CacheSize),
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
return nodeRepoInstance
|
return nodeRepoInstance
|
||||||
@@ -77,55 +77,19 @@ func (r *NodeRepository) FetchMetadata(hostname string, cluster string) (map[str
|
|||||||
return MetaData, nil
|
return MetaData, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
//
|
|
||||||
// func (r *NodeRepository) UpdateMetadata(node *schema.Node, key, val string) (err error) {
|
|
||||||
// cachekey := fmt.Sprintf("metadata:%d", node.ID)
|
|
||||||
// r.cache.Del(cachekey)
|
|
||||||
// if node.MetaData == nil {
|
|
||||||
// if _, err = r.FetchMetadata(node); err != nil {
|
|
||||||
// cclog.Warnf("Error while fetching metadata for node, DB ID '%v'", node.ID)
|
|
||||||
// return err
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
//
|
|
||||||
// if node.MetaData != nil {
|
|
||||||
// cpy := make(map[string]string, len(node.MetaData)+1)
|
|
||||||
// maps.Copy(cpy, node.MetaData)
|
|
||||||
// cpy[key] = val
|
|
||||||
// node.MetaData = cpy
|
|
||||||
// } else {
|
|
||||||
// node.MetaData = map[string]string{key: val}
|
|
||||||
// }
|
|
||||||
//
|
|
||||||
// if node.RawMetaData, err = json.Marshal(node.MetaData); err != nil {
|
|
||||||
// cclog.Warnf("Error while marshaling metadata for node, DB ID '%v'", node.ID)
|
|
||||||
// return err
|
|
||||||
// }
|
|
||||||
//
|
|
||||||
// if _, err = sq.Update("node").
|
|
||||||
// Set("meta_data", node.RawMetaData).
|
|
||||||
// Where("node.id = ?", node.ID).
|
|
||||||
// RunWith(r.stmtCache).Exec(); err != nil {
|
|
||||||
// cclog.Warnf("Error while updating metadata for node, DB ID '%v'", node.ID)
|
|
||||||
// return err
|
|
||||||
// }
|
|
||||||
//
|
|
||||||
// r.cache.Put(cachekey, node.MetaData, len(node.RawMetaData), 24*time.Hour)
|
|
||||||
// return nil
|
|
||||||
// }
|
|
||||||
|
|
||||||
func (r *NodeRepository) GetNode(hostname string, cluster string, withMeta bool) (*schema.Node, error) {
|
func (r *NodeRepository) GetNode(hostname string, cluster string, withMeta bool) (*schema.Node, error) {
|
||||||
node := &schema.Node{}
|
node := &schema.Node{}
|
||||||
|
var timestamp int
|
||||||
if err := sq.Select("node.hostname", "node.cluster", "node.subcluster", "node_state.node_state",
|
if err := sq.Select("node.hostname", "node.cluster", "node.subcluster", "node_state.node_state",
|
||||||
"node_state.health_state", "MAX(node_state.time_stamp) as time").
|
"node_state.health_state", "MAX(node_state.time_stamp) as time").
|
||||||
From("node_state").
|
From("node_state").
|
||||||
Join("node ON nodes_state.node_id = node.id").
|
Join("node ON node_state.node_id = node.id").
|
||||||
Where("node.hostname = ?", hostname).
|
Where("node.hostname = ?", hostname).
|
||||||
Where("node.cluster = ?", cluster).
|
Where("node.cluster = ?", cluster).
|
||||||
GroupBy("node_state.node_id").
|
GroupBy("node_state.node_id").
|
||||||
RunWith(r.DB).
|
RunWith(r.DB).
|
||||||
QueryRow().Scan(&node.Hostname, &node.Cluster, &node.SubCluster, &node.NodeState, &node.HealthState); err != nil {
|
QueryRow().Scan(&node.Hostname, &node.Cluster, &node.SubCluster, &node.NodeState, &node.HealthState, ×tamp); err != nil {
|
||||||
cclog.Warnf("Error while querying node '%s' from database: %v", hostname, err)
|
cclog.Warnf("Error while querying node '%s' at time '%d' from database: %v", hostname, timestamp, err)
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -144,15 +108,16 @@ func (r *NodeRepository) GetNode(hostname string, cluster string, withMeta bool)
|
|||||||
|
|
||||||
func (r *NodeRepository) GetNodeById(id int64, withMeta bool) (*schema.Node, error) {
|
func (r *NodeRepository) GetNodeById(id int64, withMeta bool) (*schema.Node, error) {
|
||||||
node := &schema.Node{}
|
node := &schema.Node{}
|
||||||
|
var timestamp int
|
||||||
if err := sq.Select("node.hostname", "node.cluster", "node.subcluster", "node_state.node_state",
|
if err := sq.Select("node.hostname", "node.cluster", "node.subcluster", "node_state.node_state",
|
||||||
"node_state.health_state", "MAX(node_state.time_stamp) as time").
|
"node_state.health_state", "MAX(node_state.time_stamp) as time").
|
||||||
From("node_state").
|
From("node_state").
|
||||||
Join("node ON nodes_state.node_id = node.id").
|
Join("node ON node_state.node_id = node.id").
|
||||||
Where("node.id = ?", id).
|
Where("node.id = ?", id).
|
||||||
GroupBy("node_state.node_id").
|
GroupBy("node_state.node_id").
|
||||||
RunWith(r.DB).
|
RunWith(r.DB).
|
||||||
QueryRow().Scan(&node.Hostname, &node.Cluster, &node.SubCluster, &node.NodeState, &node.HealthState); err != nil {
|
QueryRow().Scan(&node.Hostname, &node.Cluster, &node.SubCluster, &node.NodeState, &node.HealthState, ×tamp); err != nil {
|
||||||
cclog.Warnf("Error while querying node ID '%d' from database: %v", id, err)
|
cclog.Warnf("Error while querying node ID '%d' at time '%d' from database: %v", id, timestamp, err)
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -272,37 +237,48 @@ func (r *NodeRepository) DeleteNode(id int64) error {
|
|||||||
func (r *NodeRepository) QueryNodes(
|
func (r *NodeRepository) QueryNodes(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
filters []*model.NodeFilter,
|
filters []*model.NodeFilter,
|
||||||
|
page *model.PageRequest,
|
||||||
order *model.OrderByInput, // Currently unused!
|
order *model.OrderByInput, // Currently unused!
|
||||||
) ([]*schema.Node, error) {
|
) ([]*schema.Node, error) {
|
||||||
|
|
||||||
query, qerr := AccessCheck(ctx,
|
query, qerr := AccessCheck(ctx,
|
||||||
sq.Select("node.hostname", "node.cluster", "node.subcluster", "node_state.node_state",
|
sq.Select("hostname", "cluster", "subcluster", "node_state", "health_state", "MAX(time_stamp) as time").
|
||||||
"node_state.health_state", "MAX(node_state.time_stamp) as time").
|
|
||||||
From("node").
|
From("node").
|
||||||
Join("node_state ON nodes_state.node_id = node.id"))
|
Join("node_state ON node_state.node_id = node.id"))
|
||||||
if qerr != nil {
|
if qerr != nil {
|
||||||
return nil, qerr
|
return nil, qerr
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, f := range filters {
|
for _, f := range filters {
|
||||||
if f.Hostname != nil {
|
|
||||||
query = buildStringCondition("node.hostname", f.Hostname, query)
|
|
||||||
}
|
|
||||||
if f.Cluster != nil {
|
if f.Cluster != nil {
|
||||||
query = buildStringCondition("node.cluster", f.Cluster, query)
|
query = buildStringCondition("cluster", f.Cluster, query)
|
||||||
}
|
}
|
||||||
if f.Subcluster != nil {
|
if f.Subcluster != nil {
|
||||||
query = buildStringCondition("node.subcluster", f.Subcluster, query)
|
query = buildStringCondition("subcluster", f.Subcluster, query)
|
||||||
|
}
|
||||||
|
if f.Hostname != nil {
|
||||||
|
query = buildStringCondition("hostname", f.Hostname, query)
|
||||||
}
|
}
|
||||||
if f.SchedulerState != nil {
|
if f.SchedulerState != nil {
|
||||||
query = query.Where("node.node_state = ?", f.SchedulerState)
|
query = query.Where("node_state = ?", f.SchedulerState)
|
||||||
|
// Requires Additional time_stamp Filter: Else the last (past!) time_stamp with queried state will be returned
|
||||||
|
now := time.Now().Unix()
|
||||||
|
query = query.Where(sq.Gt{"time_stamp": (now - 60)})
|
||||||
}
|
}
|
||||||
if f.HealthState != nil {
|
if f.HealthState != nil {
|
||||||
query = query.Where("node.health_state = ?", f.HealthState)
|
query = query.Where("health_state = ?", f.HealthState)
|
||||||
|
// Requires Additional time_stamp Filter: Else the last (past!) time_stamp with queried state will be returned
|
||||||
|
now := time.Now().Unix()
|
||||||
|
query = query.Where(sq.Gt{"time_stamp": (now - 60)})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add Grouping after filters
|
query = query.GroupBy("node_id").OrderBy("hostname ASC")
|
||||||
query = query.GroupBy("node_state.node_id")
|
|
||||||
|
if page != nil && page.ItemsPerPage != -1 {
|
||||||
|
limit := uint64(page.ItemsPerPage)
|
||||||
|
query = query.Offset((uint64(page.Page) - 1) * limit).Limit(limit)
|
||||||
|
}
|
||||||
|
|
||||||
rows, err := query.RunWith(r.stmtCache).Query()
|
rows, err := query.RunWith(r.stmtCache).Query()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -311,14 +287,14 @@ func (r *NodeRepository) QueryNodes(
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
nodes := make([]*schema.Node, 0, 50)
|
nodes := make([]*schema.Node, 0)
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
node := schema.Node{}
|
node := schema.Node{}
|
||||||
|
var timestamp int
|
||||||
if err := rows.Scan(&node.Hostname, &node.Cluster, &node.SubCluster,
|
if err := rows.Scan(&node.Hostname, &node.Cluster, &node.SubCluster,
|
||||||
&node.NodeState, &node.HealthState); err != nil {
|
&node.NodeState, &node.HealthState, ×tamp); err != nil {
|
||||||
rows.Close()
|
rows.Close()
|
||||||
cclog.Warn("Error while scanning rows (Nodes)")
|
cclog.Warnf("Error while scanning rows (QueryNodes) at time '%d'", timestamp)
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
nodes = append(nodes, &node)
|
nodes = append(nodes, &node)
|
||||||
@@ -327,6 +303,67 @@ func (r *NodeRepository) QueryNodes(
|
|||||||
return nodes, nil
|
return nodes, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CountNodes returns the total matched nodes based on a node filter. It always operates
|
||||||
|
// on the last state (largest timestamp).
|
||||||
|
func (r *NodeRepository) CountNodes(
|
||||||
|
ctx context.Context,
|
||||||
|
filters []*model.NodeFilter,
|
||||||
|
) (int, error) {
|
||||||
|
|
||||||
|
query, qerr := AccessCheck(ctx,
|
||||||
|
sq.Select("time_stamp", "count(*) as countRes").
|
||||||
|
From("node").
|
||||||
|
Join("node_state ON node_state.node_id = node.id"))
|
||||||
|
if qerr != nil {
|
||||||
|
return 0, qerr
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, f := range filters {
|
||||||
|
if f.Cluster != nil {
|
||||||
|
query = buildStringCondition("cluster", f.Cluster, query)
|
||||||
|
}
|
||||||
|
if f.Subcluster != nil {
|
||||||
|
query = buildStringCondition("subcluster", f.Subcluster, query)
|
||||||
|
}
|
||||||
|
if f.Hostname != nil {
|
||||||
|
query = buildStringCondition("hostname", f.Hostname, query)
|
||||||
|
}
|
||||||
|
if f.SchedulerState != nil {
|
||||||
|
query = query.Where("node_state = ?", f.SchedulerState)
|
||||||
|
// Requires Additional time_stamp Filter: Else the last (past!) time_stamp with queried state will be returned
|
||||||
|
now := time.Now().Unix()
|
||||||
|
query = query.Where(sq.Gt{"time_stamp": (now - 60)})
|
||||||
|
}
|
||||||
|
if f.HealthState != nil {
|
||||||
|
query = query.Where("health_state = ?", f.HealthState)
|
||||||
|
// Requires Additional time_stamp Filter: Else the last (past!) time_stamp with queried state will be returned
|
||||||
|
now := time.Now().Unix()
|
||||||
|
query = query.Where(sq.Gt{"time_stamp": (now - 60)})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
query = query.GroupBy("time_stamp").OrderBy("time_stamp DESC").Limit(1)
|
||||||
|
|
||||||
|
rows, err := query.RunWith(r.stmtCache).Query()
|
||||||
|
if err != nil {
|
||||||
|
queryString, queryVars, _ := query.ToSql()
|
||||||
|
cclog.Errorf("Error while running query '%s' %v: %v", queryString, queryVars, err)
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var totalNodes int
|
||||||
|
for rows.Next() {
|
||||||
|
var timestamp int
|
||||||
|
if err := rows.Scan(×tamp, &totalNodes); err != nil {
|
||||||
|
rows.Close()
|
||||||
|
cclog.Warnf("Error while scanning rows (CountNodes) at time '%d'", timestamp)
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return totalNodes, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (r *NodeRepository) ListNodes(cluster string) ([]*schema.Node, error) {
|
func (r *NodeRepository) ListNodes(cluster string) ([]*schema.Node, error) {
|
||||||
q := sq.Select("node.hostname", "node.cluster", "node.subcluster", "node_state.node_state",
|
q := sq.Select("node.hostname", "node.cluster", "node.subcluster", "node_state.node_state",
|
||||||
"node_state.health_state", "MAX(node_state.time_stamp) as time").
|
"node_state.health_state", "MAX(node_state.time_stamp) as time").
|
||||||
@@ -345,9 +382,10 @@ func (r *NodeRepository) ListNodes(cluster string) ([]*schema.Node, error) {
|
|||||||
defer rows.Close()
|
defer rows.Close()
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
node := &schema.Node{}
|
node := &schema.Node{}
|
||||||
|
var timestamp int
|
||||||
if err := rows.Scan(&node.Hostname, &node.Cluster,
|
if err := rows.Scan(&node.Hostname, &node.Cluster,
|
||||||
&node.SubCluster, &node.NodeState, &node.HealthState); err != nil {
|
&node.SubCluster, &node.NodeState, &node.HealthState, ×tamp); err != nil {
|
||||||
cclog.Warn("Error while scanning node list")
|
cclog.Warnf("Error while scanning node list (ListNodes) at time '%d'", timestamp)
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -357,8 +395,38 @@ func (r *NodeRepository) ListNodes(cluster string) ([]*schema.Node, error) {
|
|||||||
return nodeList, nil
|
return nodeList, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *NodeRepository) CountNodeStates(ctx context.Context, filters []*model.NodeFilter) ([]*model.NodeStates, error) {
|
func (r *NodeRepository) MapNodes(cluster string) (map[string]string, error) {
|
||||||
query, qerr := AccessCheck(ctx, sq.Select("hostname", "node_state", "MAX(time_stamp) as time").From("node"))
|
q := sq.Select("node.hostname", "node_state.node_state", "MAX(node_state.time_stamp) as time").
|
||||||
|
From("node").
|
||||||
|
Join("node_state ON node_state.node_id = node.id").
|
||||||
|
Where("node.cluster = ?", cluster).
|
||||||
|
GroupBy("node_state.node_id").
|
||||||
|
OrderBy("node.hostname ASC")
|
||||||
|
|
||||||
|
rows, err := q.RunWith(r.DB).Query()
|
||||||
|
if err != nil {
|
||||||
|
cclog.Warn("Error while querying node list")
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
stateMap := make(map[string]string)
|
||||||
|
defer rows.Close()
|
||||||
|
for rows.Next() {
|
||||||
|
var hostname, nodestate string
|
||||||
|
var timestamp int
|
||||||
|
if err := rows.Scan(&hostname, &nodestate, ×tamp); err != nil {
|
||||||
|
cclog.Warnf("Error while scanning node list (MapNodes) at time '%d'", timestamp)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
stateMap[hostname] = nodestate
|
||||||
|
}
|
||||||
|
|
||||||
|
return stateMap, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *NodeRepository) CountStates(ctx context.Context, filters []*model.NodeFilter, column string) ([]*model.NodeStates, error) {
|
||||||
|
query, qerr := AccessCheck(ctx, sq.Select("hostname", column, "MAX(time_stamp) as time").From("node"))
|
||||||
if qerr != nil {
|
if qerr != nil {
|
||||||
return nil, qerr
|
return nil, qerr
|
||||||
}
|
}
|
||||||
@@ -395,16 +463,16 @@ func (r *NodeRepository) CountNodeStates(ctx context.Context, filters []*model.N
|
|||||||
|
|
||||||
stateMap := map[string]int{}
|
stateMap := map[string]int{}
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var hostname, node_state string
|
var hostname, state string
|
||||||
var timestamp int64
|
var timestamp int
|
||||||
|
|
||||||
if err := rows.Scan(&hostname, &node_state, ×tamp); err != nil {
|
if err := rows.Scan(&hostname, &state, ×tamp); err != nil {
|
||||||
rows.Close()
|
rows.Close()
|
||||||
cclog.Warn("Error while scanning rows (NodeStates)")
|
cclog.Warnf("Error while scanning rows (CountStates) at time '%d'", timestamp)
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
stateMap[node_state] += 1
|
stateMap[state] += 1
|
||||||
}
|
}
|
||||||
|
|
||||||
nodes := make([]*model.NodeStates, 0)
|
nodes := make([]*model.NodeStates, 0)
|
||||||
@@ -416,8 +484,8 @@ func (r *NodeRepository) CountNodeStates(ctx context.Context, filters []*model.N
|
|||||||
return nodes, nil
|
return nodes, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *NodeRepository) CountHealthStates(ctx context.Context, filters []*model.NodeFilter) ([]*model.NodeStates, error) {
|
func (r *NodeRepository) CountStatesTimed(ctx context.Context, filters []*model.NodeFilter, column string) ([]*model.NodeStatesTimed, error) {
|
||||||
query, qerr := AccessCheck(ctx, sq.Select("hostname", "health_state", "MAX(time_stamp) as time").From("node"))
|
query, qerr := AccessCheck(ctx, sq.Select(column, "time_stamp", "count(*) as count").From("node")) // "cluster"?
|
||||||
if qerr != nil {
|
if qerr != nil {
|
||||||
return nil, qerr
|
return nil, qerr
|
||||||
}
|
}
|
||||||
@@ -425,6 +493,11 @@ func (r *NodeRepository) CountHealthStates(ctx context.Context, filters []*model
|
|||||||
query = query.Join("node_state ON node_state.node_id = node.id")
|
query = query.Join("node_state ON node_state.node_id = node.id")
|
||||||
|
|
||||||
for _, f := range filters {
|
for _, f := range filters {
|
||||||
|
// Required
|
||||||
|
if f.TimeStart != nil {
|
||||||
|
query = query.Where("time_stamp > ?", f.TimeStart)
|
||||||
|
}
|
||||||
|
// Optional
|
||||||
if f.Hostname != nil {
|
if f.Hostname != nil {
|
||||||
query = buildStringCondition("hostname", f.Hostname, query)
|
query = buildStringCondition("hostname", f.Hostname, query)
|
||||||
}
|
}
|
||||||
@@ -443,7 +516,7 @@ func (r *NodeRepository) CountHealthStates(ctx context.Context, filters []*model
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Add Group and Order
|
// Add Group and Order
|
||||||
query = query.GroupBy("hostname").OrderBy("hostname DESC")
|
query = query.GroupBy(column + ", time_stamp").OrderBy("time_stamp ASC")
|
||||||
|
|
||||||
rows, err := query.RunWith(r.stmtCache).Query()
|
rows, err := query.RunWith(r.stmtCache).Query()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -452,27 +525,32 @@ func (r *NodeRepository) CountHealthStates(ctx context.Context, filters []*model
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
stateMap := map[string]int{}
|
rawData := make(map[string][][]int)
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var hostname, health_state string
|
var state string
|
||||||
var timestamp int64
|
var timestamp, count int
|
||||||
|
|
||||||
if err := rows.Scan(&hostname, &health_state, ×tamp); err != nil {
|
if err := rows.Scan(&state, ×tamp, &count); err != nil {
|
||||||
rows.Close()
|
rows.Close()
|
||||||
cclog.Warn("Error while scanning rows (NodeStates)")
|
cclog.Warnf("Error while scanning rows (CountStatesTimed) at time '%d'", timestamp)
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
stateMap[health_state] += 1
|
if rawData[state] == nil {
|
||||||
|
rawData[state] = [][]int{make([]int, 0), make([]int, 0)}
|
||||||
|
}
|
||||||
|
|
||||||
|
rawData[state][0] = append(rawData[state][0], timestamp)
|
||||||
|
rawData[state][1] = append(rawData[state][1], count)
|
||||||
}
|
}
|
||||||
|
|
||||||
nodes := make([]*model.NodeStates, 0)
|
timedStates := make([]*model.NodeStatesTimed, 0)
|
||||||
for state, counts := range stateMap {
|
for state, data := range rawData {
|
||||||
node := model.NodeStates{State: state, Count: counts}
|
entry := model.NodeStatesTimed{State: state, Times: data[0], Counts: data[1]}
|
||||||
nodes = append(nodes, &node)
|
timedStates = append(timedStates, &entry)
|
||||||
}
|
}
|
||||||
|
|
||||||
return nodes, nil
|
return timedStates, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func AccessCheck(ctx context.Context, query sq.SelectBuilder) (sq.SelectBuilder, error) {
|
func AccessCheck(ctx context.Context, query sq.SelectBuilder) (sq.SelectBuilder, error) {
|
||||||
|
|||||||
@@ -115,7 +115,7 @@ func nodeTestSetup(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if err := os.WriteFile(filepath.Join(jobarchive, "version.txt"),
|
if err := os.WriteFile(filepath.Join(jobarchive, "version.txt"),
|
||||||
fmt.Appendf(nil, "%d", 2), 0o666); err != nil {
|
fmt.Appendf(nil, "%d", 3), 0o666); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -114,16 +114,6 @@ func (r *JobRepository) buildStatsQuery(
|
|||||||
return query
|
return query
|
||||||
}
|
}
|
||||||
|
|
||||||
// func (r *JobRepository) getUserName(ctx context.Context, id string) string {
|
|
||||||
// user := GetUserFromContext(ctx)
|
|
||||||
// name, _ := r.FindColumnValue(user, id, "hpc_user", "name", "username", false)
|
|
||||||
// if name != "" {
|
|
||||||
// return name
|
|
||||||
// } else {
|
|
||||||
// return "-"
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
|
|
||||||
func (r *JobRepository) getCastType() string {
|
func (r *JobRepository) getCastType() string {
|
||||||
var castType string
|
var castType string
|
||||||
|
|
||||||
|
|||||||
+58
-52
@@ -5,6 +5,7 @@
|
|||||||
package repository
|
package repository
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
@@ -14,65 +15,32 @@ import (
|
|||||||
sq "github.com/Masterminds/squirrel"
|
sq "github.com/Masterminds/squirrel"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Add the tag with id `tagId` to the job with the database id `jobId`.
|
// AddTag adds the tag with id `tagId` to the job with the database id `jobId`.
|
||||||
|
// Requires user authentication for security checks.
|
||||||
func (r *JobRepository) AddTag(user *schema.User, job int64, tag int64) ([]*schema.Tag, error) {
|
func (r *JobRepository) AddTag(user *schema.User, job int64, tag int64) ([]*schema.Tag, error) {
|
||||||
j, err := r.FindByIdWithUser(user, job)
|
j, err := r.FindByIdWithUser(user, job)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
cclog.Warn("Error while finding job by id")
|
cclog.Warnf("Error finding job %d for user %s: %v", job, user.Username, err)
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
q := sq.Insert("jobtag").Columns("job_id", "tag_id").Values(job, tag)
|
return r.addJobTag(job, tag, j, func() ([]*schema.Tag, error) {
|
||||||
|
return r.GetTags(user, &job)
|
||||||
if _, err := q.RunWith(r.stmtCache).Exec(); err != nil {
|
})
|
||||||
s, _, _ := q.ToSql()
|
|
||||||
cclog.Errorf("Error adding tag with %s: %v", s, err)
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
tags, err := r.GetTags(user, &job)
|
|
||||||
if err != nil {
|
|
||||||
cclog.Warn("Error while getting tags for job")
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
archiveTags, err := r.getArchiveTags(&job)
|
|
||||||
if err != nil {
|
|
||||||
cclog.Warn("Error while getting tags for job")
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return tags, archive.UpdateTags(j, archiveTags)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// AddTagDirect adds a tag without user security checks.
|
||||||
|
// Use only for internal/admin operations.
|
||||||
func (r *JobRepository) AddTagDirect(job int64, tag int64) ([]*schema.Tag, error) {
|
func (r *JobRepository) AddTagDirect(job int64, tag int64) ([]*schema.Tag, error) {
|
||||||
j, err := r.FindByIdDirect(job)
|
j, err := r.FindByIdDirect(job)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
cclog.Warn("Error while finding job by id")
|
cclog.Warnf("Error finding job %d: %v", job, err)
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
q := sq.Insert("jobtag").Columns("job_id", "tag_id").Values(job, tag)
|
return r.addJobTag(job, tag, j, func() ([]*schema.Tag, error) {
|
||||||
|
return r.GetTagsDirect(&job)
|
||||||
if _, err := q.RunWith(r.stmtCache).Exec(); err != nil {
|
})
|
||||||
s, _, _ := q.ToSql()
|
|
||||||
cclog.Errorf("Error adding tag with %s: %v", s, err)
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
tags, err := r.GetTagsDirect(&job)
|
|
||||||
if err != nil {
|
|
||||||
cclog.Warn("Error while getting tags for job")
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
archiveTags, err := r.getArchiveTags(&job)
|
|
||||||
if err != nil {
|
|
||||||
cclog.Warn("Error while getting tags for job")
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return tags, archive.UpdateTags(j, archiveTags)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Removes a tag from a job by tag id.
|
// Removes a tag from a job by tag id.
|
||||||
@@ -260,15 +228,18 @@ func (r *JobRepository) CountTags(user *schema.User) (tags []schema.Tag, counts
|
|||||||
LeftJoin("jobtag jt ON t.id = jt.tag_id").
|
LeftJoin("jobtag jt ON t.id = jt.tag_id").
|
||||||
GroupBy("t.tag_name")
|
GroupBy("t.tag_name")
|
||||||
|
|
||||||
// Handle Scope Filtering
|
// Build scope list for filtering
|
||||||
scopeList := "\"global\""
|
var scopeBuilder strings.Builder
|
||||||
|
scopeBuilder.WriteString(`"global"`)
|
||||||
if user != nil {
|
if user != nil {
|
||||||
scopeList += ",\"" + user.Username + "\""
|
scopeBuilder.WriteString(`,"`)
|
||||||
|
scopeBuilder.WriteString(user.Username)
|
||||||
|
scopeBuilder.WriteString(`"`)
|
||||||
|
if user.HasAnyRole([]schema.Role{schema.RoleAdmin, schema.RoleSupport}) {
|
||||||
|
scopeBuilder.WriteString(`,"admin"`)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if user.HasAnyRole([]schema.Role{schema.RoleAdmin, schema.RoleSupport}) {
|
q = q.Where("t.tag_scope IN (" + scopeBuilder.String() + ")")
|
||||||
scopeList += ",\"admin\""
|
|
||||||
}
|
|
||||||
q = q.Where("t.tag_scope IN (" + scopeList + ")")
|
|
||||||
|
|
||||||
// Handle Job Ownership
|
// Handle Job Ownership
|
||||||
if user != nil && user.HasAnyRole([]schema.Role{schema.RoleAdmin, schema.RoleSupport}) { // ADMIN || SUPPORT: Count all jobs
|
if user != nil && user.HasAnyRole([]schema.Role{schema.RoleAdmin, schema.RoleSupport}) { // ADMIN || SUPPORT: Count all jobs
|
||||||
@@ -302,6 +273,41 @@ func (r *JobRepository) CountTags(user *schema.User) (tags []schema.Tag, counts
|
|||||||
return tags, counts, err
|
return tags, counts, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrTagNotFound = errors.New("the tag does not exist")
|
||||||
|
ErrJobNotOwned = errors.New("user is not owner of job")
|
||||||
|
ErrTagNoAccess = errors.New("user not permitted to use that tag")
|
||||||
|
ErrTagPrivateScope = errors.New("tag is private to another user")
|
||||||
|
ErrTagAdminScope = errors.New("tag requires admin privileges")
|
||||||
|
ErrTagsIncompatScopes = errors.New("combining admin and non-admin scoped tags not allowed")
|
||||||
|
)
|
||||||
|
|
||||||
|
// addJobTag is a helper function that inserts a job-tag association and updates the archive.
|
||||||
|
// Returns the updated tag list for the job.
|
||||||
|
func (r *JobRepository) addJobTag(jobId int64, tagId int64, job *schema.Job, getTags func() ([]*schema.Tag, error)) ([]*schema.Tag, error) {
|
||||||
|
q := sq.Insert("jobtag").Columns("job_id", "tag_id").Values(jobId, tagId)
|
||||||
|
|
||||||
|
if _, err := q.RunWith(r.stmtCache).Exec(); err != nil {
|
||||||
|
s, _, _ := q.ToSql()
|
||||||
|
cclog.Errorf("Error adding tag with %s: %v", s, err)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
tags, err := getTags()
|
||||||
|
if err != nil {
|
||||||
|
cclog.Warnf("Error getting tags for job %d: %v", jobId, err)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
archiveTags, err := r.getArchiveTags(&jobId)
|
||||||
|
if err != nil {
|
||||||
|
cclog.Warnf("Error getting archive tags for job %d: %v", jobId, err)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return tags, archive.UpdateTags(job, archiveTags)
|
||||||
|
}
|
||||||
|
|
||||||
// AddTagOrCreate adds the tag with the specified type and name to the job with the database id `jobId`.
|
// AddTagOrCreate adds the tag with the specified type and name to the job with the database id `jobId`.
|
||||||
// If such a tag does not yet exist, it is created.
|
// If such a tag does not yet exist, it is created.
|
||||||
func (r *JobRepository) AddTagOrCreate(user *schema.User, jobId int64, tagType string, tagName string, tagScope string) (tagId int64, err error) {
|
func (r *JobRepository) AddTagOrCreate(user *schema.User, jobId int64, tagType string, tagName string, tagScope string) (tagId int64, err error) {
|
||||||
|
|||||||
@@ -5,84 +5,96 @@
|
|||||||
package repository
|
package repository
|
||||||
|
|
||||||
import (
|
import (
|
||||||
cclog "github.com/ClusterCockpit/cc-lib/ccLogger"
|
"fmt"
|
||||||
|
|
||||||
"github.com/jmoiron/sqlx"
|
"github.com/jmoiron/sqlx"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Transaction wraps a database transaction for job-related operations.
|
||||||
type Transaction struct {
|
type Transaction struct {
|
||||||
tx *sqlx.Tx
|
tx *sqlx.Tx
|
||||||
stmt *sqlx.NamedStmt
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TransactionInit begins a new transaction.
|
||||||
func (r *JobRepository) TransactionInit() (*Transaction, error) {
|
func (r *JobRepository) TransactionInit() (*Transaction, error) {
|
||||||
var err error
|
tx, err := r.DB.Beginx()
|
||||||
t := new(Transaction)
|
|
||||||
|
|
||||||
t.tx, err = r.DB.Beginx()
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
cclog.Warn("Error while bundling transactions")
|
return nil, fmt.Errorf("beginning transaction: %w", err)
|
||||||
return nil, err
|
|
||||||
}
|
}
|
||||||
return t, nil
|
return &Transaction{tx: tx}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *JobRepository) TransactionCommit(t *Transaction) error {
|
// Commit commits the transaction.
|
||||||
var err error
|
// After calling Commit, the transaction should not be used again.
|
||||||
if t.tx != nil {
|
func (t *Transaction) Commit() error {
|
||||||
if err = t.tx.Commit(); err != nil {
|
if t.tx == nil {
|
||||||
cclog.Warn("Error while committing transactions")
|
return fmt.Errorf("transaction already committed or rolled back")
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
err := t.tx.Commit()
|
||||||
t.tx, err = r.DB.Beginx()
|
t.tx = nil // Mark as completed
|
||||||
if err != nil {
|
if err != nil {
|
||||||
cclog.Warn("Error while bundling transactions")
|
return fmt.Errorf("committing transaction: %w", err)
|
||||||
return err
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Rollback rolls back the transaction.
|
||||||
|
// It's safe to call Rollback on an already committed or rolled back transaction.
|
||||||
|
func (t *Transaction) Rollback() error {
|
||||||
|
if t.tx == nil {
|
||||||
|
return nil // Already committed/rolled back
|
||||||
|
}
|
||||||
|
err := t.tx.Rollback()
|
||||||
|
t.tx = nil // Mark as completed
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("rolling back transaction: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// TransactionEnd commits the transaction.
|
||||||
|
// Deprecated: Use Commit() instead.
|
||||||
func (r *JobRepository) TransactionEnd(t *Transaction) error {
|
func (r *JobRepository) TransactionEnd(t *Transaction) error {
|
||||||
if err := t.tx.Commit(); err != nil {
|
return t.Commit()
|
||||||
cclog.Warn("Error while committing SQL transactions")
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TransactionAddNamed executes a named query within the transaction.
|
||||||
func (r *JobRepository) TransactionAddNamed(
|
func (r *JobRepository) TransactionAddNamed(
|
||||||
t *Transaction,
|
t *Transaction,
|
||||||
query string,
|
query string,
|
||||||
args ...interface{},
|
args ...interface{},
|
||||||
) (int64, error) {
|
) (int64, error) {
|
||||||
|
if t.tx == nil {
|
||||||
|
return 0, fmt.Errorf("transaction is nil or already completed")
|
||||||
|
}
|
||||||
|
|
||||||
res, err := t.tx.NamedExec(query, args)
|
res, err := t.tx.NamedExec(query, args)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
cclog.Errorf("Named Exec failed: %v", err)
|
return 0, fmt.Errorf("named exec: %w", err)
|
||||||
return 0, err
|
|
||||||
}
|
}
|
||||||
|
|
||||||
id, err := res.LastInsertId()
|
id, err := res.LastInsertId()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
cclog.Errorf("repository initDB(): %v", err)
|
return 0, fmt.Errorf("getting last insert id: %w", err)
|
||||||
return 0, err
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return id, nil
|
return id, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TransactionAdd executes a query within the transaction.
|
||||||
func (r *JobRepository) TransactionAdd(t *Transaction, query string, args ...interface{}) (int64, error) {
|
func (r *JobRepository) TransactionAdd(t *Transaction, query string, args ...interface{}) (int64, error) {
|
||||||
|
if t.tx == nil {
|
||||||
|
return 0, fmt.Errorf("transaction is nil or already completed")
|
||||||
|
}
|
||||||
|
|
||||||
res, err := t.tx.Exec(query, args...)
|
res, err := t.tx.Exec(query, args...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
cclog.Errorf("TransactionAdd(), Exec() Error: %v", err)
|
return 0, fmt.Errorf("exec: %w", err)
|
||||||
return 0, err
|
|
||||||
}
|
}
|
||||||
|
|
||||||
id, err := res.LastInsertId()
|
id, err := res.LastInsertId()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
cclog.Errorf("TransactionAdd(), LastInsertId() Error: %v", err)
|
return 0, fmt.Errorf("getting last insert id: %w", err)
|
||||||
return 0, err
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return id, nil
|
return id, nil
|
||||||
|
|||||||
@@ -24,10 +24,14 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
//go:embed jobclasses/*
|
//go:embed jobclasses/*
|
||||||
var jobclassFiles embed.FS
|
var jobClassFiles embed.FS
|
||||||
|
|
||||||
|
// Variable defines a named expression that can be computed and reused in rules.
|
||||||
|
// Variables are evaluated before the main rule and their results are added to the environment.
|
||||||
type Variable struct {
|
type Variable struct {
|
||||||
|
// Name is the variable identifier used in rule expressions
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
|
// Expr is the expression to evaluate (must return a numeric value)
|
||||||
Expr string `json:"expr"`
|
Expr string `json:"expr"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -36,14 +40,25 @@ type ruleVariable struct {
|
|||||||
expr *vm.Program
|
expr *vm.Program
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RuleFormat defines the JSON structure for job classification rules.
|
||||||
|
// Each rule specifies requirements, metrics to analyze, variables to compute,
|
||||||
|
// and the final rule expression that determines if the job matches the classification.
|
||||||
type RuleFormat struct {
|
type RuleFormat struct {
|
||||||
|
// Name is a human-readable description of the rule
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
|
// Tag is the classification tag to apply if the rule matches
|
||||||
Tag string `json:"tag"`
|
Tag string `json:"tag"`
|
||||||
|
// Parameters are shared values referenced in the rule (e.g., thresholds)
|
||||||
Parameters []string `json:"parameters"`
|
Parameters []string `json:"parameters"`
|
||||||
|
// Metrics are the job metrics required for this rule (e.g., "cpu_load", "mem_used")
|
||||||
Metrics []string `json:"metrics"`
|
Metrics []string `json:"metrics"`
|
||||||
|
// Requirements are boolean expressions that must be true for the rule to apply
|
||||||
Requirements []string `json:"requirements"`
|
Requirements []string `json:"requirements"`
|
||||||
|
// Variables are computed values used in the rule expression
|
||||||
Variables []Variable `json:"variables"`
|
Variables []Variable `json:"variables"`
|
||||||
|
// Rule is the boolean expression that determines if the job matches
|
||||||
Rule string `json:"rule"`
|
Rule string `json:"rule"`
|
||||||
|
// Hint is a template string that generates a message when the rule matches
|
||||||
Hint string `json:"hint"`
|
Hint string `json:"hint"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -56,11 +71,35 @@ type ruleInfo struct {
|
|||||||
hint *template.Template
|
hint *template.Template
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// JobRepository defines the interface for job database operations needed by the tagger.
|
||||||
|
// This interface allows for easier testing and decoupling from the concrete repository implementation.
|
||||||
|
type JobRepository interface {
|
||||||
|
// HasTag checks if a job already has a specific tag
|
||||||
|
HasTag(jobId int64, tagType string, tagName string) bool
|
||||||
|
// AddTagOrCreateDirect adds a tag to a job or creates it if it doesn't exist
|
||||||
|
AddTagOrCreateDirect(jobId int64, tagType string, tagName string) (tagId int64, err error)
|
||||||
|
// UpdateMetadata updates job metadata with a key-value pair
|
||||||
|
UpdateMetadata(job *schema.Job, key, val string) (err error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// JobClassTagger classifies jobs based on configurable rules that evaluate job metrics and properties.
|
||||||
|
// Rules are loaded from embedded JSON files and can be dynamically reloaded from a watched directory.
|
||||||
|
// When a job matches a rule, it is tagged with the corresponding classification and an optional hint message.
|
||||||
type JobClassTagger struct {
|
type JobClassTagger struct {
|
||||||
rules map[string]ruleInfo
|
// rules maps classification tags to their compiled rule information
|
||||||
parameters map[string]any
|
rules map[string]ruleInfo
|
||||||
tagType string
|
// parameters are shared values (e.g., thresholds) used across multiple rules
|
||||||
cfgPath string
|
parameters map[string]any
|
||||||
|
// tagType is the type of tag ("jobClass")
|
||||||
|
tagType string
|
||||||
|
// cfgPath is the path to watch for configuration changes
|
||||||
|
cfgPath string
|
||||||
|
// repo provides access to job database operations
|
||||||
|
repo JobRepository
|
||||||
|
// getStatistics retrieves job statistics for analysis
|
||||||
|
getStatistics func(job *schema.Job) (map[string]schema.JobStatistics, error)
|
||||||
|
// getMetricConfig retrieves metric configuration (limits) for a cluster
|
||||||
|
getMetricConfig func(cluster, subCluster string) map[string]*schema.Metric
|
||||||
}
|
}
|
||||||
|
|
||||||
func (t *JobClassTagger) prepareRule(b []byte, fns string) {
|
func (t *JobClassTagger) prepareRule(b []byte, fns string) {
|
||||||
@@ -127,10 +166,14 @@ func (t *JobClassTagger) prepareRule(b []byte, fns string) {
|
|||||||
t.rules[rule.Tag] = ri
|
t.rules[rule.Tag] = ri
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// EventMatch checks if a filesystem event should trigger configuration reload.
|
||||||
|
// It returns true if the event path contains "jobclasses".
|
||||||
func (t *JobClassTagger) EventMatch(s string) bool {
|
func (t *JobClassTagger) EventMatch(s string) bool {
|
||||||
return strings.Contains(s, "jobclasses")
|
return strings.Contains(s, "jobclasses")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// EventCallback is triggered when the configuration directory changes.
|
||||||
|
// It reloads parameters and all rule files from the watched directory.
|
||||||
// FIXME: Only process the file that caused the event
|
// FIXME: Only process the file that caused the event
|
||||||
func (t *JobClassTagger) EventCallback() {
|
func (t *JobClassTagger) EventCallback() {
|
||||||
files, err := os.ReadDir(t.cfgPath)
|
files, err := os.ReadDir(t.cfgPath)
|
||||||
@@ -170,7 +213,7 @@ func (t *JobClassTagger) EventCallback() {
|
|||||||
|
|
||||||
func (t *JobClassTagger) initParameters() error {
|
func (t *JobClassTagger) initParameters() error {
|
||||||
cclog.Info("Initialize parameters")
|
cclog.Info("Initialize parameters")
|
||||||
b, err := jobclassFiles.ReadFile("jobclasses/parameters.json")
|
b, err := jobClassFiles.ReadFile("jobclasses/parameters.json")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
cclog.Warnf("prepareRule() > open file error: %v", err)
|
cclog.Warnf("prepareRule() > open file error: %v", err)
|
||||||
return err
|
return err
|
||||||
@@ -184,6 +227,10 @@ func (t *JobClassTagger) initParameters() error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Register initializes the JobClassTagger by loading parameters and classification rules.
|
||||||
|
// It loads embedded configuration files and sets up a file watch on ./var/tagger/jobclasses
|
||||||
|
// if it exists, allowing for dynamic configuration updates without restarting the application.
|
||||||
|
// Returns an error if the embedded configuration files cannot be read or parsed.
|
||||||
func (t *JobClassTagger) Register() error {
|
func (t *JobClassTagger) Register() error {
|
||||||
t.cfgPath = "./var/tagger/jobclasses"
|
t.cfgPath = "./var/tagger/jobclasses"
|
||||||
t.tagType = "jobClass"
|
t.tagType = "jobClass"
|
||||||
@@ -194,18 +241,18 @@ func (t *JobClassTagger) Register() error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
files, err := jobclassFiles.ReadDir("jobclasses")
|
files, err := jobClassFiles.ReadDir("jobclasses")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("error reading app folder: %#v", err)
|
return fmt.Errorf("error reading app folder: %#v", err)
|
||||||
}
|
}
|
||||||
t.rules = make(map[string]ruleInfo, 0)
|
t.rules = make(map[string]ruleInfo)
|
||||||
for _, fn := range files {
|
for _, fn := range files {
|
||||||
fns := fn.Name()
|
fns := fn.Name()
|
||||||
if fns != "parameters.json" {
|
if fns != "parameters.json" {
|
||||||
filename := fmt.Sprintf("jobclasses/%s", fns)
|
filename := fmt.Sprintf("jobclasses/%s", fns)
|
||||||
cclog.Infof("Process: %s", fns)
|
cclog.Infof("Process: %s", fns)
|
||||||
|
|
||||||
b, err := jobclassFiles.ReadFile(filename)
|
b, err := jobClassFiles.ReadFile(filename)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
cclog.Warnf("prepareRule() > open file error: %v", err)
|
cclog.Warnf("prepareRule() > open file error: %v", err)
|
||||||
return err
|
return err
|
||||||
@@ -220,13 +267,30 @@ func (t *JobClassTagger) Register() error {
|
|||||||
util.AddListener(t.cfgPath, t)
|
util.AddListener(t.cfgPath, t)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
t.repo = repository.GetJobRepository()
|
||||||
|
t.getStatistics = archive.GetStatistics
|
||||||
|
t.getMetricConfig = archive.GetMetricConfigSubCluster
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Match evaluates all classification rules against a job and applies matching tags.
|
||||||
|
// It retrieves job statistics and metric configurations, then tests each rule's requirements
|
||||||
|
// and main expression. For each matching rule, it:
|
||||||
|
// - Applies the classification tag to the job
|
||||||
|
// - Generates and stores a hint message based on the rule's template
|
||||||
|
//
|
||||||
|
// The function constructs an evaluation environment containing:
|
||||||
|
// - Job properties (duration, cores, nodes, state, etc.)
|
||||||
|
// - Metric statistics (min, max, avg) and their configured limits
|
||||||
|
// - Shared parameters defined in parameters.json
|
||||||
|
// - Computed variables from the rule definition
|
||||||
|
//
|
||||||
|
// Rules are evaluated in arbitrary order. If multiple rules match, only the first
|
||||||
|
// encountered match is applied (FIXME: this should handle multiple matches).
|
||||||
func (t *JobClassTagger) Match(job *schema.Job) {
|
func (t *JobClassTagger) Match(job *schema.Job) {
|
||||||
r := repository.GetJobRepository()
|
jobStats, err := t.getStatistics(job)
|
||||||
jobstats, err := archive.GetStatistics(job)
|
metricsList := t.getMetricConfig(job.Cluster, job.SubCluster)
|
||||||
metricsList := archive.GetMetricConfigSubCluster(job.Cluster, job.SubCluster)
|
|
||||||
cclog.Infof("Enter match rule with %d rules for job %d", len(t.rules), job.JobID)
|
cclog.Infof("Enter match rule with %d rules for job %d", len(t.rules), job.JobID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
cclog.Errorf("job classification failed for job %d: %#v", job.JobID, err)
|
cclog.Errorf("job classification failed for job %d: %#v", job.JobID, err)
|
||||||
@@ -251,7 +315,7 @@ func (t *JobClassTagger) Match(job *schema.Job) {
|
|||||||
|
|
||||||
// add metrics to env
|
// add metrics to env
|
||||||
for _, m := range ri.metrics {
|
for _, m := range ri.metrics {
|
||||||
stats, ok := jobstats[m]
|
stats, ok := jobStats[m]
|
||||||
if !ok {
|
if !ok {
|
||||||
cclog.Errorf("job classification failed for job %d: missing metric '%s'", job.JobID, m)
|
cclog.Errorf("job classification failed for job %d: missing metric '%s'", job.JobID, m)
|
||||||
return
|
return
|
||||||
@@ -302,8 +366,11 @@ func (t *JobClassTagger) Match(job *schema.Job) {
|
|||||||
if match.(bool) {
|
if match.(bool) {
|
||||||
cclog.Info("Rule matches!")
|
cclog.Info("Rule matches!")
|
||||||
id := *job.ID
|
id := *job.ID
|
||||||
if !r.HasTag(id, t.tagType, tag) {
|
if !t.repo.HasTag(id, t.tagType, tag) {
|
||||||
r.AddTagOrCreateDirect(id, t.tagType, tag)
|
_, err := t.repo.AddTagOrCreateDirect(id, t.tagType, tag)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// process hint template
|
// process hint template
|
||||||
@@ -314,7 +381,11 @@ func (t *JobClassTagger) Match(job *schema.Job) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// FIXME: Handle case where multiple tags apply
|
// FIXME: Handle case where multiple tags apply
|
||||||
r.UpdateMetadata(job, "message", msg.String())
|
// FIXME: Handle case where multiple tags apply
|
||||||
|
err = t.repo.UpdateMetadata(job, "message", msg.String())
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
cclog.Info("Rule does not match!")
|
cclog.Info("Rule does not match!")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,162 @@
|
|||||||
|
package tagger
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/ClusterCockpit/cc-lib/schema"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/mock"
|
||||||
|
)
|
||||||
|
|
||||||
|
// MockJobRepository is a mock implementation of the JobRepository interface
|
||||||
|
type MockJobRepository struct {
|
||||||
|
mock.Mock
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MockJobRepository) HasTag(jobId int64, tagType string, tagName string) bool {
|
||||||
|
args := m.Called(jobId, tagType, tagName)
|
||||||
|
return args.Bool(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MockJobRepository) AddTagOrCreateDirect(jobId int64, tagType string, tagName string) (tagId int64, err error) {
|
||||||
|
args := m.Called(jobId, tagType, tagName)
|
||||||
|
return args.Get(0).(int64), args.Error(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MockJobRepository) UpdateMetadata(job *schema.Job, key, val string) (err error) {
|
||||||
|
args := m.Called(job, key, val)
|
||||||
|
return args.Error(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPrepareRule(t *testing.T) {
|
||||||
|
tagger := &JobClassTagger{
|
||||||
|
rules: make(map[string]ruleInfo),
|
||||||
|
parameters: make(map[string]any),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Valid rule JSON
|
||||||
|
validRule := []byte(`{
|
||||||
|
"name": "Test Rule",
|
||||||
|
"tag": "test_tag",
|
||||||
|
"parameters": [],
|
||||||
|
"metrics": ["flops_any"],
|
||||||
|
"requirements": ["job.numNodes > 1"],
|
||||||
|
"variables": [{"name": "avg_flops", "expr": "flops_any.avg"}],
|
||||||
|
"rule": "avg_flops > 100",
|
||||||
|
"hint": "High FLOPS"
|
||||||
|
}`)
|
||||||
|
|
||||||
|
tagger.prepareRule(validRule, "test_rule.json")
|
||||||
|
|
||||||
|
assert.Contains(t, tagger.rules, "test_tag")
|
||||||
|
rule := tagger.rules["test_tag"]
|
||||||
|
assert.Equal(t, 1, len(rule.metrics))
|
||||||
|
assert.Equal(t, 1, len(rule.requirements))
|
||||||
|
assert.Equal(t, 1, len(rule.variables))
|
||||||
|
assert.NotNil(t, rule.rule)
|
||||||
|
assert.NotNil(t, rule.hint)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClassifyJobMatch(t *testing.T) {
|
||||||
|
mockRepo := new(MockJobRepository)
|
||||||
|
tagger := &JobClassTagger{
|
||||||
|
rules: make(map[string]ruleInfo),
|
||||||
|
parameters: make(map[string]any),
|
||||||
|
tagType: "jobClass",
|
||||||
|
repo: mockRepo,
|
||||||
|
getStatistics: func(job *schema.Job) (map[string]schema.JobStatistics, error) {
|
||||||
|
return map[string]schema.JobStatistics{
|
||||||
|
"flops_any": {Min: 0, Max: 200, Avg: 150},
|
||||||
|
}, nil
|
||||||
|
},
|
||||||
|
getMetricConfig: func(cluster, subCluster string) map[string]*schema.Metric {
|
||||||
|
return map[string]*schema.Metric{
|
||||||
|
"flops_any": {Peak: 1000, Normal: 100, Caution: 50, Alert: 10},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add a rule manually or via prepareRule
|
||||||
|
validRule := []byte(`{
|
||||||
|
"name": "Test Rule",
|
||||||
|
"tag": "high_flops",
|
||||||
|
"parameters": [],
|
||||||
|
"metrics": ["flops_any"],
|
||||||
|
"requirements": [],
|
||||||
|
"variables": [{"name": "avg_flops", "expr": "flops_any.avg"}],
|
||||||
|
"rule": "avg_flops > 100",
|
||||||
|
"hint": "High FLOPS: {{.avg_flops}}"
|
||||||
|
}`)
|
||||||
|
tagger.prepareRule(validRule, "test_rule.json")
|
||||||
|
|
||||||
|
jobID := int64(123)
|
||||||
|
job := &schema.Job{
|
||||||
|
ID: &jobID,
|
||||||
|
JobID: 123,
|
||||||
|
Cluster: "test_cluster",
|
||||||
|
SubCluster: "test_subcluster",
|
||||||
|
NumNodes: 2,
|
||||||
|
NumHWThreads: 4,
|
||||||
|
State: schema.JobStateCompleted,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Expectation: Rule matches
|
||||||
|
// 1. Check if tag exists (return false)
|
||||||
|
mockRepo.On("HasTag", jobID, "jobClass", "high_flops").Return(false)
|
||||||
|
// 2. Add tag
|
||||||
|
mockRepo.On("AddTagOrCreateDirect", jobID, "jobClass", "high_flops").Return(int64(1), nil)
|
||||||
|
// 3. Update metadata
|
||||||
|
mockRepo.On("UpdateMetadata", job, "message", mock.Anything).Return(nil)
|
||||||
|
|
||||||
|
tagger.Match(job)
|
||||||
|
|
||||||
|
mockRepo.AssertExpectations(t)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMatch_NoMatch(t *testing.T) {
|
||||||
|
mockRepo := new(MockJobRepository)
|
||||||
|
tagger := &JobClassTagger{
|
||||||
|
rules: make(map[string]ruleInfo),
|
||||||
|
parameters: make(map[string]any),
|
||||||
|
tagType: "jobClass",
|
||||||
|
repo: mockRepo,
|
||||||
|
getStatistics: func(job *schema.Job) (map[string]schema.JobStatistics, error) {
|
||||||
|
return map[string]schema.JobStatistics{
|
||||||
|
"flops_any": {Min: 0, Max: 50, Avg: 20}, // Avg 20 < 100
|
||||||
|
}, nil
|
||||||
|
},
|
||||||
|
getMetricConfig: func(cluster, subCluster string) map[string]*schema.Metric {
|
||||||
|
return map[string]*schema.Metric{
|
||||||
|
"flops_any": {Peak: 1000, Normal: 100, Caution: 50, Alert: 10},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
validRule := []byte(`{
|
||||||
|
"name": "Test Rule",
|
||||||
|
"tag": "high_flops",
|
||||||
|
"parameters": [],
|
||||||
|
"metrics": ["flops_any"],
|
||||||
|
"requirements": [],
|
||||||
|
"variables": [{"name": "avg_flops", "expr": "flops_any.avg"}],
|
||||||
|
"rule": "avg_flops > 100",
|
||||||
|
"hint": "High FLOPS"
|
||||||
|
}`)
|
||||||
|
tagger.prepareRule(validRule, "test_rule.json")
|
||||||
|
|
||||||
|
jobID := int64(123)
|
||||||
|
job := &schema.Job{
|
||||||
|
ID: &jobID,
|
||||||
|
JobID: 123,
|
||||||
|
Cluster: "test_cluster",
|
||||||
|
SubCluster: "test_subcluster",
|
||||||
|
NumNodes: 2,
|
||||||
|
NumHWThreads: 4,
|
||||||
|
State: schema.JobStateCompleted,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Expectation: Rule does NOT match, so no repo calls
|
||||||
|
tagger.Match(job)
|
||||||
|
|
||||||
|
mockRepo.AssertExpectations(t)
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
// All rights reserved. This file is part of cc-backend.
|
// All rights reserved. This file is part of cc-backend.
|
||||||
// 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.
|
||||||
|
|
||||||
package tagger
|
package tagger
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -28,9 +29,16 @@ type appInfo struct {
|
|||||||
strings []string
|
strings []string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// AppTagger detects applications by matching patterns in job scripts.
|
||||||
|
// It loads application patterns from embedded files and can dynamically reload
|
||||||
|
// configuration from a watched directory. When a job script matches a pattern,
|
||||||
|
// the corresponding application tag is automatically applied.
|
||||||
type AppTagger struct {
|
type AppTagger struct {
|
||||||
|
// apps maps application tags to their matching patterns
|
||||||
apps map[string]appInfo
|
apps map[string]appInfo
|
||||||
|
// tagType is the type of tag ("app")
|
||||||
tagType string
|
tagType string
|
||||||
|
// cfgPath is the path to watch for configuration changes
|
||||||
cfgPath string
|
cfgPath string
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -45,10 +53,14 @@ func (t *AppTagger) scanApp(f fs.File, fns string) {
|
|||||||
t.apps[ai.tag] = ai
|
t.apps[ai.tag] = ai
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// EventMatch checks if a filesystem event should trigger configuration reload.
|
||||||
|
// It returns true if the event path contains "apps".
|
||||||
func (t *AppTagger) EventMatch(s string) bool {
|
func (t *AppTagger) EventMatch(s string) bool {
|
||||||
return strings.Contains(s, "apps")
|
return strings.Contains(s, "apps")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// EventCallback is triggered when the configuration directory changes.
|
||||||
|
// It reloads all application pattern files from the watched directory.
|
||||||
// FIXME: Only process the file that caused the event
|
// FIXME: Only process the file that caused the event
|
||||||
func (t *AppTagger) EventCallback() {
|
func (t *AppTagger) EventCallback() {
|
||||||
files, err := os.ReadDir(t.cfgPath)
|
files, err := os.ReadDir(t.cfgPath)
|
||||||
@@ -67,6 +79,10 @@ func (t *AppTagger) EventCallback() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Register initializes the AppTagger by loading application patterns from embedded files.
|
||||||
|
// It also sets up a file watch on ./var/tagger/apps if it exists, allowing for
|
||||||
|
// dynamic configuration updates without restarting the application.
|
||||||
|
// Returns an error if the embedded application files cannot be read.
|
||||||
func (t *AppTagger) Register() error {
|
func (t *AppTagger) Register() error {
|
||||||
t.cfgPath = "./var/tagger/apps"
|
t.cfgPath = "./var/tagger/apps"
|
||||||
t.tagType = "app"
|
t.tagType = "app"
|
||||||
@@ -96,6 +112,11 @@ func (t *AppTagger) Register() error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Match attempts to detect the application used by a job by analyzing its job script.
|
||||||
|
// It fetches the job metadata, extracts the job script, and matches it against
|
||||||
|
// all configured application patterns using regular expressions.
|
||||||
|
// If a match is found, the corresponding application tag is added to the job.
|
||||||
|
// Only the first matching application is tagged.
|
||||||
func (t *AppTagger) Match(job *schema.Job) {
|
func (t *AppTagger) Match(job *schema.Job) {
|
||||||
r := repository.GetJobRepository()
|
r := repository.GetJobRepository()
|
||||||
metadata, err := r.FetchMetadata(job)
|
metadata, err := r.FetchMetadata(job)
|
||||||
|
|||||||
@@ -2,6 +2,11 @@
|
|||||||
// All rights reserved. This file is part of cc-backend.
|
// All rights reserved. This file is part of cc-backend.
|
||||||
// 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.
|
||||||
|
|
||||||
|
// Package tagger provides automatic job tagging functionality for cc-backend.
|
||||||
|
// It supports detecting applications and classifying jobs based on configurable rules.
|
||||||
|
// Tags are automatically applied when jobs start or stop, or can be applied retroactively
|
||||||
|
// to existing jobs using RunTaggers.
|
||||||
package tagger
|
package tagger
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -12,8 +17,15 @@ import (
|
|||||||
"github.com/ClusterCockpit/cc-lib/schema"
|
"github.com/ClusterCockpit/cc-lib/schema"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Tagger is the interface that must be implemented by all tagging components.
|
||||||
|
// Taggers can be registered at job start or stop events to automatically apply tags.
|
||||||
type Tagger interface {
|
type Tagger interface {
|
||||||
|
// Register initializes the tagger and loads any required configuration.
|
||||||
|
// It should be called once before the tagger is used.
|
||||||
Register() error
|
Register() error
|
||||||
|
|
||||||
|
// Match evaluates the tagger's rules against a job and applies appropriate tags.
|
||||||
|
// It is called for each job that needs to be evaluated.
|
||||||
Match(job *schema.Job)
|
Match(job *schema.Job)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -22,8 +34,12 @@ var (
|
|||||||
jobTagger *JobTagger
|
jobTagger *JobTagger
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// JobTagger coordinates multiple taggers that run at different job lifecycle events.
|
||||||
|
// It maintains separate lists of taggers that run when jobs start and when they stop.
|
||||||
type JobTagger struct {
|
type JobTagger struct {
|
||||||
|
// startTaggers are applied when a job starts (e.g., application detection)
|
||||||
startTaggers []Tagger
|
startTaggers []Tagger
|
||||||
|
// stopTaggers are applied when a job completes (e.g., job classification)
|
||||||
stopTaggers []Tagger
|
stopTaggers []Tagger
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -42,6 +58,9 @@ func newTagger() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Init initializes the job tagger system and registers it with the job repository.
|
||||||
|
// This function is safe to call multiple times; initialization only occurs once.
|
||||||
|
// It should be called during application startup.
|
||||||
func Init() {
|
func Init() {
|
||||||
initOnce.Do(func() {
|
initOnce.Do(func() {
|
||||||
newTagger()
|
newTagger()
|
||||||
@@ -49,22 +68,30 @@ func Init() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// JobStartCallback is called when a job starts.
|
||||||
|
// It runs all registered start taggers (e.g., application detection) on the job.
|
||||||
func (jt *JobTagger) JobStartCallback(job *schema.Job) {
|
func (jt *JobTagger) JobStartCallback(job *schema.Job) {
|
||||||
for _, tagger := range jt.startTaggers {
|
for _, tagger := range jt.startTaggers {
|
||||||
tagger.Match(job)
|
tagger.Match(job)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// JobStopCallback is called when a job completes.
|
||||||
|
// It runs all registered stop taggers (e.g., job classification) on the job.
|
||||||
func (jt *JobTagger) JobStopCallback(job *schema.Job) {
|
func (jt *JobTagger) JobStopCallback(job *schema.Job) {
|
||||||
for _, tagger := range jt.stopTaggers {
|
for _, tagger := range jt.stopTaggers {
|
||||||
tagger.Match(job)
|
tagger.Match(job)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RunTaggers applies all configured taggers to all existing jobs in the repository.
|
||||||
|
// This is useful for retroactively applying tags to jobs that were created before
|
||||||
|
// the tagger system was initialized or when new tagging rules are added.
|
||||||
|
// It fetches all jobs and runs both start and stop taggers on each one.
|
||||||
func RunTaggers() error {
|
func RunTaggers() error {
|
||||||
newTagger()
|
newTagger()
|
||||||
r := repository.GetJobRepository()
|
r := repository.GetJobRepository()
|
||||||
jl, err := r.GetJobList()
|
jl, err := r.GetJobList(0, 0) // 0 limit means get all jobs (no pagination)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
cclog.Errorf("Error while getting job list %s", err)
|
cclog.Errorf("Error while getting job list %s", err)
|
||||||
return err
|
return err
|
||||||
|
|||||||
@@ -12,12 +12,40 @@ var configSchema = `
|
|||||||
"kind": {
|
"kind": {
|
||||||
"description": "Backend type for job-archive",
|
"description": "Backend type for job-archive",
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"enum": ["file", "s3"]
|
"enum": ["file", "s3", "sqlite"]
|
||||||
},
|
},
|
||||||
"path": {
|
"path": {
|
||||||
"description": "Path to job archive for file backend",
|
"description": "Path to job archive for file backend",
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
|
"dbPath": {
|
||||||
|
"description": "Path to SQLite database file for sqlite backend",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"endpoint": {
|
||||||
|
"description": "S3 endpoint URL (for S3-compatible services like MinIO)",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"accessKey": {
|
||||||
|
"description": "S3 access key ID",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"secretKey": {
|
||||||
|
"description": "S3 secret access key",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"bucket": {
|
||||||
|
"description": "S3 bucket name for job archive",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"region": {
|
||||||
|
"description": "AWS region for S3 bucket",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"usePathStyle": {
|
||||||
|
"description": "Use path-style S3 URLs (required for MinIO and some S3-compatible services)",
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
"compression": {
|
"compression": {
|
||||||
"description": "Setup automatic compression for jobs older than number of days",
|
"description": "Setup automatic compression for jobs older than number of days",
|
||||||
"type": "integer"
|
"type": "integer"
|
||||||
|
|||||||
+195
-10
@@ -3,7 +3,79 @@
|
|||||||
// 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.
|
||||||
|
|
||||||
// Package archive implements the job archive interface and various backend implementations
|
// Package archive implements the job archive interface and various backend implementations.
|
||||||
|
//
|
||||||
|
// The archive package provides a pluggable storage backend system for job metadata and performance data.
|
||||||
|
// It supports three backend types:
|
||||||
|
//
|
||||||
|
// - file: Filesystem-based storage with hierarchical directory structure
|
||||||
|
// - s3: AWS S3 and S3-compatible object storage (MinIO, localstack)
|
||||||
|
// - sqlite: Single-file SQLite database with BLOB storage
|
||||||
|
//
|
||||||
|
// # Backend Selection
|
||||||
|
//
|
||||||
|
// Choose a backend based on your deployment requirements:
|
||||||
|
//
|
||||||
|
// - File: Best for single-server deployments with local fast storage
|
||||||
|
// - S3: Best for distributed deployments requiring redundancy and multi-instance access
|
||||||
|
// - SQLite: Best for portable archives with SQL query capability and transactional integrity
|
||||||
|
//
|
||||||
|
// # Configuration
|
||||||
|
//
|
||||||
|
// The archive backend is configured via JSON in the application config file:
|
||||||
|
//
|
||||||
|
// {
|
||||||
|
// "archive": {
|
||||||
|
// "kind": "file", // or "s3" or "sqlite"
|
||||||
|
// "path": "/var/lib/archive" // for file backend
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// For S3 backend:
|
||||||
|
//
|
||||||
|
// {
|
||||||
|
// "archive": {
|
||||||
|
// "kind": "s3",
|
||||||
|
// "bucket": "my-job-archive",
|
||||||
|
// "region": "us-east-1",
|
||||||
|
// "accessKey": "...",
|
||||||
|
// "secretKey": "..."
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// For SQLite backend:
|
||||||
|
//
|
||||||
|
// {
|
||||||
|
// "archive": {
|
||||||
|
// "kind": "sqlite",
|
||||||
|
// "dbPath": "/var/lib/archive.db"
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// # Usage
|
||||||
|
//
|
||||||
|
// The package is initialized once at application startup:
|
||||||
|
//
|
||||||
|
// err := archive.Init(rawConfig, false)
|
||||||
|
// if err != nil {
|
||||||
|
// log.Fatal(err)
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// After initialization, use the global functions to interact with the archive:
|
||||||
|
//
|
||||||
|
// // Check if a job exists
|
||||||
|
// exists := archive.GetHandle().Exists(job)
|
||||||
|
//
|
||||||
|
// // Load job metadata
|
||||||
|
// jobMeta, err := archive.GetHandle().LoadJobMeta(job)
|
||||||
|
//
|
||||||
|
// // Store job metadata
|
||||||
|
// err = archive.GetHandle().StoreJobMeta(job)
|
||||||
|
//
|
||||||
|
// # Thread Safety
|
||||||
|
//
|
||||||
|
// All backend implementations are safe for concurrent use. The package uses
|
||||||
|
// internal locking for operations that modify shared state.
|
||||||
package archive
|
package archive
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -18,45 +90,88 @@ import (
|
|||||||
"github.com/ClusterCockpit/cc-lib/schema"
|
"github.com/ClusterCockpit/cc-lib/schema"
|
||||||
)
|
)
|
||||||
|
|
||||||
const Version uint64 = 2
|
// Version is the current archive schema version.
|
||||||
|
// The archive backend must match this version for compatibility.
|
||||||
|
const Version uint64 = 3
|
||||||
|
|
||||||
|
// ArchiveBackend defines the interface that all archive storage backends must implement.
|
||||||
|
// Implementations include FsArchive (filesystem), S3Archive (object storage), and SqliteArchive (database).
|
||||||
|
//
|
||||||
|
// All methods are safe for concurrent use unless otherwise noted.
|
||||||
type ArchiveBackend interface {
|
type ArchiveBackend interface {
|
||||||
|
// Init initializes the archive backend with the provided configuration.
|
||||||
|
// Returns the archive version found in the backend storage.
|
||||||
|
// Returns an error if the version is incompatible or initialization fails.
|
||||||
Init(rawConfig json.RawMessage) (uint64, error)
|
Init(rawConfig json.RawMessage) (uint64, error)
|
||||||
|
|
||||||
|
// Info prints archive statistics to stdout, including job counts,
|
||||||
|
// date ranges, and storage sizes per cluster.
|
||||||
Info()
|
Info()
|
||||||
|
|
||||||
|
// Exists checks if a job with the given ID, cluster, and start time
|
||||||
|
// exists in the archive.
|
||||||
Exists(job *schema.Job) bool
|
Exists(job *schema.Job) bool
|
||||||
|
|
||||||
|
// LoadJobMeta loads job metadata from the archive.
|
||||||
|
// Returns the complete Job structure including resources, tags, and statistics.
|
||||||
LoadJobMeta(job *schema.Job) (*schema.Job, error)
|
LoadJobMeta(job *schema.Job) (*schema.Job, error)
|
||||||
|
|
||||||
|
// LoadJobData loads the complete time-series performance data for a job.
|
||||||
|
// Returns a map of metric names to their scoped data (node, socket, core, etc.).
|
||||||
LoadJobData(job *schema.Job) (schema.JobData, error)
|
LoadJobData(job *schema.Job) (schema.JobData, error)
|
||||||
|
|
||||||
|
// LoadJobStats loads pre-computed statistics from the job data.
|
||||||
|
// Returns scoped statistics (min, max, avg) for all metrics.
|
||||||
LoadJobStats(job *schema.Job) (schema.ScopedJobStats, error)
|
LoadJobStats(job *schema.Job) (schema.ScopedJobStats, error)
|
||||||
|
|
||||||
|
// LoadClusterCfg loads the cluster configuration.
|
||||||
|
// Returns the cluster topology, metrics, and hardware specifications.
|
||||||
LoadClusterCfg(name string) (*schema.Cluster, error)
|
LoadClusterCfg(name string) (*schema.Cluster, error)
|
||||||
|
|
||||||
|
// StoreJobMeta stores job metadata to the archive.
|
||||||
|
// Overwrites existing metadata for the same job ID, cluster, and start time.
|
||||||
StoreJobMeta(jobMeta *schema.Job) error
|
StoreJobMeta(jobMeta *schema.Job) error
|
||||||
|
|
||||||
|
// ImportJob stores both job metadata and performance data to the archive.
|
||||||
|
// This is typically used during initial job archiving.
|
||||||
ImportJob(jobMeta *schema.Job, jobData *schema.JobData) error
|
ImportJob(jobMeta *schema.Job, jobData *schema.JobData) error
|
||||||
|
|
||||||
|
// GetClusters returns a list of all cluster names found in the archive.
|
||||||
GetClusters() []string
|
GetClusters() []string
|
||||||
|
|
||||||
|
// CleanUp removes the specified jobs from the archive.
|
||||||
|
// Used by retention policies to delete old jobs.
|
||||||
CleanUp(jobs []*schema.Job)
|
CleanUp(jobs []*schema.Job)
|
||||||
|
|
||||||
|
// Move relocates jobs to a different path within the archive.
|
||||||
|
// The implementation depends on the backend type.
|
||||||
Move(jobs []*schema.Job, path string)
|
Move(jobs []*schema.Job, path string)
|
||||||
|
|
||||||
|
// Clean removes jobs outside the specified time range.
|
||||||
|
// Jobs with start_time < before OR start_time > after are deleted.
|
||||||
|
// Set after=0 to only use the before parameter.
|
||||||
Clean(before int64, after int64)
|
Clean(before int64, after int64)
|
||||||
|
|
||||||
|
// Compress compresses job data files to save storage space.
|
||||||
|
// For filesystem and SQLite backends, this applies gzip compression.
|
||||||
|
// For S3, this compresses and replaces objects.
|
||||||
Compress(jobs []*schema.Job)
|
Compress(jobs []*schema.Job)
|
||||||
|
|
||||||
|
// CompressLast returns the timestamp of the last compression run
|
||||||
|
// and updates it to the provided starttime.
|
||||||
CompressLast(starttime int64) int64
|
CompressLast(starttime int64) int64
|
||||||
|
|
||||||
|
// Iter returns a channel that yields all jobs in the archive.
|
||||||
|
// If loadMetricData is true, includes performance data; otherwise only metadata.
|
||||||
|
// The channel is closed when iteration completes.
|
||||||
Iter(loadMetricData bool) <-chan JobContainer
|
Iter(loadMetricData bool) <-chan JobContainer
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// JobContainer combines job metadata and optional performance data.
|
||||||
|
// Used by Iter() to yield jobs during archive iteration.
|
||||||
type JobContainer struct {
|
type JobContainer struct {
|
||||||
Meta *schema.Job
|
Meta *schema.Job // Job metadata (always present)
|
||||||
Data *schema.JobData
|
Data *schema.JobData // Performance data (nil if not loaded)
|
||||||
}
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@@ -67,6 +182,15 @@ var (
|
|||||||
mutex sync.Mutex
|
mutex sync.Mutex
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Init initializes the archive backend with the provided configuration.
|
||||||
|
// Must be called once at application startup before using any archive functions.
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// - rawConfig: JSON configuration for the archive backend
|
||||||
|
// - disableArchive: if true, disables archive functionality
|
||||||
|
//
|
||||||
|
// The configuration determines which backend is used (file, s3, or sqlite).
|
||||||
|
// Returns an error if initialization fails or version is incompatible.
|
||||||
func Init(rawConfig json.RawMessage, disableArchive bool) error {
|
func Init(rawConfig json.RawMessage, disableArchive bool) error {
|
||||||
var err error
|
var err error
|
||||||
|
|
||||||
@@ -86,8 +210,10 @@ func Init(rawConfig json.RawMessage, disableArchive bool) error {
|
|||||||
switch cfg.Kind {
|
switch cfg.Kind {
|
||||||
case "file":
|
case "file":
|
||||||
ar = &FsArchive{}
|
ar = &FsArchive{}
|
||||||
// case "s3":
|
case "s3":
|
||||||
// ar = &S3Archive{}
|
ar = &S3Archive{}
|
||||||
|
case "sqlite":
|
||||||
|
ar = &SqliteArchive{}
|
||||||
default:
|
default:
|
||||||
err = fmt.Errorf("ARCHIVE/ARCHIVE > unkown archive backend '%s''", cfg.Kind)
|
err = fmt.Errorf("ARCHIVE/ARCHIVE > unkown archive backend '%s''", cfg.Kind)
|
||||||
}
|
}
|
||||||
@@ -106,10 +232,59 @@ func Init(rawConfig json.RawMessage, disableArchive bool) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetHandle returns the initialized archive backend instance.
|
||||||
|
// Must be called after Init().
|
||||||
func GetHandle() ArchiveBackend {
|
func GetHandle() ArchiveBackend {
|
||||||
return ar
|
return ar
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// InitBackend creates and initializes a new archive backend instance
|
||||||
|
// without affecting the global singleton. This is useful for archive migration
|
||||||
|
// tools that need to work with multiple archive backends simultaneously.
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// - rawConfig: JSON configuration for the archive backend
|
||||||
|
//
|
||||||
|
// Returns the initialized backend instance or an error if initialization fails.
|
||||||
|
// Does not validate the configuration against the schema.
|
||||||
|
func InitBackend(rawConfig json.RawMessage) (ArchiveBackend, error) {
|
||||||
|
var cfg struct {
|
||||||
|
Kind string `json:"kind"`
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := json.Unmarshal(rawConfig, &cfg); err != nil {
|
||||||
|
cclog.Warn("Error while unmarshaling raw config json")
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var backend ArchiveBackend
|
||||||
|
switch cfg.Kind {
|
||||||
|
case "file":
|
||||||
|
backend = &FsArchive{}
|
||||||
|
case "s3":
|
||||||
|
backend = &S3Archive{}
|
||||||
|
case "sqlite":
|
||||||
|
backend = &SqliteArchive{}
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("ARCHIVE/ARCHIVE > unknown archive backend '%s'", cfg.Kind)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := backend.Init(rawConfig)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("error while initializing archive backend: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return backend, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// LoadAveragesFromArchive loads average metric values for a job from the archive.
|
||||||
|
// This is a helper function that extracts average values from job statistics.
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// - job: Job to load averages for
|
||||||
|
// - metrics: List of metric names to retrieve
|
||||||
|
// - data: 2D slice where averages will be appended (one row per metric)
|
||||||
func LoadAveragesFromArchive(
|
func LoadAveragesFromArchive(
|
||||||
job *schema.Job,
|
job *schema.Job,
|
||||||
metrics []string,
|
metrics []string,
|
||||||
@@ -132,6 +307,8 @@ func LoadAveragesFromArchive(
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// LoadStatsFromArchive loads metric statistics for a job from the archive.
|
||||||
|
// Returns a map of metric names to their statistics (min, max, avg).
|
||||||
func LoadStatsFromArchive(
|
func LoadStatsFromArchive(
|
||||||
job *schema.Job,
|
job *schema.Job,
|
||||||
metrics []string,
|
metrics []string,
|
||||||
@@ -160,6 +337,8 @@ func LoadStatsFromArchive(
|
|||||||
return data, nil
|
return data, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// LoadScopedStatsFromArchive loads scoped statistics for a job from the archive.
|
||||||
|
// Returns statistics organized by metric scope (node, socket, core, etc.).
|
||||||
func LoadScopedStatsFromArchive(
|
func LoadScopedStatsFromArchive(
|
||||||
job *schema.Job,
|
job *schema.Job,
|
||||||
metrics []string,
|
metrics []string,
|
||||||
@@ -174,6 +353,8 @@ func LoadScopedStatsFromArchive(
|
|||||||
return data, nil
|
return data, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetStatistics returns all metric statistics for a job.
|
||||||
|
// Returns a map of metric names to their job-level statistics.
|
||||||
func GetStatistics(job *schema.Job) (map[string]schema.JobStatistics, error) {
|
func GetStatistics(job *schema.Job) (map[string]schema.JobStatistics, error) {
|
||||||
metaFile, err := ar.LoadJobMeta(job)
|
metaFile, err := ar.LoadJobMeta(job)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -184,8 +365,10 @@ func GetStatistics(job *schema.Job) (map[string]schema.JobStatistics, error) {
|
|||||||
return metaFile.Statistics, nil
|
return metaFile.Statistics, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// UpdateMetadata checks if the job is archived, find its `meta.json` file and override the Metadata
|
// UpdateMetadata updates the metadata map for an archived job.
|
||||||
// in that JSON file. If the job is not archived, nothing is done.
|
// If the job is still running or archiving is disabled, this is a no-op.
|
||||||
|
//
|
||||||
|
// This function is safe for concurrent use (protected by mutex).
|
||||||
func UpdateMetadata(job *schema.Job, metadata map[string]string) error {
|
func UpdateMetadata(job *schema.Job, metadata map[string]string) error {
|
||||||
mutex.Lock()
|
mutex.Lock()
|
||||||
defer mutex.Unlock()
|
defer mutex.Unlock()
|
||||||
@@ -205,8 +388,10 @@ func UpdateMetadata(job *schema.Job, metadata map[string]string) error {
|
|||||||
return ar.StoreJobMeta(jobMeta)
|
return ar.StoreJobMeta(jobMeta)
|
||||||
}
|
}
|
||||||
|
|
||||||
// UpdateTags checks if the job is archived, find its `meta.json` file and override the tags list
|
// UpdateTags updates the tag list for an archived job.
|
||||||
// in that JSON file. If the job is not archived, nothing is done.
|
// If the job is still running or archiving is disabled, this is a no-op.
|
||||||
|
//
|
||||||
|
// This function is safe for concurrent use (protected by mutex).
|
||||||
func UpdateTags(job *schema.Job, tags []*schema.Tag) error {
|
func UpdateTags(job *schema.Job, tags []*schema.Tag) error {
|
||||||
mutex.Lock()
|
mutex.Lock()
|
||||||
defer mutex.Unlock()
|
defer mutex.Unlock()
|
||||||
|
|||||||
@@ -28,20 +28,26 @@ import (
|
|||||||
"github.com/santhosh-tekuri/jsonschema/v5"
|
"github.com/santhosh-tekuri/jsonschema/v5"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// FsArchiveConfig holds the configuration for the filesystem archive backend.
|
||||||
type FsArchiveConfig struct {
|
type FsArchiveConfig struct {
|
||||||
Path string `json:"path"`
|
Path string `json:"path"` // Root directory path for the archive
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// FsArchive implements ArchiveBackend using a hierarchical filesystem structure.
|
||||||
|
// Jobs are stored in directories organized by cluster, job ID, and start time.
|
||||||
|
//
|
||||||
|
// Directory structure: <path>/<cluster>/<jobid/1000>/<jobid%1000>/<starttime>/
|
||||||
type FsArchive struct {
|
type FsArchive struct {
|
||||||
path string
|
path string // Root path of the archive
|
||||||
clusters []string
|
clusters []string // List of discovered cluster names
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// clusterInfo holds statistics about jobs in a cluster.
|
||||||
type clusterInfo struct {
|
type clusterInfo struct {
|
||||||
numJobs int
|
numJobs int // Total number of jobs
|
||||||
dateFirst int64
|
dateFirst int64 // Unix timestamp of oldest job
|
||||||
dateLast int64
|
dateLast int64 // Unix timestamp of newest job
|
||||||
diskSize float64
|
diskSize float64 // Total disk usage in MB
|
||||||
}
|
}
|
||||||
|
|
||||||
func getDirectory(
|
func getDirectory(
|
||||||
@@ -509,7 +515,6 @@ func (fsa *FsArchive) Iter(loadMetricData bool) <-chan JobContainer {
|
|||||||
cclog.Errorf("in %s: %s", filepath.Join(dirpath, startTimeDir.Name()), err.Error())
|
cclog.Errorf("in %s: %s", filepath.Join(dirpath, startTimeDir.Name()), err.Error())
|
||||||
}
|
}
|
||||||
ch <- JobContainer{Meta: job, Data: &data}
|
ch <- JobContainer{Meta: job, Data: &data}
|
||||||
cclog.Errorf("in %s: %s", filepath.Join(dirpath, startTimeDir.Name()), err.Error())
|
|
||||||
} else {
|
} else {
|
||||||
ch <- JobContainer{Meta: job, Data: nil}
|
ch <- JobContainer{Meta: job, Data: nil}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ func TestInit(t *testing.T) {
|
|||||||
if fsa.path != "testdata/archive" {
|
if fsa.path != "testdata/archive" {
|
||||||
t.Fail()
|
t.Fail()
|
||||||
}
|
}
|
||||||
if version != 2 {
|
if version != 3 {
|
||||||
t.Fail()
|
t.Fail()
|
||||||
}
|
}
|
||||||
if len(fsa.clusters) != 3 || fsa.clusters[1] != "emmy" {
|
if len(fsa.clusters) != 3 || fsa.clusters[1] != "emmy" {
|
||||||
|
|||||||
+823
-2
@@ -5,10 +5,831 @@
|
|||||||
|
|
||||||
package archive
|
package archive
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"compress/gzip"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"math"
|
||||||
|
"os"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"text/tabwriter"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/ClusterCockpit/cc-backend/internal/config"
|
||||||
|
cclog "github.com/ClusterCockpit/cc-lib/ccLogger"
|
||||||
|
"github.com/ClusterCockpit/cc-lib/schema"
|
||||||
|
"github.com/ClusterCockpit/cc-lib/util"
|
||||||
|
"github.com/aws/aws-sdk-go-v2/aws"
|
||||||
|
awsconfig "github.com/aws/aws-sdk-go-v2/config"
|
||||||
|
"github.com/aws/aws-sdk-go-v2/credentials"
|
||||||
|
"github.com/aws/aws-sdk-go-v2/service/s3"
|
||||||
|
)
|
||||||
|
|
||||||
|
// S3ArchiveConfig holds the configuration for the S3 archive backend.
|
||||||
type S3ArchiveConfig struct {
|
type S3ArchiveConfig struct {
|
||||||
Path string `json:"filePath"`
|
Endpoint string `json:"endpoint"` // S3 endpoint URL (optional, for MinIO/localstack)
|
||||||
|
AccessKey string `json:"accessKey"` // AWS access key ID
|
||||||
|
SecretKey string `json:"secretKey"` // AWS secret access key
|
||||||
|
Bucket string `json:"bucket"` // S3 bucket name
|
||||||
|
Region string `json:"region"` // AWS region
|
||||||
|
UsePathStyle bool `json:"usePathStyle"` // Use path-style URLs (required for MinIO)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// S3Archive implements ArchiveBackend using AWS S3 or S3-compatible object storage.
|
||||||
|
// Jobs are stored as objects with keys mirroring the filesystem structure.
|
||||||
|
//
|
||||||
|
// Object key structure: <cluster>/<jobid/1000>/<jobid%1000>/<starttime>/meta.json
|
||||||
type S3Archive struct {
|
type S3Archive struct {
|
||||||
path string
|
client *s3.Client // AWS S3 client
|
||||||
|
bucket string // S3 bucket name
|
||||||
|
clusters []string // List of discovered cluster names
|
||||||
|
}
|
||||||
|
|
||||||
|
// getS3Key generates the S3 object key for a job file
|
||||||
|
func getS3Key(job *schema.Job, file string) string {
|
||||||
|
lvl1 := fmt.Sprintf("%d", job.JobID/1000)
|
||||||
|
lvl2 := fmt.Sprintf("%03d", job.JobID%1000)
|
||||||
|
startTime := strconv.FormatInt(job.StartTime, 10)
|
||||||
|
return fmt.Sprintf("%s/%s/%s/%s/%s", job.Cluster, lvl1, lvl2, startTime, file)
|
||||||
|
}
|
||||||
|
|
||||||
|
// getS3Directory generates the S3 key prefix for a job directory
|
||||||
|
func getS3Directory(job *schema.Job) string {
|
||||||
|
lvl1 := fmt.Sprintf("%d", job.JobID/1000)
|
||||||
|
lvl2 := fmt.Sprintf("%03d", job.JobID%1000)
|
||||||
|
startTime := strconv.FormatInt(job.StartTime, 10)
|
||||||
|
return fmt.Sprintf("%s/%s/%s/%s/", job.Cluster, lvl1, lvl2, startTime)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s3a *S3Archive) Init(rawConfig json.RawMessage) (uint64, error) {
|
||||||
|
var cfg S3ArchiveConfig
|
||||||
|
if err := json.Unmarshal(rawConfig, &cfg); err != nil {
|
||||||
|
cclog.Warnf("S3Archive Init() > Unmarshal error: %#v", err)
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.Bucket == "" {
|
||||||
|
err := fmt.Errorf("S3Archive Init(): empty bucket name")
|
||||||
|
cclog.Errorf("S3Archive Init() > config error: %v", err)
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.Region == "" {
|
||||||
|
cfg.Region = "us-east-1" // Default region
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// Create custom AWS config
|
||||||
|
var awsCfg aws.Config
|
||||||
|
var err error
|
||||||
|
|
||||||
|
if cfg.AccessKey != "" && cfg.SecretKey != "" {
|
||||||
|
// Use static credentials
|
||||||
|
awsCfg, err = awsconfig.LoadDefaultConfig(ctx,
|
||||||
|
awsconfig.WithRegion(cfg.Region),
|
||||||
|
awsconfig.WithCredentialsProvider(credentials.NewStaticCredentialsProvider(
|
||||||
|
cfg.AccessKey,
|
||||||
|
cfg.SecretKey,
|
||||||
|
"",
|
||||||
|
)),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
// Use default credential chain
|
||||||
|
awsCfg, err = awsconfig.LoadDefaultConfig(ctx,
|
||||||
|
awsconfig.WithRegion(cfg.Region),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
cclog.Errorf("S3Archive Init() > failed to load AWS config: %v", err)
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create S3 client with path-style option and custom endpoint if specified
|
||||||
|
s3a.client = s3.NewFromConfig(awsCfg, func(o *s3.Options) {
|
||||||
|
o.UsePathStyle = cfg.UsePathStyle
|
||||||
|
if cfg.Endpoint != "" {
|
||||||
|
o.BaseEndpoint = aws.String(cfg.Endpoint)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
s3a.bucket = cfg.Bucket
|
||||||
|
|
||||||
|
// Check if bucket exists and is accessible
|
||||||
|
_, err = s3a.client.HeadBucket(ctx, &s3.HeadBucketInput{
|
||||||
|
Bucket: aws.String(s3a.bucket),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
cclog.Errorf("S3Archive Init() > bucket access error: %v", err)
|
||||||
|
return 0, fmt.Errorf("cannot access S3 bucket '%s': %w", s3a.bucket, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read version.txt from S3
|
||||||
|
versionKey := "version.txt"
|
||||||
|
result, err := s3a.client.GetObject(ctx, &s3.GetObjectInput{
|
||||||
|
Bucket: aws.String(s3a.bucket),
|
||||||
|
Key: aws.String(versionKey),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
cclog.Warnf("S3Archive Init() > cannot read version.txt: %v", err)
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
defer result.Body.Close()
|
||||||
|
|
||||||
|
versionBytes, err := io.ReadAll(result.Body)
|
||||||
|
if err != nil {
|
||||||
|
cclog.Errorf("S3Archive Init() > failed to read version.txt: %v", err)
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
version, err := strconv.ParseUint(strings.TrimSuffix(string(versionBytes), "\n"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
cclog.Errorf("S3Archive Init() > version parse error: %v", err)
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if version != Version {
|
||||||
|
return version, fmt.Errorf("unsupported version %d, need %d", version, Version)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Discover clusters by listing top-level prefixes
|
||||||
|
s3a.clusters = []string{}
|
||||||
|
paginator := s3.NewListObjectsV2Paginator(s3a.client, &s3.ListObjectsV2Input{
|
||||||
|
Bucket: aws.String(s3a.bucket),
|
||||||
|
Delimiter: aws.String("/"),
|
||||||
|
})
|
||||||
|
|
||||||
|
for paginator.HasMorePages() {
|
||||||
|
page, err := paginator.NextPage(ctx)
|
||||||
|
if err != nil {
|
||||||
|
cclog.Errorf("S3Archive Init() > failed to list clusters: %v", err)
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, prefix := range page.CommonPrefixes {
|
||||||
|
if prefix.Prefix != nil {
|
||||||
|
clusterName := strings.TrimSuffix(*prefix.Prefix, "/")
|
||||||
|
// Filter out non-cluster entries
|
||||||
|
if clusterName != "" && clusterName != "version.txt" {
|
||||||
|
s3a.clusters = append(s3a.clusters, clusterName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
cclog.Infof("S3Archive initialized with bucket '%s', found %d clusters", s3a.bucket, len(s3a.clusters))
|
||||||
|
return version, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s3a *S3Archive) Info() {
|
||||||
|
ctx := context.Background()
|
||||||
|
fmt.Printf("S3 Job archive bucket: %s\n", s3a.bucket)
|
||||||
|
|
||||||
|
ci := make(map[string]*clusterInfo)
|
||||||
|
|
||||||
|
for _, cluster := range s3a.clusters {
|
||||||
|
ci[cluster] = &clusterInfo{dateFirst: time.Now().Unix()}
|
||||||
|
|
||||||
|
// List all jobs for this cluster
|
||||||
|
prefix := cluster + "/"
|
||||||
|
paginator := s3.NewListObjectsV2Paginator(s3a.client, &s3.ListObjectsV2Input{
|
||||||
|
Bucket: aws.String(s3a.bucket),
|
||||||
|
Prefix: aws.String(prefix),
|
||||||
|
})
|
||||||
|
|
||||||
|
for paginator.HasMorePages() {
|
||||||
|
page, err := paginator.NextPage(ctx)
|
||||||
|
if err != nil {
|
||||||
|
cclog.Fatalf("S3Archive Info() > failed to list objects: %s", err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, obj := range page.Contents {
|
||||||
|
if obj.Key != nil && strings.HasSuffix(*obj.Key, "/meta.json") {
|
||||||
|
ci[cluster].numJobs++
|
||||||
|
// Extract starttime from key: cluster/lvl1/lvl2/starttime/meta.json
|
||||||
|
parts := strings.Split(*obj.Key, "/")
|
||||||
|
if len(parts) >= 4 {
|
||||||
|
startTime, err := strconv.ParseInt(parts[3], 10, 64)
|
||||||
|
if err == nil {
|
||||||
|
ci[cluster].dateFirst = util.Min(ci[cluster].dateFirst, startTime)
|
||||||
|
ci[cluster].dateLast = util.Max(ci[cluster].dateLast, startTime)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if obj.Size != nil {
|
||||||
|
ci[cluster].diskSize += float64(*obj.Size) / (1024 * 1024) // Convert to MB
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
cit := clusterInfo{dateFirst: time.Now().Unix()}
|
||||||
|
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', tabwriter.Debug)
|
||||||
|
fmt.Fprintln(w, "cluster\t#jobs\tfrom\tto\tsize (MB)")
|
||||||
|
for cluster, clusterInfo := range ci {
|
||||||
|
fmt.Fprintf(w, "%s\t%d\t%s\t%s\t%.2f\n", cluster,
|
||||||
|
clusterInfo.numJobs,
|
||||||
|
time.Unix(clusterInfo.dateFirst, 0),
|
||||||
|
time.Unix(clusterInfo.dateLast, 0),
|
||||||
|
clusterInfo.diskSize)
|
||||||
|
|
||||||
|
cit.numJobs += clusterInfo.numJobs
|
||||||
|
cit.dateFirst = util.Min(cit.dateFirst, clusterInfo.dateFirst)
|
||||||
|
cit.dateLast = util.Max(cit.dateLast, clusterInfo.dateLast)
|
||||||
|
cit.diskSize += clusterInfo.diskSize
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Fprintf(w, "TOTAL\t%d\t%s\t%s\t%.2f\n",
|
||||||
|
cit.numJobs, time.Unix(cit.dateFirst, 0), time.Unix(cit.dateLast, 0), cit.diskSize)
|
||||||
|
w.Flush()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s3a *S3Archive) Exists(job *schema.Job) bool {
|
||||||
|
ctx := context.Background()
|
||||||
|
key := getS3Key(job, "meta.json")
|
||||||
|
|
||||||
|
_, err := s3a.client.HeadObject(ctx, &s3.HeadObjectInput{
|
||||||
|
Bucket: aws.String(s3a.bucket),
|
||||||
|
Key: aws.String(key),
|
||||||
|
})
|
||||||
|
|
||||||
|
return err == nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s3a *S3Archive) LoadJobMeta(job *schema.Job) (*schema.Job, error) {
|
||||||
|
ctx := context.Background()
|
||||||
|
key := getS3Key(job, "meta.json")
|
||||||
|
|
||||||
|
result, err := s3a.client.GetObject(ctx, &s3.GetObjectInput{
|
||||||
|
Bucket: aws.String(s3a.bucket),
|
||||||
|
Key: aws.String(key),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
cclog.Errorf("S3Archive LoadJobMeta() > GetObject error: %v", err)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer result.Body.Close()
|
||||||
|
|
||||||
|
b, err := io.ReadAll(result.Body)
|
||||||
|
if err != nil {
|
||||||
|
cclog.Errorf("S3Archive LoadJobMeta() > read error: %v", err)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if config.Keys.Validate {
|
||||||
|
if err := schema.Validate(schema.Meta, bytes.NewReader(b)); err != nil {
|
||||||
|
return nil, fmt.Errorf("validate job meta: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return DecodeJobMeta(bytes.NewReader(b))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s3a *S3Archive) LoadJobData(job *schema.Job) (schema.JobData, error) {
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// Try compressed file first
|
||||||
|
keyGz := getS3Key(job, "data.json.gz")
|
||||||
|
result, err := s3a.client.GetObject(ctx, &s3.GetObjectInput{
|
||||||
|
Bucket: aws.String(s3a.bucket),
|
||||||
|
Key: aws.String(keyGz),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
// Try uncompressed file
|
||||||
|
key := getS3Key(job, "data.json")
|
||||||
|
result, err = s3a.client.GetObject(ctx, &s3.GetObjectInput{
|
||||||
|
Bucket: aws.String(s3a.bucket),
|
||||||
|
Key: aws.String(key),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
cclog.Errorf("S3Archive LoadJobData() > GetObject error: %v", err)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer result.Body.Close()
|
||||||
|
|
||||||
|
if config.Keys.Validate {
|
||||||
|
b, _ := io.ReadAll(result.Body)
|
||||||
|
if err := schema.Validate(schema.Data, bytes.NewReader(b)); err != nil {
|
||||||
|
return schema.JobData{}, fmt.Errorf("validate job data: %v", err)
|
||||||
|
}
|
||||||
|
return DecodeJobData(bytes.NewReader(b), key)
|
||||||
|
}
|
||||||
|
return DecodeJobData(result.Body, key)
|
||||||
|
}
|
||||||
|
defer result.Body.Close()
|
||||||
|
|
||||||
|
// Decompress
|
||||||
|
r, err := gzip.NewReader(result.Body)
|
||||||
|
if err != nil {
|
||||||
|
cclog.Errorf("S3Archive LoadJobData() > gzip error: %v", err)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer r.Close()
|
||||||
|
|
||||||
|
if config.Keys.Validate {
|
||||||
|
b, _ := io.ReadAll(r)
|
||||||
|
if err := schema.Validate(schema.Data, bytes.NewReader(b)); err != nil {
|
||||||
|
return schema.JobData{}, fmt.Errorf("validate job data: %v", err)
|
||||||
|
}
|
||||||
|
return DecodeJobData(bytes.NewReader(b), keyGz)
|
||||||
|
}
|
||||||
|
return DecodeJobData(r, keyGz)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s3a *S3Archive) LoadJobStats(job *schema.Job) (schema.ScopedJobStats, error) {
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// Try compressed file first
|
||||||
|
keyGz := getS3Key(job, "data.json.gz")
|
||||||
|
result, err := s3a.client.GetObject(ctx, &s3.GetObjectInput{
|
||||||
|
Bucket: aws.String(s3a.bucket),
|
||||||
|
Key: aws.String(keyGz),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
// Try uncompressed file
|
||||||
|
key := getS3Key(job, "data.json")
|
||||||
|
result, err = s3a.client.GetObject(ctx, &s3.GetObjectInput{
|
||||||
|
Bucket: aws.String(s3a.bucket),
|
||||||
|
Key: aws.String(key),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
cclog.Errorf("S3Archive LoadJobStats() > GetObject error: %v", err)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer result.Body.Close()
|
||||||
|
|
||||||
|
if config.Keys.Validate {
|
||||||
|
b, _ := io.ReadAll(result.Body)
|
||||||
|
if err := schema.Validate(schema.Data, bytes.NewReader(b)); err != nil {
|
||||||
|
return nil, fmt.Errorf("validate job data: %v", err)
|
||||||
|
}
|
||||||
|
return DecodeJobStats(bytes.NewReader(b), key)
|
||||||
|
}
|
||||||
|
return DecodeJobStats(result.Body, key)
|
||||||
|
}
|
||||||
|
defer result.Body.Close()
|
||||||
|
|
||||||
|
// Decompress
|
||||||
|
r, err := gzip.NewReader(result.Body)
|
||||||
|
if err != nil {
|
||||||
|
cclog.Errorf("S3Archive LoadJobStats() > gzip error: %v", err)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer r.Close()
|
||||||
|
|
||||||
|
if config.Keys.Validate {
|
||||||
|
b, _ := io.ReadAll(r)
|
||||||
|
if err := schema.Validate(schema.Data, bytes.NewReader(b)); err != nil {
|
||||||
|
return nil, fmt.Errorf("validate job data: %v", err)
|
||||||
|
}
|
||||||
|
return DecodeJobStats(bytes.NewReader(b), keyGz)
|
||||||
|
}
|
||||||
|
return DecodeJobStats(r, keyGz)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s3a *S3Archive) LoadClusterCfg(name string) (*schema.Cluster, error) {
|
||||||
|
ctx := context.Background()
|
||||||
|
key := fmt.Sprintf("%s/cluster.json", name)
|
||||||
|
|
||||||
|
result, err := s3a.client.GetObject(ctx, &s3.GetObjectInput{
|
||||||
|
Bucket: aws.String(s3a.bucket),
|
||||||
|
Key: aws.String(key),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
cclog.Errorf("S3Archive LoadClusterCfg() > GetObject error: %v", err)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer result.Body.Close()
|
||||||
|
|
||||||
|
b, err := io.ReadAll(result.Body)
|
||||||
|
if err != nil {
|
||||||
|
cclog.Errorf("S3Archive LoadClusterCfg() > read error: %v", err)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := schema.Validate(schema.ClusterCfg, bytes.NewReader(b)); err != nil {
|
||||||
|
cclog.Warnf("Validate cluster config: %v\n", err)
|
||||||
|
return &schema.Cluster{}, fmt.Errorf("validate cluster config: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return DecodeCluster(bytes.NewReader(b))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s3a *S3Archive) StoreJobMeta(job *schema.Job) error {
|
||||||
|
ctx := context.Background()
|
||||||
|
key := getS3Key(job, "meta.json")
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
if err := EncodeJobMeta(&buf, job); err != nil {
|
||||||
|
cclog.Error("S3Archive StoreJobMeta() > encoding error")
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := s3a.client.PutObject(ctx, &s3.PutObjectInput{
|
||||||
|
Bucket: aws.String(s3a.bucket),
|
||||||
|
Key: aws.String(key),
|
||||||
|
Body: bytes.NewReader(buf.Bytes()),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
cclog.Errorf("S3Archive StoreJobMeta() > PutObject error: %v", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s3a *S3Archive) ImportJob(jobMeta *schema.Job, jobData *schema.JobData) error {
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// Upload meta.json
|
||||||
|
metaKey := getS3Key(jobMeta, "meta.json")
|
||||||
|
var metaBuf bytes.Buffer
|
||||||
|
if err := EncodeJobMeta(&metaBuf, jobMeta); err != nil {
|
||||||
|
cclog.Error("S3Archive ImportJob() > encoding meta error")
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := s3a.client.PutObject(ctx, &s3.PutObjectInput{
|
||||||
|
Bucket: aws.String(s3a.bucket),
|
||||||
|
Key: aws.String(metaKey),
|
||||||
|
Body: bytes.NewReader(metaBuf.Bytes()),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
cclog.Errorf("S3Archive ImportJob() > PutObject meta error: %v", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Upload data.json
|
||||||
|
dataKey := getS3Key(jobMeta, "data.json")
|
||||||
|
var dataBuf bytes.Buffer
|
||||||
|
if err := EncodeJobData(&dataBuf, jobData); err != nil {
|
||||||
|
cclog.Error("S3Archive ImportJob() > encoding data error")
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = s3a.client.PutObject(ctx, &s3.PutObjectInput{
|
||||||
|
Bucket: aws.String(s3a.bucket),
|
||||||
|
Key: aws.String(dataKey),
|
||||||
|
Body: bytes.NewReader(dataBuf.Bytes()),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
cclog.Errorf("S3Archive ImportJob() > PutObject data error: %v", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s3a *S3Archive) GetClusters() []string {
|
||||||
|
return s3a.clusters
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s3a *S3Archive) CleanUp(jobs []*schema.Job) {
|
||||||
|
ctx := context.Background()
|
||||||
|
start := time.Now()
|
||||||
|
|
||||||
|
for _, job := range jobs {
|
||||||
|
if job == nil {
|
||||||
|
cclog.Errorf("S3Archive CleanUp() error: job is nil")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete all files in the job directory
|
||||||
|
prefix := getS3Directory(job)
|
||||||
|
|
||||||
|
paginator := s3.NewListObjectsV2Paginator(s3a.client, &s3.ListObjectsV2Input{
|
||||||
|
Bucket: aws.String(s3a.bucket),
|
||||||
|
Prefix: aws.String(prefix),
|
||||||
|
})
|
||||||
|
|
||||||
|
for paginator.HasMorePages() {
|
||||||
|
page, err := paginator.NextPage(ctx)
|
||||||
|
if err != nil {
|
||||||
|
cclog.Errorf("S3Archive CleanUp() > list error: %v", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, obj := range page.Contents {
|
||||||
|
if obj.Key != nil {
|
||||||
|
_, err := s3a.client.DeleteObject(ctx, &s3.DeleteObjectInput{
|
||||||
|
Bucket: aws.String(s3a.bucket),
|
||||||
|
Key: obj.Key,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
cclog.Errorf("S3Archive CleanUp() > delete error: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
cclog.Infof("Retention Service - Remove %d jobs from S3 in %s", len(jobs), time.Since(start))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s3a *S3Archive) Move(jobs []*schema.Job, targetPath string) {
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
for _, job := range jobs {
|
||||||
|
sourcePrefix := getS3Directory(job)
|
||||||
|
|
||||||
|
// List all objects in source
|
||||||
|
paginator := s3.NewListObjectsV2Paginator(s3a.client, &s3.ListObjectsV2Input{
|
||||||
|
Bucket: aws.String(s3a.bucket),
|
||||||
|
Prefix: aws.String(sourcePrefix),
|
||||||
|
})
|
||||||
|
|
||||||
|
for paginator.HasMorePages() {
|
||||||
|
page, err := paginator.NextPage(ctx)
|
||||||
|
if err != nil {
|
||||||
|
cclog.Errorf("S3Archive Move() > list error: %v", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, obj := range page.Contents {
|
||||||
|
if obj.Key == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compute target key by replacing prefix
|
||||||
|
targetKey := strings.Replace(*obj.Key, sourcePrefix, targetPath+"/", 1)
|
||||||
|
|
||||||
|
// Copy object
|
||||||
|
_, err := s3a.client.CopyObject(ctx, &s3.CopyObjectInput{
|
||||||
|
Bucket: aws.String(s3a.bucket),
|
||||||
|
CopySource: aws.String(fmt.Sprintf("%s/%s", s3a.bucket, *obj.Key)),
|
||||||
|
Key: aws.String(targetKey),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
cclog.Errorf("S3Archive Move() > copy error: %v", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete source object
|
||||||
|
_, err = s3a.client.DeleteObject(ctx, &s3.DeleteObjectInput{
|
||||||
|
Bucket: aws.String(s3a.bucket),
|
||||||
|
Key: obj.Key,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
cclog.Errorf("S3Archive Move() > delete error: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s3a *S3Archive) Clean(before int64, after int64) {
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
if after == 0 {
|
||||||
|
after = math.MaxInt64
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, cluster := range s3a.clusters {
|
||||||
|
prefix := cluster + "/"
|
||||||
|
|
||||||
|
paginator := s3.NewListObjectsV2Paginator(s3a.client, &s3.ListObjectsV2Input{
|
||||||
|
Bucket: aws.String(s3a.bucket),
|
||||||
|
Prefix: aws.String(prefix),
|
||||||
|
})
|
||||||
|
|
||||||
|
for paginator.HasMorePages() {
|
||||||
|
page, err := paginator.NextPage(ctx)
|
||||||
|
if err != nil {
|
||||||
|
cclog.Fatalf("S3Archive Clean() > list error: %s", err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, obj := range page.Contents {
|
||||||
|
if obj.Key == nil || !strings.HasSuffix(*obj.Key, "/meta.json") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract starttime from key: cluster/lvl1/lvl2/starttime/meta.json
|
||||||
|
parts := strings.Split(*obj.Key, "/")
|
||||||
|
if len(parts) < 4 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
startTime, err := strconv.ParseInt(parts[3], 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
cclog.Fatalf("S3Archive Clean() > cannot parse starttime: %s", err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
if startTime < before || startTime > after {
|
||||||
|
// Delete entire job directory
|
||||||
|
jobPrefix := strings.Join(parts[:4], "/") + "/"
|
||||||
|
|
||||||
|
jobPaginator := s3.NewListObjectsV2Paginator(s3a.client, &s3.ListObjectsV2Input{
|
||||||
|
Bucket: aws.String(s3a.bucket),
|
||||||
|
Prefix: aws.String(jobPrefix),
|
||||||
|
})
|
||||||
|
|
||||||
|
for jobPaginator.HasMorePages() {
|
||||||
|
jobPage, err := jobPaginator.NextPage(ctx)
|
||||||
|
if err != nil {
|
||||||
|
cclog.Errorf("S3Archive Clean() > list job error: %v", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, jobObj := range jobPage.Contents {
|
||||||
|
if jobObj.Key != nil {
|
||||||
|
_, err := s3a.client.DeleteObject(ctx, &s3.DeleteObjectInput{
|
||||||
|
Bucket: aws.String(s3a.bucket),
|
||||||
|
Key: jobObj.Key,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
cclog.Errorf("S3Archive Clean() > delete error: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s3a *S3Archive) Compress(jobs []*schema.Job) {
|
||||||
|
ctx := context.Background()
|
||||||
|
var cnt int
|
||||||
|
start := time.Now()
|
||||||
|
|
||||||
|
for _, job := range jobs {
|
||||||
|
dataKey := getS3Key(job, "data.json")
|
||||||
|
|
||||||
|
// Check if uncompressed file exists and get its size
|
||||||
|
headResult, err := s3a.client.HeadObject(ctx, &s3.HeadObjectInput{
|
||||||
|
Bucket: aws.String(s3a.bucket),
|
||||||
|
Key: aws.String(dataKey),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
continue // File doesn't exist or error
|
||||||
|
}
|
||||||
|
|
||||||
|
if headResult.ContentLength == nil || *headResult.ContentLength < 2000 {
|
||||||
|
continue // Too small to compress
|
||||||
|
}
|
||||||
|
|
||||||
|
// Download the file
|
||||||
|
result, err := s3a.client.GetObject(ctx, &s3.GetObjectInput{
|
||||||
|
Bucket: aws.String(s3a.bucket),
|
||||||
|
Key: aws.String(dataKey),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
cclog.Errorf("S3Archive Compress() > GetObject error: %v", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := io.ReadAll(result.Body)
|
||||||
|
result.Body.Close()
|
||||||
|
if err != nil {
|
||||||
|
cclog.Errorf("S3Archive Compress() > read error: %v", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compress the data
|
||||||
|
var compressedBuf bytes.Buffer
|
||||||
|
gzipWriter := gzip.NewWriter(&compressedBuf)
|
||||||
|
if _, err := gzipWriter.Write(data); err != nil {
|
||||||
|
cclog.Errorf("S3Archive Compress() > gzip write error: %v", err)
|
||||||
|
gzipWriter.Close()
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
gzipWriter.Close()
|
||||||
|
|
||||||
|
// Upload compressed file
|
||||||
|
compressedKey := getS3Key(job, "data.json.gz")
|
||||||
|
_, err = s3a.client.PutObject(ctx, &s3.PutObjectInput{
|
||||||
|
Bucket: aws.String(s3a.bucket),
|
||||||
|
Key: aws.String(compressedKey),
|
||||||
|
Body: bytes.NewReader(compressedBuf.Bytes()),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
cclog.Errorf("S3Archive Compress() > PutObject error: %v", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete uncompressed file
|
||||||
|
_, err = s3a.client.DeleteObject(ctx, &s3.DeleteObjectInput{
|
||||||
|
Bucket: aws.String(s3a.bucket),
|
||||||
|
Key: aws.String(dataKey),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
cclog.Errorf("S3Archive Compress() > delete error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cnt++
|
||||||
|
}
|
||||||
|
|
||||||
|
cclog.Infof("Compression Service - %d files in S3 took %s", cnt, time.Since(start))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s3a *S3Archive) CompressLast(starttime int64) int64 {
|
||||||
|
ctx := context.Background()
|
||||||
|
compressKey := "compress.txt"
|
||||||
|
|
||||||
|
// Try to read existing compress.txt
|
||||||
|
result, err := s3a.client.GetObject(ctx, &s3.GetObjectInput{
|
||||||
|
Bucket: aws.String(s3a.bucket),
|
||||||
|
Key: aws.String(compressKey),
|
||||||
|
})
|
||||||
|
|
||||||
|
var last int64
|
||||||
|
if err == nil {
|
||||||
|
b, _ := io.ReadAll(result.Body)
|
||||||
|
result.Body.Close()
|
||||||
|
last, err = strconv.ParseInt(strings.TrimSuffix(string(b), "\n"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
cclog.Errorf("S3Archive CompressLast() > parse error: %v", err)
|
||||||
|
last = starttime
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
last = starttime
|
||||||
|
}
|
||||||
|
|
||||||
|
cclog.Infof("S3Archive CompressLast() - start %d last %d", starttime, last)
|
||||||
|
|
||||||
|
// Write new timestamp
|
||||||
|
newValue := fmt.Sprintf("%d", starttime)
|
||||||
|
_, err = s3a.client.PutObject(ctx, &s3.PutObjectInput{
|
||||||
|
Bucket: aws.String(s3a.bucket),
|
||||||
|
Key: aws.String(compressKey),
|
||||||
|
Body: strings.NewReader(newValue),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
cclog.Errorf("S3Archive CompressLast() > PutObject error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return last
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s3a *S3Archive) Iter(loadMetricData bool) <-chan JobContainer {
|
||||||
|
ch := make(chan JobContainer)
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
ctx := context.Background()
|
||||||
|
defer close(ch)
|
||||||
|
|
||||||
|
for _, cluster := range s3a.clusters {
|
||||||
|
prefix := cluster + "/"
|
||||||
|
|
||||||
|
paginator := s3.NewListObjectsV2Paginator(s3a.client, &s3.ListObjectsV2Input{
|
||||||
|
Bucket: aws.String(s3a.bucket),
|
||||||
|
Prefix: aws.String(prefix),
|
||||||
|
})
|
||||||
|
|
||||||
|
for paginator.HasMorePages() {
|
||||||
|
page, err := paginator.NextPage(ctx)
|
||||||
|
if err != nil {
|
||||||
|
cclog.Fatalf("S3Archive Iter() > list error: %s", err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, obj := range page.Contents {
|
||||||
|
if obj.Key == nil || !strings.HasSuffix(*obj.Key, "/meta.json") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load job metadata
|
||||||
|
result, err := s3a.client.GetObject(ctx, &s3.GetObjectInput{
|
||||||
|
Bucket: aws.String(s3a.bucket),
|
||||||
|
Key: obj.Key,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
cclog.Errorf("S3Archive Iter() > GetObject meta error: %v", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
b, err := io.ReadAll(result.Body)
|
||||||
|
result.Body.Close()
|
||||||
|
if err != nil {
|
||||||
|
cclog.Errorf("S3Archive Iter() > read meta error: %v", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
job, err := DecodeJobMeta(bytes.NewReader(b))
|
||||||
|
if err != nil {
|
||||||
|
cclog.Errorf("S3Archive Iter() > decode meta error: %v", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if loadMetricData {
|
||||||
|
jobData, err := s3a.LoadJobData(job)
|
||||||
|
if err != nil {
|
||||||
|
cclog.Errorf("S3Archive Iter() > load data error: %v", err)
|
||||||
|
ch <- JobContainer{Meta: job, Data: nil}
|
||||||
|
} else {
|
||||||
|
ch <- JobContainer{Meta: job, Data: &jobData}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
ch <- JobContainer{Meta: job, Data: nil}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
return ch
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,293 @@
|
|||||||
|
// Copyright (C) NHR@FAU, University Erlangen-Nuremberg.
|
||||||
|
// All rights reserved.
|
||||||
|
// Use of this source code is governed by a MIT-style
|
||||||
|
// license that can be found in the LICENSE file.
|
||||||
|
package archive
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/ClusterCockpit/cc-lib/schema"
|
||||||
|
"github.com/aws/aws-sdk-go-v2/aws"
|
||||||
|
"github.com/aws/aws-sdk-go-v2/service/s3"
|
||||||
|
"github.com/aws/aws-sdk-go-v2/service/s3/types"
|
||||||
|
)
|
||||||
|
|
||||||
|
// MockS3Client is a mock implementation of the S3 client for testing
|
||||||
|
type MockS3Client struct {
|
||||||
|
objects map[string][]byte
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewMockS3Client() *MockS3Client {
|
||||||
|
return &MockS3Client{
|
||||||
|
objects: make(map[string][]byte),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MockS3Client) HeadBucket(ctx context.Context, params *s3.HeadBucketInput, optFns ...func(*s3.Options)) (*s3.HeadBucketOutput, error) {
|
||||||
|
// Always succeed for mock
|
||||||
|
return &s3.HeadBucketOutput{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MockS3Client) GetObject(ctx context.Context, params *s3.GetObjectInput, optFns ...func(*s3.Options)) (*s3.GetObjectOutput, error) {
|
||||||
|
key := aws.ToString(params.Key)
|
||||||
|
data, exists := m.objects[key]
|
||||||
|
if !exists {
|
||||||
|
return nil, fmt.Errorf("NoSuchKey: object not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
contentLength := int64(len(data))
|
||||||
|
return &s3.GetObjectOutput{
|
||||||
|
Body: io.NopCloser(bytes.NewReader(data)),
|
||||||
|
ContentLength: &contentLength,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MockS3Client) PutObject(ctx context.Context, params *s3.PutObjectInput, optFns ...func(*s3.Options)) (*s3.PutObjectOutput, error) {
|
||||||
|
key := aws.ToString(params.Key)
|
||||||
|
data, err := io.ReadAll(params.Body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
m.objects[key] = data
|
||||||
|
return &s3.PutObjectOutput{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MockS3Client) HeadObject(ctx context.Context, params *s3.HeadObjectInput, optFns ...func(*s3.Options)) (*s3.HeadObjectOutput, error) {
|
||||||
|
key := aws.ToString(params.Key)
|
||||||
|
data, exists := m.objects[key]
|
||||||
|
if !exists {
|
||||||
|
return nil, fmt.Errorf("NotFound")
|
||||||
|
}
|
||||||
|
|
||||||
|
contentLength := int64(len(data))
|
||||||
|
return &s3.HeadObjectOutput{
|
||||||
|
ContentLength: &contentLength,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MockS3Client) DeleteObject(ctx context.Context, params *s3.DeleteObjectInput, optFns ...func(*s3.Options)) (*s3.DeleteObjectOutput, error) {
|
||||||
|
key := aws.ToString(params.Key)
|
||||||
|
delete(m.objects, key)
|
||||||
|
return &s3.DeleteObjectOutput{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MockS3Client) CopyObject(ctx context.Context, params *s3.CopyObjectInput, optFns ...func(*s3.Options)) (*s3.CopyObjectOutput, error) {
|
||||||
|
// Parse source bucket/key from CopySource
|
||||||
|
source := aws.ToString(params.CopySource)
|
||||||
|
parts := strings.SplitN(source, "/", 2)
|
||||||
|
if len(parts) < 2 {
|
||||||
|
return nil, fmt.Errorf("invalid CopySource")
|
||||||
|
}
|
||||||
|
sourceKey := parts[1]
|
||||||
|
|
||||||
|
data, exists := m.objects[sourceKey]
|
||||||
|
if !exists {
|
||||||
|
return nil, fmt.Errorf("source not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
destKey := aws.ToString(params.Key)
|
||||||
|
m.objects[destKey] = data
|
||||||
|
return &s3.CopyObjectOutput{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MockS3Client) ListObjectsV2(ctx context.Context, params *s3.ListObjectsV2Input, optFns ...func(*s3.Options)) (*s3.ListObjectsV2Output, error) {
|
||||||
|
prefix := aws.ToString(params.Prefix)
|
||||||
|
delimiter := aws.ToString(params.Delimiter)
|
||||||
|
|
||||||
|
var contents []types.Object
|
||||||
|
commonPrefixes := make(map[string]bool)
|
||||||
|
|
||||||
|
for key, data := range m.objects {
|
||||||
|
if !strings.HasPrefix(key, prefix) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if delimiter != "" {
|
||||||
|
// Check if there's a delimiter after the prefix
|
||||||
|
remainder := strings.TrimPrefix(key, prefix)
|
||||||
|
delimIdx := strings.Index(remainder, delimiter)
|
||||||
|
if delimIdx >= 0 {
|
||||||
|
// This is a "directory" - add to common prefixes
|
||||||
|
commonPrefix := prefix + remainder[:delimIdx+1]
|
||||||
|
commonPrefixes[commonPrefix] = true
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
size := int64(len(data))
|
||||||
|
contents = append(contents, types.Object{
|
||||||
|
Key: aws.String(key),
|
||||||
|
Size: &size,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
var prefixList []types.CommonPrefix
|
||||||
|
for p := range commonPrefixes {
|
||||||
|
prefixList = append(prefixList, types.CommonPrefix{
|
||||||
|
Prefix: aws.String(p),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return &s3.ListObjectsV2Output{
|
||||||
|
Contents: contents,
|
||||||
|
CommonPrefixes: prefixList,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test helper to create a mock S3 archive with test data
|
||||||
|
func setupMockS3Archive(t *testing.T) *MockS3Client {
|
||||||
|
mock := NewMockS3Client()
|
||||||
|
|
||||||
|
// Add version.txt
|
||||||
|
mock.objects["version.txt"] = []byte("2\n")
|
||||||
|
|
||||||
|
// Add a test cluster directory
|
||||||
|
mock.objects["emmy/cluster.json"] = []byte(`{
|
||||||
|
"name": "emmy",
|
||||||
|
"metricConfig": [],
|
||||||
|
"subClusters": [
|
||||||
|
{
|
||||||
|
"name": "main",
|
||||||
|
"processorType": "Intel Xeon",
|
||||||
|
"socketsPerNode": 2,
|
||||||
|
"coresPerSocket": 4,
|
||||||
|
"threadsPerCore": 2,
|
||||||
|
"flopRateScalar": 16,
|
||||||
|
"flopRateSimd": 32,
|
||||||
|
"memoryBandwidth": 100
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}`)
|
||||||
|
|
||||||
|
// Add a test job
|
||||||
|
mock.objects["emmy/1403/244/1608923076/meta.json"] = []byte(`{
|
||||||
|
"jobId": 1403244,
|
||||||
|
"cluster": "emmy",
|
||||||
|
"startTime": 1608923076,
|
||||||
|
"numNodes": 1,
|
||||||
|
"resources": [{"hostname": "node001"}]
|
||||||
|
}`)
|
||||||
|
|
||||||
|
mock.objects["emmy/1403/244/1608923076/data.json"] = []byte(`{
|
||||||
|
"mem_used": {
|
||||||
|
"node": {
|
||||||
|
"node001": {
|
||||||
|
"series": [{"time": 1608923076, "value": 1000}]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}`)
|
||||||
|
|
||||||
|
return mock
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestS3InitEmptyBucket(t *testing.T) {
|
||||||
|
var s3a S3Archive
|
||||||
|
_, err := s3a.Init(json.RawMessage(`{"kind":"s3"}`))
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for empty bucket")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestS3InitInvalidConfig(t *testing.T) {
|
||||||
|
var s3a S3Archive
|
||||||
|
_, err := s3a.Init(json.RawMessage(`"bucket":"test-bucket"`))
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for invalid config")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Note: TestS3Init would require actual S3 connection or more complex mocking
|
||||||
|
// For now, we document that Init() should be tested manually with MinIO
|
||||||
|
|
||||||
|
func TestGetS3Key(t *testing.T) {
|
||||||
|
job := &schema.Job{
|
||||||
|
JobID: 1403244,
|
||||||
|
Cluster: "emmy",
|
||||||
|
StartTime: 1608923076,
|
||||||
|
}
|
||||||
|
|
||||||
|
key := getS3Key(job, "meta.json")
|
||||||
|
expected := "emmy/1403/244/1608923076/meta.json"
|
||||||
|
if key != expected {
|
||||||
|
t.Errorf("expected key %s, got %s", expected, key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetS3Directory(t *testing.T) {
|
||||||
|
job := &schema.Job{
|
||||||
|
JobID: 1403244,
|
||||||
|
Cluster: "emmy",
|
||||||
|
StartTime: 1608923076,
|
||||||
|
}
|
||||||
|
|
||||||
|
dir := getS3Directory(job)
|
||||||
|
expected := "emmy/1403/244/1608923076/"
|
||||||
|
if dir != expected {
|
||||||
|
t.Errorf("expected dir %s, got %s", expected, dir)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Integration-style tests would go here for actual S3 operations
|
||||||
|
// These would require MinIO or localstack for testing
|
||||||
|
|
||||||
|
func TestS3ArchiveConfigParsing(t *testing.T) {
|
||||||
|
rawConfig := json.RawMessage(`{
|
||||||
|
"endpoint": "http://localhost:9000",
|
||||||
|
"accessKey": "minioadmin",
|
||||||
|
"secretKey": "minioadmin",
|
||||||
|
"bucket": "test-bucket",
|
||||||
|
"region": "us-east-1",
|
||||||
|
"usePathStyle": true
|
||||||
|
}`)
|
||||||
|
|
||||||
|
var cfg S3ArchiveConfig
|
||||||
|
err := json.Unmarshal(rawConfig, &cfg)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to parse config: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.Bucket != "test-bucket" {
|
||||||
|
t.Errorf("expected bucket 'test-bucket', got '%s'", cfg.Bucket)
|
||||||
|
}
|
||||||
|
if cfg.Region != "us-east-1" {
|
||||||
|
t.Errorf("expected region 'us-east-1', got '%s'", cfg.Region)
|
||||||
|
}
|
||||||
|
if !cfg.UsePathStyle {
|
||||||
|
t.Error("expected usePathStyle to be true")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestS3KeyGeneration(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
jobID int64
|
||||||
|
cluster string
|
||||||
|
startTime int64
|
||||||
|
file string
|
||||||
|
expected string
|
||||||
|
}{
|
||||||
|
{1403244, "emmy", 1608923076, "meta.json", "emmy/1403/244/1608923076/meta.json"},
|
||||||
|
{1404397, "emmy", 1609300556, "data.json.gz", "emmy/1404/397/1609300556/data.json.gz"},
|
||||||
|
{42, "fritz", 1234567890, "meta.json", "fritz/0/042/1234567890/meta.json"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
job := &schema.Job{
|
||||||
|
JobID: tt.jobID,
|
||||||
|
Cluster: tt.cluster,
|
||||||
|
StartTime: tt.startTime,
|
||||||
|
}
|
||||||
|
|
||||||
|
key := getS3Key(job, tt.file)
|
||||||
|
if key != tt.expected {
|
||||||
|
t.Errorf("for job %d: expected %s, got %s", tt.jobID, tt.expected, key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,584 @@
|
|||||||
|
// 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 archive
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"compress/gzip"
|
||||||
|
"database/sql"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"math"
|
||||||
|
"os"
|
||||||
|
"strconv"
|
||||||
|
"text/tabwriter"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/ClusterCockpit/cc-backend/internal/config"
|
||||||
|
cclog "github.com/ClusterCockpit/cc-lib/ccLogger"
|
||||||
|
"github.com/ClusterCockpit/cc-lib/schema"
|
||||||
|
"github.com/ClusterCockpit/cc-lib/util"
|
||||||
|
_ "github.com/mattn/go-sqlite3"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SqliteArchiveConfig holds the configuration for the SQLite archive backend.
|
||||||
|
type SqliteArchiveConfig struct {
|
||||||
|
DBPath string `json:"dbPath"` // Path to SQLite database file
|
||||||
|
}
|
||||||
|
|
||||||
|
// SqliteArchive implements ArchiveBackend using a SQLite database with BLOB storage.
|
||||||
|
// Job metadata and data are stored as JSON BLOBs with indexes for fast queries.
|
||||||
|
//
|
||||||
|
// Uses WAL (Write-Ahead Logging) mode for better concurrency and a 64MB cache.
|
||||||
|
type SqliteArchive struct {
|
||||||
|
db *sql.DB // SQLite database connection
|
||||||
|
clusters []string // List of discovered cluster names
|
||||||
|
}
|
||||||
|
|
||||||
|
// sqliteSchema defines the database schema for SQLite archive backend.
|
||||||
|
// Jobs table: stores job metadata and data as BLOBs with compression flag
|
||||||
|
// Clusters table: stores cluster configurations
|
||||||
|
// Metadata table: stores version and other key-value pairs
|
||||||
|
const sqliteSchema = `
|
||||||
|
CREATE TABLE IF NOT EXISTS jobs (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
job_id INTEGER NOT NULL,
|
||||||
|
cluster TEXT NOT NULL,
|
||||||
|
start_time INTEGER NOT NULL,
|
||||||
|
meta_json BLOB NOT NULL,
|
||||||
|
data_json BLOB,
|
||||||
|
data_compressed BOOLEAN DEFAULT 0,
|
||||||
|
created_at INTEGER NOT NULL,
|
||||||
|
updated_at INTEGER NOT NULL,
|
||||||
|
UNIQUE(job_id, cluster, start_time)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_jobs_cluster ON jobs(cluster);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_jobs_start_time ON jobs(start_time);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_jobs_lookup ON jobs(cluster, job_id, start_time);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS clusters (
|
||||||
|
name TEXT PRIMARY KEY,
|
||||||
|
config_json BLOB NOT NULL,
|
||||||
|
updated_at INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS metadata (
|
||||||
|
key TEXT PRIMARY KEY,
|
||||||
|
value TEXT NOT NULL
|
||||||
|
);
|
||||||
|
`
|
||||||
|
|
||||||
|
func (sa *SqliteArchive) Init(rawConfig json.RawMessage) (uint64, error) {
|
||||||
|
var cfg SqliteArchiveConfig
|
||||||
|
if err := json.Unmarshal(rawConfig, &cfg); err != nil {
|
||||||
|
cclog.Warnf("SqliteArchive Init() > Unmarshal error: %#v", err)
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.DBPath == "" {
|
||||||
|
err := fmt.Errorf("SqliteArchive Init(): empty database path")
|
||||||
|
cclog.Errorf("SqliteArchive Init() > config error: %v", err)
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Open SQLite database
|
||||||
|
db, err := sql.Open("sqlite3", cfg.DBPath)
|
||||||
|
if err != nil {
|
||||||
|
cclog.Errorf("SqliteArchive Init() > failed to open database: %v", err)
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
sa.db = db
|
||||||
|
|
||||||
|
// Set pragmas for better performance
|
||||||
|
pragmas := []string{
|
||||||
|
"PRAGMA journal_mode=WAL",
|
||||||
|
"PRAGMA synchronous=NORMAL",
|
||||||
|
"PRAGMA cache_size=-64000", // 64MB cache
|
||||||
|
"PRAGMA busy_timeout=5000",
|
||||||
|
}
|
||||||
|
for _, pragma := range pragmas {
|
||||||
|
if _, err := sa.db.Exec(pragma); err != nil {
|
||||||
|
cclog.Warnf("SqliteArchive Init() > pragma failed: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create schema
|
||||||
|
if _, err := sa.db.Exec(sqliteSchema); err != nil {
|
||||||
|
cclog.Errorf("SqliteArchive Init() > schema creation failed: %v", err)
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check/set version
|
||||||
|
var versionStr string
|
||||||
|
err = sa.db.QueryRow("SELECT value FROM metadata WHERE key = 'version'").Scan(&versionStr)
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
// First time initialization, set version
|
||||||
|
_, err = sa.db.Exec("INSERT INTO metadata (key, value) VALUES ('version', ?)", fmt.Sprintf("%d", Version))
|
||||||
|
if err != nil {
|
||||||
|
cclog.Errorf("SqliteArchive Init() > failed to set version: %v", err)
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
versionStr = fmt.Sprintf("%d", Version)
|
||||||
|
} else if err != nil {
|
||||||
|
cclog.Errorf("SqliteArchive Init() > failed to read version: %v", err)
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
version, err := strconv.ParseUint(versionStr, 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
cclog.Errorf("SqliteArchive Init() > version parse error: %v", err)
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if version != Version {
|
||||||
|
return version, fmt.Errorf("unsupported version %d, need %d", version, Version)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Discover clusters
|
||||||
|
sa.clusters = []string{}
|
||||||
|
rows, err := sa.db.Query("SELECT DISTINCT cluster FROM jobs ORDER BY cluster")
|
||||||
|
if err != nil {
|
||||||
|
cclog.Errorf("SqliteArchive Init() > failed to query clusters: %v", err)
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
for rows.Next() {
|
||||||
|
var cluster string
|
||||||
|
if err := rows.Scan(&cluster); err != nil {
|
||||||
|
cclog.Errorf("SqliteArchive Init() > failed to scan cluster: %v", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
sa.clusters = append(sa.clusters, cluster)
|
||||||
|
}
|
||||||
|
|
||||||
|
cclog.Infof("SqliteArchive initialized with database '%s', found %d clusters", cfg.DBPath, len(sa.clusters))
|
||||||
|
return version, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (sa *SqliteArchive) Info() {
|
||||||
|
fmt.Printf("SQLite Job archive database\n")
|
||||||
|
|
||||||
|
ci := make(map[string]*clusterInfo)
|
||||||
|
|
||||||
|
rows, err := sa.db.Query(`
|
||||||
|
SELECT cluster, COUNT(*), MIN(start_time), MAX(start_time),
|
||||||
|
SUM(LENGTH(meta_json) + COALESCE(LENGTH(data_json), 0))
|
||||||
|
FROM jobs
|
||||||
|
GROUP BY cluster
|
||||||
|
`)
|
||||||
|
if err != nil {
|
||||||
|
cclog.Fatalf("SqliteArchive Info() > query failed: %s", err.Error())
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
for rows.Next() {
|
||||||
|
var cluster string
|
||||||
|
var numJobs int
|
||||||
|
var dateFirst, dateLast int64
|
||||||
|
var diskSize int64
|
||||||
|
|
||||||
|
if err := rows.Scan(&cluster, &numJobs, &dateFirst, &dateLast, &diskSize); err != nil {
|
||||||
|
cclog.Errorf("SqliteArchive Info() > scan failed: %v", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
ci[cluster] = &clusterInfo{
|
||||||
|
numJobs: numJobs,
|
||||||
|
dateFirst: dateFirst,
|
||||||
|
dateLast: dateLast,
|
||||||
|
diskSize: float64(diskSize) / (1024 * 1024), // Convert to MB
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
cit := clusterInfo{dateFirst: time.Now().Unix()}
|
||||||
|
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', tabwriter.Debug)
|
||||||
|
fmt.Fprintln(w, "cluster\t#jobs\tfrom\tto\tsize (MB)")
|
||||||
|
for cluster, clusterInfo := range ci {
|
||||||
|
fmt.Fprintf(w, "%s\t%d\t%s\t%s\t%.2f\n", cluster,
|
||||||
|
clusterInfo.numJobs,
|
||||||
|
time.Unix(clusterInfo.dateFirst, 0),
|
||||||
|
time.Unix(clusterInfo.dateLast, 0),
|
||||||
|
clusterInfo.diskSize)
|
||||||
|
|
||||||
|
cit.numJobs += clusterInfo.numJobs
|
||||||
|
cit.dateFirst = util.Min(cit.dateFirst, clusterInfo.dateFirst)
|
||||||
|
cit.dateLast = util.Max(cit.dateLast, clusterInfo.dateLast)
|
||||||
|
cit.diskSize += clusterInfo.diskSize
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Fprintf(w, "TOTAL\t%d\t%s\t%s\t%.2f\n",
|
||||||
|
cit.numJobs, time.Unix(cit.dateFirst, 0), time.Unix(cit.dateLast, 0), cit.diskSize)
|
||||||
|
w.Flush()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (sa *SqliteArchive) Exists(job *schema.Job) bool {
|
||||||
|
var count int
|
||||||
|
err := sa.db.QueryRow("SELECT COUNT(*) FROM jobs WHERE job_id = ? AND cluster = ? AND start_time = ?",
|
||||||
|
job.JobID, job.Cluster, job.StartTime).Scan(&count)
|
||||||
|
return err == nil && count > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func (sa *SqliteArchive) LoadJobMeta(job *schema.Job) (*schema.Job, error) {
|
||||||
|
var metaBlob []byte
|
||||||
|
err := sa.db.QueryRow("SELECT meta_json FROM jobs WHERE job_id = ? AND cluster = ? AND start_time = ?",
|
||||||
|
job.JobID, job.Cluster, job.StartTime).Scan(&metaBlob)
|
||||||
|
if err != nil {
|
||||||
|
cclog.Errorf("SqliteArchive LoadJobMeta() > query error: %v", err)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if config.Keys.Validate {
|
||||||
|
if err := schema.Validate(schema.Meta, bytes.NewReader(metaBlob)); err != nil {
|
||||||
|
return nil, fmt.Errorf("validate job meta: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return DecodeJobMeta(bytes.NewReader(metaBlob))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (sa *SqliteArchive) LoadJobData(job *schema.Job) (schema.JobData, error) {
|
||||||
|
var dataBlob []byte
|
||||||
|
var compressed bool
|
||||||
|
err := sa.db.QueryRow("SELECT data_json, data_compressed FROM jobs WHERE job_id = ? AND cluster = ? AND start_time = ?",
|
||||||
|
job.JobID, job.Cluster, job.StartTime).Scan(&dataBlob, &compressed)
|
||||||
|
if err != nil {
|
||||||
|
cclog.Errorf("SqliteArchive LoadJobData() > query error: %v", err)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var reader io.Reader = bytes.NewReader(dataBlob)
|
||||||
|
if compressed {
|
||||||
|
gzipReader, err := gzip.NewReader(reader)
|
||||||
|
if err != nil {
|
||||||
|
cclog.Errorf("SqliteArchive LoadJobData() > gzip error: %v", err)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer gzipReader.Close()
|
||||||
|
reader = gzipReader
|
||||||
|
}
|
||||||
|
|
||||||
|
if config.Keys.Validate {
|
||||||
|
data, _ := io.ReadAll(reader)
|
||||||
|
if err := schema.Validate(schema.Data, bytes.NewReader(data)); err != nil {
|
||||||
|
return schema.JobData{}, fmt.Errorf("validate job data: %v", err)
|
||||||
|
}
|
||||||
|
return DecodeJobData(bytes.NewReader(data), "sqlite")
|
||||||
|
}
|
||||||
|
|
||||||
|
return DecodeJobData(reader, "sqlite")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (sa *SqliteArchive) LoadJobStats(job *schema.Job) (schema.ScopedJobStats, error) {
|
||||||
|
var dataBlob []byte
|
||||||
|
var compressed bool
|
||||||
|
err := sa.db.QueryRow("SELECT data_json, data_compressed FROM jobs WHERE job_id = ? AND cluster = ? AND start_time = ?",
|
||||||
|
job.JobID, job.Cluster, job.StartTime).Scan(&dataBlob, &compressed)
|
||||||
|
if err != nil {
|
||||||
|
cclog.Errorf("SqliteArchive LoadJobStats() > query error: %v", err)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var reader io.Reader = bytes.NewReader(dataBlob)
|
||||||
|
if compressed {
|
||||||
|
gzipReader, err := gzip.NewReader(reader)
|
||||||
|
if err != nil {
|
||||||
|
cclog.Errorf("SqliteArchive LoadJobStats() > gzip error: %v", err)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer gzipReader.Close()
|
||||||
|
reader = gzipReader
|
||||||
|
}
|
||||||
|
|
||||||
|
if config.Keys.Validate {
|
||||||
|
data, _ := io.ReadAll(reader)
|
||||||
|
if err := schema.Validate(schema.Data, bytes.NewReader(data)); err != nil {
|
||||||
|
return nil, fmt.Errorf("validate job data: %v", err)
|
||||||
|
}
|
||||||
|
return DecodeJobStats(bytes.NewReader(data), "sqlite")
|
||||||
|
}
|
||||||
|
|
||||||
|
return DecodeJobStats(reader, "sqlite")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (sa *SqliteArchive) LoadClusterCfg(name string) (*schema.Cluster, error) {
|
||||||
|
var configBlob []byte
|
||||||
|
err := sa.db.QueryRow("SELECT config_json FROM clusters WHERE name = ?", name).Scan(&configBlob)
|
||||||
|
if err != nil {
|
||||||
|
cclog.Errorf("SqliteArchive LoadClusterCfg() > query error: %v", err)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := schema.Validate(schema.ClusterCfg, bytes.NewReader(configBlob)); err != nil {
|
||||||
|
cclog.Warnf("Validate cluster config: %v\n", err)
|
||||||
|
return &schema.Cluster{}, fmt.Errorf("validate cluster config: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return DecodeCluster(bytes.NewReader(configBlob))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (sa *SqliteArchive) StoreJobMeta(job *schema.Job) error {
|
||||||
|
var metaBuf bytes.Buffer
|
||||||
|
if err := EncodeJobMeta(&metaBuf, job); err != nil {
|
||||||
|
cclog.Error("SqliteArchive StoreJobMeta() > encoding error")
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now().Unix()
|
||||||
|
_, err := sa.db.Exec(`
|
||||||
|
INSERT INTO jobs (job_id, cluster, start_time, meta_json, created_at, updated_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?)
|
||||||
|
ON CONFLICT(job_id, cluster, start_time) DO UPDATE SET
|
||||||
|
meta_json = excluded.meta_json,
|
||||||
|
updated_at = excluded.updated_at
|
||||||
|
`, job.JobID, job.Cluster, job.StartTime, metaBuf.Bytes(), now, now)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
cclog.Errorf("SqliteArchive StoreJobMeta() > insert error: %v", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (sa *SqliteArchive) ImportJob(jobMeta *schema.Job, jobData *schema.JobData) error {
|
||||||
|
var metaBuf, dataBuf bytes.Buffer
|
||||||
|
if err := EncodeJobMeta(&metaBuf, jobMeta); err != nil {
|
||||||
|
cclog.Error("SqliteArchive ImportJob() > encoding meta error")
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := EncodeJobData(&dataBuf, jobData); err != nil {
|
||||||
|
cclog.Error("SqliteArchive ImportJob() > encoding data error")
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now().Unix()
|
||||||
|
_, err := sa.db.Exec(`
|
||||||
|
INSERT INTO jobs (job_id, cluster, start_time, meta_json, data_json, data_compressed, created_at, updated_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, 0, ?, ?)
|
||||||
|
ON CONFLICT(job_id, cluster, start_time) DO UPDATE SET
|
||||||
|
meta_json = excluded.meta_json,
|
||||||
|
data_json = excluded.data_json,
|
||||||
|
data_compressed = excluded.data_compressed,
|
||||||
|
updated_at = excluded.updated_at
|
||||||
|
`, jobMeta.JobID, jobMeta.Cluster, jobMeta.StartTime, metaBuf.Bytes(), dataBuf.Bytes(), now, now)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
cclog.Errorf("SqliteArchive ImportJob() > insert error: %v", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (sa *SqliteArchive) GetClusters() []string {
|
||||||
|
return sa.clusters
|
||||||
|
}
|
||||||
|
|
||||||
|
func (sa *SqliteArchive) CleanUp(jobs []*schema.Job) {
|
||||||
|
start := time.Now()
|
||||||
|
count := 0
|
||||||
|
|
||||||
|
tx, err := sa.db.Begin()
|
||||||
|
if err != nil {
|
||||||
|
cclog.Errorf("SqliteArchive CleanUp() > transaction error: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer tx.Rollback()
|
||||||
|
|
||||||
|
stmt, err := tx.Prepare("DELETE FROM jobs WHERE job_id = ? AND cluster = ? AND start_time = ?")
|
||||||
|
if err != nil {
|
||||||
|
cclog.Errorf("SqliteArchive CleanUp() > prepare error: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer stmt.Close()
|
||||||
|
|
||||||
|
for _, job := range jobs {
|
||||||
|
if job == nil {
|
||||||
|
cclog.Errorf("SqliteArchive CleanUp() error: job is nil")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := stmt.Exec(job.JobID, job.Cluster, job.StartTime); err != nil {
|
||||||
|
cclog.Errorf("SqliteArchive CleanUp() > delete error: %v", err)
|
||||||
|
} else {
|
||||||
|
count++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tx.Commit(); err != nil {
|
||||||
|
cclog.Errorf("SqliteArchive CleanUp() > commit error: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
cclog.Infof("Retention Service - Remove %d jobs from SQLite in %s", count, time.Since(start))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (sa *SqliteArchive) Move(jobs []*schema.Job, targetPath string) {
|
||||||
|
// For SQLite, "move" means updating the cluster field or similar
|
||||||
|
// This is interpretation-dependent; for now we'll just log
|
||||||
|
cclog.Warnf("SqliteArchive Move() is not fully implemented - moves within database not applicable")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (sa *SqliteArchive) Clean(before int64, after int64) {
|
||||||
|
if after == 0 {
|
||||||
|
after = math.MaxInt64
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := sa.db.Exec("DELETE FROM jobs WHERE start_time < ? OR start_time > ?", before, after)
|
||||||
|
if err != nil {
|
||||||
|
cclog.Fatalf("SqliteArchive Clean() > delete error: %s", err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
rowsAffected, _ := result.RowsAffected()
|
||||||
|
cclog.Infof("SqliteArchive Clean() removed %d jobs", rowsAffected)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (sa *SqliteArchive) Compress(jobs []*schema.Job) {
|
||||||
|
var cnt int
|
||||||
|
start := time.Now()
|
||||||
|
|
||||||
|
tx, err := sa.db.Begin()
|
||||||
|
if err != nil {
|
||||||
|
cclog.Errorf("SqliteArchive Compress() > transaction error: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer tx.Rollback()
|
||||||
|
|
||||||
|
stmt, err := tx.Prepare("UPDATE jobs SET data_json = ?, data_compressed = 1 WHERE job_id = ? AND cluster = ? AND start_time = ?")
|
||||||
|
if err != nil {
|
||||||
|
cclog.Errorf("SqliteArchive Compress() > prepare error: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer stmt.Close()
|
||||||
|
|
||||||
|
for _, job := range jobs {
|
||||||
|
var dataBlob []byte
|
||||||
|
var compressed bool
|
||||||
|
err := sa.db.QueryRow("SELECT data_json, data_compressed FROM jobs WHERE job_id = ? AND cluster = ? AND start_time = ?",
|
||||||
|
job.JobID, job.Cluster, job.StartTime).Scan(&dataBlob, &compressed)
|
||||||
|
if err != nil || compressed || len(dataBlob) < 2000 {
|
||||||
|
continue // Skip if error, already compressed, or too small
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compress the data
|
||||||
|
var compressedBuf bytes.Buffer
|
||||||
|
gzipWriter := gzip.NewWriter(&compressedBuf)
|
||||||
|
if _, err := gzipWriter.Write(dataBlob); err != nil {
|
||||||
|
cclog.Errorf("SqliteArchive Compress() > gzip error: %v", err)
|
||||||
|
gzipWriter.Close()
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
gzipWriter.Close()
|
||||||
|
|
||||||
|
if _, err := stmt.Exec(compressedBuf.Bytes(), job.JobID, job.Cluster, job.StartTime); err != nil {
|
||||||
|
cclog.Errorf("SqliteArchive Compress() > update error: %v", err)
|
||||||
|
} else {
|
||||||
|
cnt++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tx.Commit(); err != nil {
|
||||||
|
cclog.Errorf("SqliteArchive Compress() > commit error: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
cclog.Infof("Compression Service - %d jobs in SQLite took %s", cnt, time.Since(start))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (sa *SqliteArchive) CompressLast(starttime int64) int64 {
|
||||||
|
var lastStr string
|
||||||
|
err := sa.db.QueryRow("SELECT value FROM metadata WHERE key = 'compress_last'").Scan(&lastStr)
|
||||||
|
|
||||||
|
var last int64
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
last = starttime
|
||||||
|
} else if err != nil {
|
||||||
|
cclog.Errorf("SqliteArchive CompressLast() > query error: %v", err)
|
||||||
|
last = starttime
|
||||||
|
} else {
|
||||||
|
last, err = strconv.ParseInt(lastStr, 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
cclog.Errorf("SqliteArchive CompressLast() > parse error: %v", err)
|
||||||
|
last = starttime
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
cclog.Infof("SqliteArchive CompressLast() - start %d last %d", starttime, last)
|
||||||
|
|
||||||
|
// Update timestamp
|
||||||
|
_, err = sa.db.Exec(`
|
||||||
|
INSERT INTO metadata (key, value) VALUES ('compress_last', ?)
|
||||||
|
ON CONFLICT(key) DO UPDATE SET value = excluded.value
|
||||||
|
`, fmt.Sprintf("%d", starttime))
|
||||||
|
if err != nil {
|
||||||
|
cclog.Errorf("SqliteArchive CompressLast() > update error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return last
|
||||||
|
}
|
||||||
|
|
||||||
|
func (sa *SqliteArchive) Iter(loadMetricData bool) <-chan JobContainer {
|
||||||
|
ch := make(chan JobContainer)
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
defer close(ch)
|
||||||
|
|
||||||
|
rows, err := sa.db.Query("SELECT job_id, cluster, start_time, meta_json, data_json, data_compressed FROM jobs ORDER BY cluster, start_time")
|
||||||
|
if err != nil {
|
||||||
|
cclog.Fatalf("SqliteArchive Iter() > query error: %s", err.Error())
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
for rows.Next() {
|
||||||
|
var jobID int64
|
||||||
|
var cluster string
|
||||||
|
var startTime int64
|
||||||
|
var metaBlob []byte
|
||||||
|
var dataBlob []byte
|
||||||
|
var compressed bool
|
||||||
|
|
||||||
|
if err := rows.Scan(&jobID, &cluster, &startTime, &metaBlob, &dataBlob, &compressed); err != nil {
|
||||||
|
cclog.Errorf("SqliteArchive Iter() > scan error: %v", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
job, err := DecodeJobMeta(bytes.NewReader(metaBlob))
|
||||||
|
if err != nil {
|
||||||
|
cclog.Errorf("SqliteArchive Iter() > decode meta error: %v", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if loadMetricData && dataBlob != nil {
|
||||||
|
var reader io.Reader = bytes.NewReader(dataBlob)
|
||||||
|
if compressed {
|
||||||
|
gzipReader, err := gzip.NewReader(reader)
|
||||||
|
if err != nil {
|
||||||
|
cclog.Errorf("SqliteArchive Iter() > gzip error: %v", err)
|
||||||
|
ch <- JobContainer{Meta: job, Data: nil}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
defer gzipReader.Close()
|
||||||
|
reader = gzipReader
|
||||||
|
}
|
||||||
|
|
||||||
|
jobData, err := DecodeJobData(reader, "sqlite")
|
||||||
|
if err != nil {
|
||||||
|
cclog.Errorf("SqliteArchive Iter() > decode data error: %v", err)
|
||||||
|
ch <- JobContainer{Meta: job, Data: nil}
|
||||||
|
} else {
|
||||||
|
ch <- JobContainer{Meta: job, Data: &jobData}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
ch <- JobContainer{Meta: job, Data: nil}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
return ch
|
||||||
|
}
|
||||||
@@ -0,0 +1,313 @@
|
|||||||
|
// Copyright (C) NHR@FAU, University Erlangen-Nuremberg.
|
||||||
|
// All rights reserved.
|
||||||
|
// Use of this source code is governed by a MIT-style
|
||||||
|
// license that can be found in the LICENSE file.
|
||||||
|
package archive
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/ClusterCockpit/cc-lib/schema"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSqliteInitEmptyPath(t *testing.T) {
|
||||||
|
var sa SqliteArchive
|
||||||
|
_, err := sa.Init(json.RawMessage(`{"kind":"sqlite"}`))
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for empty database path")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSqliteInitInvalidConfig(t *testing.T) {
|
||||||
|
var sa SqliteArchive
|
||||||
|
_, err := sa.Init(json.RawMessage(`"dbPath":"/tmp/test.db"`))
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for invalid config")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSqliteInit(t *testing.T) {
|
||||||
|
tmpfile := t.TempDir() + "/test.db"
|
||||||
|
defer os.Remove(tmpfile)
|
||||||
|
|
||||||
|
var sa SqliteArchive
|
||||||
|
version, err := sa.Init(json.RawMessage(`{"dbPath":"` + tmpfile + `"}`))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("init failed: %v", err)
|
||||||
|
}
|
||||||
|
if version != Version {
|
||||||
|
t.Errorf("expected version %d, got %d", Version, version)
|
||||||
|
}
|
||||||
|
if sa.db == nil {
|
||||||
|
t.Fatal("database not initialized")
|
||||||
|
}
|
||||||
|
sa.db.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSqliteStoreAndLoadJobMeta(t *testing.T) {
|
||||||
|
tmpfile := t.TempDir() + "/test.db"
|
||||||
|
defer os.Remove(tmpfile)
|
||||||
|
|
||||||
|
var sa SqliteArchive
|
||||||
|
_, err := sa.Init(json.RawMessage(`{"dbPath":"` + tmpfile + `"}`))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("init failed: %v", err)
|
||||||
|
}
|
||||||
|
defer sa.db.Close()
|
||||||
|
|
||||||
|
job := &schema.Job{
|
||||||
|
JobID: 12345,
|
||||||
|
Cluster: "test-cluster",
|
||||||
|
StartTime: 1234567890,
|
||||||
|
NumNodes: 1,
|
||||||
|
Resources: []*schema.Resource{{Hostname: "node001"}},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store job metadata
|
||||||
|
if err := sa.StoreJobMeta(job); err != nil {
|
||||||
|
t.Fatalf("store failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if exists
|
||||||
|
if !sa.Exists(job) {
|
||||||
|
t.Fatal("job should exist")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load job metadata
|
||||||
|
loaded, err := sa.LoadJobMeta(job)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("load failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if loaded.JobID != job.JobID {
|
||||||
|
t.Errorf("expected JobID %d, got %d", job.JobID, loaded.JobID)
|
||||||
|
}
|
||||||
|
if loaded.Cluster != job.Cluster {
|
||||||
|
t.Errorf("expected Cluster %s, got %s", job.Cluster, loaded.Cluster)
|
||||||
|
}
|
||||||
|
if loaded.StartTime != job.StartTime {
|
||||||
|
t.Errorf("expected StartTime %d, got %d", job.StartTime, loaded.StartTime)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSqliteImportJob(t *testing.T) {
|
||||||
|
tmpfile := t.TempDir() + "/test.db"
|
||||||
|
defer os.Remove(tmpfile)
|
||||||
|
|
||||||
|
var sa SqliteArchive
|
||||||
|
_, err := sa.Init(json.RawMessage(`{"dbPath":"` + tmpfile + `"}`))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("init failed: %v", err)
|
||||||
|
}
|
||||||
|
defer sa.db.Close()
|
||||||
|
|
||||||
|
// For now, skip complex JobData testing
|
||||||
|
// Just test that ImportJob accepts the parameters
|
||||||
|
// Full integration testing would require actual job data files
|
||||||
|
t.Log("ImportJob interface verified (full data test requires integration)")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSqliteGetClusters(t *testing.T) {
|
||||||
|
tmpfile := t.TempDir() + "/test.db"
|
||||||
|
defer os.Remove(tmpfile)
|
||||||
|
|
||||||
|
var sa SqliteArchive
|
||||||
|
_, err := sa.Init(json.RawMessage(`{"dbPath":"` + tmpfile + `"}`))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("init failed: %v", err)
|
||||||
|
}
|
||||||
|
defer sa.db.Close()
|
||||||
|
|
||||||
|
// Add jobs from different clusters
|
||||||
|
job1 := &schema.Job{
|
||||||
|
JobID: 1,
|
||||||
|
Cluster: "cluster-a",
|
||||||
|
StartTime: 1000,
|
||||||
|
NumNodes: 1,
|
||||||
|
Resources: []*schema.Resource{{Hostname: "node001"}},
|
||||||
|
}
|
||||||
|
job2 := &schema.Job{
|
||||||
|
JobID: 2,
|
||||||
|
Cluster: "cluster-b",
|
||||||
|
StartTime: 2000,
|
||||||
|
NumNodes: 1,
|
||||||
|
Resources: []*schema.Resource{{Hostname: "node002"}},
|
||||||
|
}
|
||||||
|
|
||||||
|
sa.StoreJobMeta(job1)
|
||||||
|
sa.StoreJobMeta(job2)
|
||||||
|
|
||||||
|
// Reinitialize to refresh cluster list
|
||||||
|
sa.db.Close()
|
||||||
|
_, err = sa.Init(json.RawMessage(`{"dbPath":"` + tmpfile + `"}`))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("reinit failed: %v", err)
|
||||||
|
}
|
||||||
|
defer sa.db.Close()
|
||||||
|
|
||||||
|
clusters := sa.GetClusters()
|
||||||
|
if len(clusters) != 2 {
|
||||||
|
t.Errorf("expected 2 clusters, got %d", len(clusters))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSqliteCleanUp(t *testing.T) {
|
||||||
|
tmpfile := t.TempDir() + "/test.db"
|
||||||
|
defer os.Remove(tmpfile)
|
||||||
|
|
||||||
|
var sa SqliteArchive
|
||||||
|
_, err := sa.Init(json.RawMessage(`{"dbPath":"` + tmpfile + `"}`))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("init failed: %v", err)
|
||||||
|
}
|
||||||
|
defer sa.db.Close()
|
||||||
|
|
||||||
|
job := &schema.Job{
|
||||||
|
JobID: 999,
|
||||||
|
Cluster: "test",
|
||||||
|
StartTime: 5000,
|
||||||
|
NumNodes: 1,
|
||||||
|
Resources: []*schema.Resource{{Hostname: "node001"}},
|
||||||
|
}
|
||||||
|
|
||||||
|
sa.StoreJobMeta(job)
|
||||||
|
|
||||||
|
// Verify exists
|
||||||
|
if !sa.Exists(job) {
|
||||||
|
t.Fatal("job should exist")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean up
|
||||||
|
sa.CleanUp([]*schema.Job{job})
|
||||||
|
|
||||||
|
// Verify deleted
|
||||||
|
if sa.Exists(job) {
|
||||||
|
t.Fatal("job should not exist after cleanup")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSqliteClean(t *testing.T) {
|
||||||
|
tmpfile := t.TempDir() + "/test.db"
|
||||||
|
defer os.Remove(tmpfile)
|
||||||
|
|
||||||
|
var sa SqliteArchive
|
||||||
|
_, err := sa.Init(json.RawMessage(`{"dbPath":"` + tmpfile + `"}`))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("init failed: %v", err)
|
||||||
|
}
|
||||||
|
defer sa.db.Close()
|
||||||
|
|
||||||
|
// Add jobs with different start times
|
||||||
|
oldJob := &schema.Job{
|
||||||
|
JobID: 1,
|
||||||
|
Cluster: "test",
|
||||||
|
StartTime: 1000,
|
||||||
|
NumNodes: 1,
|
||||||
|
Resources: []*schema.Resource{{Hostname: "node001"}},
|
||||||
|
}
|
||||||
|
newJob := &schema.Job{
|
||||||
|
JobID: 2,
|
||||||
|
Cluster: "test",
|
||||||
|
StartTime: 9000,
|
||||||
|
NumNodes: 1,
|
||||||
|
Resources: []*schema.Resource{{Hostname: "node002"}},
|
||||||
|
}
|
||||||
|
|
||||||
|
sa.StoreJobMeta(oldJob)
|
||||||
|
sa.StoreJobMeta(newJob)
|
||||||
|
|
||||||
|
// Clean jobs before 5000
|
||||||
|
sa.Clean(5000, 0)
|
||||||
|
|
||||||
|
// Old job should be deleted
|
||||||
|
if sa.Exists(oldJob) {
|
||||||
|
t.Error("old job should be deleted")
|
||||||
|
}
|
||||||
|
|
||||||
|
// New job should still exist
|
||||||
|
if !sa.Exists(newJob) {
|
||||||
|
t.Error("new job should still exist")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSqliteIter(t *testing.T) {
|
||||||
|
tmpfile := t.TempDir() + "/test.db"
|
||||||
|
defer os.Remove(tmpfile)
|
||||||
|
|
||||||
|
var sa SqliteArchive
|
||||||
|
_, err := sa.Init(json.RawMessage(`{"dbPath":"` + tmpfile + `"}`))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("init failed: %v", err)
|
||||||
|
}
|
||||||
|
defer sa.db.Close()
|
||||||
|
|
||||||
|
// Add multiple jobs
|
||||||
|
for i := 1; i <= 3; i++ {
|
||||||
|
job := &schema.Job{
|
||||||
|
JobID: int64(i),
|
||||||
|
Cluster: "test",
|
||||||
|
StartTime: int64(i * 1000),
|
||||||
|
NumNodes: 1,
|
||||||
|
Resources: []*schema.Resource{{Hostname: "node001"}},
|
||||||
|
}
|
||||||
|
sa.StoreJobMeta(job)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Iterate
|
||||||
|
count := 0
|
||||||
|
for container := range sa.Iter(false) {
|
||||||
|
if container.Meta == nil {
|
||||||
|
t.Error("expected non-nil meta")
|
||||||
|
}
|
||||||
|
count++
|
||||||
|
}
|
||||||
|
|
||||||
|
if count != 3 {
|
||||||
|
t.Errorf("expected 3 jobs, got %d", count)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSqliteCompress(t *testing.T) {
|
||||||
|
// Compression test requires actual job data
|
||||||
|
// For now just verify the method exists and doesn't panic
|
||||||
|
tmpfile := t.TempDir() + "/test.db"
|
||||||
|
defer os.Remove(tmpfile)
|
||||||
|
|
||||||
|
var sa SqliteArchive
|
||||||
|
_, err := sa.Init(json.RawMessage(`{"dbPath":"` + tmpfile + `"}`))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("init failed: %v", err)
|
||||||
|
}
|
||||||
|
defer sa.db.Close()
|
||||||
|
|
||||||
|
job := &schema.Job{
|
||||||
|
JobID: 777,
|
||||||
|
Cluster: "test",
|
||||||
|
StartTime: 7777,
|
||||||
|
NumNodes: 1,
|
||||||
|
Resources: []*schema.Resource{{Hostname: "node001"}},
|
||||||
|
}
|
||||||
|
|
||||||
|
sa.StoreJobMeta(job)
|
||||||
|
|
||||||
|
// Compress should not panic even with missing data
|
||||||
|
sa.Compress([]*schema.Job{job})
|
||||||
|
|
||||||
|
t.Log("Compression method verified")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSqliteConfigParsing(t *testing.T) {
|
||||||
|
rawConfig := json.RawMessage(`{"dbPath": "/tmp/test.db"}`)
|
||||||
|
|
||||||
|
var cfg SqliteArchiveConfig
|
||||||
|
err := json.Unmarshal(rawConfig, &cfg)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to parse config: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.DBPath != "/tmp/test.db" {
|
||||||
|
t.Errorf("expected dbPath '/tmp/test.db', got '%s'", cfg.DBPath)
|
||||||
|
}
|
||||||
|
}
|
||||||
+1
-1
@@ -1 +1 @@
|
|||||||
2
|
3
|
||||||
|
|||||||
Executable
BIN
Binary file not shown.
@@ -0,0 +1,341 @@
|
|||||||
|
// 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 main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/ClusterCockpit/cc-backend/pkg/archive"
|
||||||
|
"github.com/ClusterCockpit/cc-lib/schema"
|
||||||
|
"github.com/ClusterCockpit/cc-lib/util"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestImportFileToSqlite tests importing jobs from file backend to SQLite backend
|
||||||
|
func TestImportFileToSqlite(t *testing.T) {
|
||||||
|
// Create temporary directories
|
||||||
|
tmpdir := t.TempDir()
|
||||||
|
srcArchive := filepath.Join(tmpdir, "src-archive")
|
||||||
|
dstDb := filepath.Join(tmpdir, "dst-archive.db")
|
||||||
|
|
||||||
|
// Copy test data to source archive
|
||||||
|
testDataPath := "../../pkg/archive/testdata/archive"
|
||||||
|
if _, err := os.Stat(testDataPath); os.IsNotExist(err) {
|
||||||
|
t.Skip("Test data not found, skipping integration test")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := util.CopyDir(testDataPath, srcArchive); err != nil {
|
||||||
|
t.Fatalf("Failed to copy test data: %s", err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize source backend (file)
|
||||||
|
srcConfig := fmt.Sprintf(`{"kind":"file","path":"%s"}`, srcArchive)
|
||||||
|
srcBackend, err := archive.InitBackend(json.RawMessage(srcConfig))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to initialize source backend: %s", err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize destination backend (sqlite)
|
||||||
|
dstConfig := fmt.Sprintf(`{"kind":"sqlite","dbPath":"%s"}`, dstDb)
|
||||||
|
dstBackend, err := archive.InitBackend(json.RawMessage(dstConfig))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to initialize destination backend: %s", err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Perform import
|
||||||
|
imported, failed, err := importArchive(srcBackend, dstBackend)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("Import failed: %s", err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
if imported == 0 {
|
||||||
|
t.Error("No jobs were imported")
|
||||||
|
}
|
||||||
|
|
||||||
|
if failed > 0 {
|
||||||
|
t.Errorf("%d jobs failed to import", failed)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Logf("Successfully imported %d jobs", imported)
|
||||||
|
|
||||||
|
// Verify jobs exist in destination
|
||||||
|
// Count jobs in source
|
||||||
|
srcCount := 0
|
||||||
|
for range srcBackend.Iter(false) {
|
||||||
|
srcCount++
|
||||||
|
}
|
||||||
|
|
||||||
|
// Count jobs in destination
|
||||||
|
dstCount := 0
|
||||||
|
for range dstBackend.Iter(false) {
|
||||||
|
dstCount++
|
||||||
|
}
|
||||||
|
|
||||||
|
if srcCount != dstCount {
|
||||||
|
t.Errorf("Job count mismatch: source has %d jobs, destination has %d jobs", srcCount, dstCount)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestImportFileToFile tests importing jobs from one file backend to another
|
||||||
|
func TestImportFileToFile(t *testing.T) {
|
||||||
|
// Create temporary directories
|
||||||
|
tmpdir := t.TempDir()
|
||||||
|
srcArchive := filepath.Join(tmpdir, "src-archive")
|
||||||
|
dstArchive := filepath.Join(tmpdir, "dst-archive")
|
||||||
|
|
||||||
|
// Copy test data to source archive
|
||||||
|
testDataPath := "../../pkg/archive/testdata/archive"
|
||||||
|
if _, err := os.Stat(testDataPath); os.IsNotExist(err) {
|
||||||
|
t.Skip("Test data not found, skipping integration test")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := util.CopyDir(testDataPath, srcArchive); err != nil {
|
||||||
|
t.Fatalf("Failed to copy test data: %s", err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create destination archive directory
|
||||||
|
if err := os.MkdirAll(dstArchive, 0755); err != nil {
|
||||||
|
t.Fatalf("Failed to create destination directory: %s", err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write version file
|
||||||
|
versionFile := filepath.Join(dstArchive, "version.txt")
|
||||||
|
if err := os.WriteFile(versionFile, []byte("3"), 0644); err != nil {
|
||||||
|
t.Fatalf("Failed to write version file: %s", err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize source backend
|
||||||
|
srcConfig := fmt.Sprintf(`{"kind":"file","path":"%s"}`, srcArchive)
|
||||||
|
srcBackend, err := archive.InitBackend(json.RawMessage(srcConfig))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to initialize source backend: %s", err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize destination backend
|
||||||
|
dstConfig := fmt.Sprintf(`{"kind":"file","path":"%s"}`, dstArchive)
|
||||||
|
dstBackend, err := archive.InitBackend(json.RawMessage(dstConfig))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to initialize destination backend: %s", err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Perform import
|
||||||
|
imported, failed, err := importArchive(srcBackend, dstBackend)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("Import failed: %s", err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
if imported == 0 {
|
||||||
|
t.Error("No jobs were imported")
|
||||||
|
}
|
||||||
|
|
||||||
|
if failed > 0 {
|
||||||
|
t.Errorf("%d jobs failed to import", failed)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Logf("Successfully imported %d jobs", imported)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestImportDataIntegrity verifies that job metadata and data are correctly imported
|
||||||
|
func TestImportDataIntegrity(t *testing.T) {
|
||||||
|
// Create temporary directories
|
||||||
|
tmpdir := t.TempDir()
|
||||||
|
srcArchive := filepath.Join(tmpdir, "src-archive")
|
||||||
|
dstDb := filepath.Join(tmpdir, "dst-archive.db")
|
||||||
|
|
||||||
|
// Copy test data to source archive
|
||||||
|
testDataPath := "../../pkg/archive/testdata/archive"
|
||||||
|
if _, err := os.Stat(testDataPath); os.IsNotExist(err) {
|
||||||
|
t.Skip("Test data not found, skipping integration test")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := util.CopyDir(testDataPath, srcArchive); err != nil {
|
||||||
|
t.Fatalf("Failed to copy test data: %s", err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize backends
|
||||||
|
srcConfig := fmt.Sprintf(`{"kind":"file","path":"%s"}`, srcArchive)
|
||||||
|
srcBackend, err := archive.InitBackend(json.RawMessage(srcConfig))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to initialize source backend: %s", err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
dstConfig := fmt.Sprintf(`{"kind":"sqlite","dbPath":"%s"}`, dstDb)
|
||||||
|
dstBackend, err := archive.InitBackend(json.RawMessage(dstConfig))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to initialize destination backend: %s", err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Perform import
|
||||||
|
_, _, err = importArchive(srcBackend, dstBackend)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("Import failed: %s", err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify data integrity for each job
|
||||||
|
verifiedJobs := 0
|
||||||
|
for srcJob := range srcBackend.Iter(false) {
|
||||||
|
if srcJob.Meta == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load job from destination
|
||||||
|
dstJobMeta, err := dstBackend.LoadJobMeta(srcJob.Meta)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("Failed to load job %d from destination: %s", srcJob.Meta.JobID, err.Error())
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify basic metadata
|
||||||
|
if dstJobMeta.JobID != srcJob.Meta.JobID {
|
||||||
|
t.Errorf("JobID mismatch: expected %d, got %d", srcJob.Meta.JobID, dstJobMeta.JobID)
|
||||||
|
}
|
||||||
|
|
||||||
|
if dstJobMeta.Cluster != srcJob.Meta.Cluster {
|
||||||
|
t.Errorf("Cluster mismatch for job %d: expected %s, got %s",
|
||||||
|
srcJob.Meta.JobID, srcJob.Meta.Cluster, dstJobMeta.Cluster)
|
||||||
|
}
|
||||||
|
|
||||||
|
if dstJobMeta.StartTime != srcJob.Meta.StartTime {
|
||||||
|
t.Errorf("StartTime mismatch for job %d: expected %d, got %d",
|
||||||
|
srcJob.Meta.JobID, srcJob.Meta.StartTime, dstJobMeta.StartTime)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load and verify job data
|
||||||
|
srcData, err := srcBackend.LoadJobData(srcJob.Meta)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("Failed to load job data from source: %s", err.Error())
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
dstData, err := dstBackend.LoadJobData(srcJob.Meta)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("Failed to load job data from destination: %s", err.Error())
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify metric data exists
|
||||||
|
if len(srcData) != len(dstData) {
|
||||||
|
t.Errorf("Metric count mismatch for job %d: expected %d, got %d",
|
||||||
|
srcJob.Meta.JobID, len(srcData), len(dstData))
|
||||||
|
}
|
||||||
|
|
||||||
|
verifiedJobs++
|
||||||
|
}
|
||||||
|
|
||||||
|
if verifiedJobs == 0 {
|
||||||
|
t.Error("No jobs were verified")
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Logf("Successfully verified %d jobs", verifiedJobs)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestImportEmptyArchive tests importing from an empty archive
|
||||||
|
func TestImportEmptyArchive(t *testing.T) {
|
||||||
|
tmpdir := t.TempDir()
|
||||||
|
srcArchive := filepath.Join(tmpdir, "empty-archive")
|
||||||
|
dstDb := filepath.Join(tmpdir, "dst-archive.db")
|
||||||
|
|
||||||
|
// Create empty source archive
|
||||||
|
if err := os.MkdirAll(srcArchive, 0755); err != nil {
|
||||||
|
t.Fatalf("Failed to create source directory: %s", err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write version file
|
||||||
|
versionFile := filepath.Join(srcArchive, "version.txt")
|
||||||
|
if err := os.WriteFile(versionFile, []byte("3"), 0644); err != nil {
|
||||||
|
t.Fatalf("Failed to write version file: %s", err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize backends
|
||||||
|
srcConfig := fmt.Sprintf(`{"kind":"file","path":"%s"}`, srcArchive)
|
||||||
|
srcBackend, err := archive.InitBackend(json.RawMessage(srcConfig))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to initialize source backend: %s", err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
dstConfig := fmt.Sprintf(`{"kind":"sqlite","dbPath":"%s"}`, dstDb)
|
||||||
|
dstBackend, err := archive.InitBackend(json.RawMessage(dstConfig))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to initialize destination backend: %s", err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Perform import
|
||||||
|
imported, failed, err := importArchive(srcBackend, dstBackend)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("Import from empty archive should not fail: %s", err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
if imported != 0 {
|
||||||
|
t.Errorf("Expected 0 imported jobs, got %d", imported)
|
||||||
|
}
|
||||||
|
|
||||||
|
if failed != 0 {
|
||||||
|
t.Errorf("Expected 0 failed jobs, got %d", failed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestImportDuplicateJobs tests that duplicate jobs are skipped
|
||||||
|
func TestImportDuplicateJobs(t *testing.T) {
|
||||||
|
tmpdir := t.TempDir()
|
||||||
|
srcArchive := filepath.Join(tmpdir, "src-archive")
|
||||||
|
dstDb := filepath.Join(tmpdir, "dst-archive.db")
|
||||||
|
|
||||||
|
// Copy test data
|
||||||
|
testDataPath := "../../pkg/archive/testdata/archive"
|
||||||
|
if _, err := os.Stat(testDataPath); os.IsNotExist(err) {
|
||||||
|
t.Skip("Test data not found, skipping integration test")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := util.CopyDir(testDataPath, srcArchive); err != nil {
|
||||||
|
t.Fatalf("Failed to copy test data: %s", err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize backends
|
||||||
|
srcConfig := fmt.Sprintf(`{"kind":"file","path":"%s"}`, srcArchive)
|
||||||
|
srcBackend, err := archive.InitBackend(json.RawMessage(srcConfig))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to initialize source backend: %s", err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
dstConfig := fmt.Sprintf(`{"kind":"sqlite","dbPath":"%s"}`, dstDb)
|
||||||
|
dstBackend, err := archive.InitBackend(json.RawMessage(dstConfig))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to initialize destination backend: %s", err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
// First import
|
||||||
|
imported1, _, err := importArchive(srcBackend, dstBackend)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("First import failed: %s", err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Second import (should skip all jobs)
|
||||||
|
imported2, _, err := importArchive(srcBackend, dstBackend)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("Second import failed: %s", err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
if imported2 != 0 {
|
||||||
|
t.Errorf("Second import should skip all jobs, but imported %d", imported2)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Logf("First import: %d jobs, Second import: %d jobs (all skipped as expected)", imported1, imported2)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestJobStub is a helper test to verify that the job stub used in tests matches the schema
|
||||||
|
func TestJobStub(t *testing.T) {
|
||||||
|
job := &schema.Job{
|
||||||
|
JobID: 123,
|
||||||
|
Cluster: "test-cluster",
|
||||||
|
StartTime: 1234567890,
|
||||||
|
}
|
||||||
|
|
||||||
|
if job.JobID != 123 {
|
||||||
|
t.Errorf("Expected JobID 123, got %d", job.JobID)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,6 +9,8 @@ import (
|
|||||||
"flag"
|
"flag"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/ClusterCockpit/cc-backend/internal/config"
|
"github.com/ClusterCockpit/cc-backend/internal/config"
|
||||||
@@ -31,9 +33,106 @@ func parseDate(in string) int64 {
|
|||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// importArchive imports all jobs from a source archive backend to a destination archive backend.
|
||||||
|
// It uses parallel processing with a worker pool to improve performance.
|
||||||
|
// Returns the number of successfully imported jobs, failed jobs, and any error encountered.
|
||||||
|
func importArchive(srcBackend, dstBackend archive.ArchiveBackend) (int, int, error) {
|
||||||
|
cclog.Info("Starting parallel archive import...")
|
||||||
|
|
||||||
|
// Use atomic counters for thread-safe updates
|
||||||
|
var imported int32
|
||||||
|
var failed int32
|
||||||
|
var skipped int32
|
||||||
|
|
||||||
|
// Number of parallel workers
|
||||||
|
numWorkers := 4
|
||||||
|
cclog.Infof("Using %d parallel workers", numWorkers)
|
||||||
|
|
||||||
|
// Create channels for job distribution
|
||||||
|
jobs := make(chan archive.JobContainer, numWorkers*2)
|
||||||
|
|
||||||
|
// WaitGroup to track worker completion
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
|
||||||
|
// Start worker goroutines
|
||||||
|
for i := 0; i < numWorkers; i++ {
|
||||||
|
wg.Add(1)
|
||||||
|
go func(workerID int) {
|
||||||
|
defer wg.Done()
|
||||||
|
|
||||||
|
for job := range jobs {
|
||||||
|
// Validate job metadata
|
||||||
|
if job.Meta == nil {
|
||||||
|
cclog.Warn("Skipping job with nil metadata")
|
||||||
|
atomic.AddInt32(&failed, 1)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate job data
|
||||||
|
if job.Data == nil {
|
||||||
|
cclog.Warnf("Job %d from cluster %s has no metric data, skipping",
|
||||||
|
job.Meta.JobID, job.Meta.Cluster)
|
||||||
|
atomic.AddInt32(&failed, 1)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if job already exists in destination
|
||||||
|
if dstBackend.Exists(job.Meta) {
|
||||||
|
cclog.Debugf("Job %d (cluster: %s, start: %d) already exists in destination, skipping",
|
||||||
|
job.Meta.JobID, job.Meta.Cluster, job.Meta.StartTime)
|
||||||
|
atomic.AddInt32(&skipped, 1)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Import job to destination
|
||||||
|
if err := dstBackend.ImportJob(job.Meta, job.Data); err != nil {
|
||||||
|
cclog.Errorf("Failed to import job %d from cluster %s: %s",
|
||||||
|
job.Meta.JobID, job.Meta.Cluster, err.Error())
|
||||||
|
atomic.AddInt32(&failed, 1)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Successfully imported
|
||||||
|
newCount := atomic.AddInt32(&imported, 1)
|
||||||
|
if newCount%100 == 0 {
|
||||||
|
cclog.Infof("Progress: %d jobs imported, %d skipped, %d failed",
|
||||||
|
newCount, atomic.LoadInt32(&skipped), atomic.LoadInt32(&failed))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}(i)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Feed jobs to workers
|
||||||
|
go func() {
|
||||||
|
for job := range srcBackend.Iter(true) {
|
||||||
|
jobs <- job
|
||||||
|
}
|
||||||
|
close(jobs)
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Wait for all workers to complete
|
||||||
|
wg.Wait()
|
||||||
|
|
||||||
|
finalImported := int(atomic.LoadInt32(&imported))
|
||||||
|
finalFailed := int(atomic.LoadInt32(&failed))
|
||||||
|
finalSkipped := int(atomic.LoadInt32(&skipped))
|
||||||
|
|
||||||
|
cclog.Infof("Import completed: %d jobs imported, %d skipped, %d failed",
|
||||||
|
finalImported, finalSkipped, finalFailed)
|
||||||
|
|
||||||
|
if finalFailed > 0 {
|
||||||
|
return finalImported, finalFailed, fmt.Errorf("%d jobs failed to import", finalFailed)
|
||||||
|
}
|
||||||
|
|
||||||
|
return finalImported, finalFailed, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
var srcPath, flagConfigFile, flagLogLevel, flagRemoveCluster, flagRemoveAfter, flagRemoveBefore string
|
var srcPath, flagConfigFile, flagLogLevel, flagRemoveCluster, flagRemoveAfter, flagRemoveBefore string
|
||||||
var flagLogDateTime, flagValidate bool
|
var flagSrcConfig, flagDstConfig string
|
||||||
|
var flagLogDateTime, flagValidate, flagImport bool
|
||||||
|
|
||||||
flag.StringVar(&srcPath, "s", "./var/job-archive", "Specify the source job archive path. Default is ./var/job-archive")
|
flag.StringVar(&srcPath, "s", "./var/job-archive", "Specify the source job archive path. Default is ./var/job-archive")
|
||||||
flag.BoolVar(&flagLogDateTime, "logdate", false, "Set this flag to add date and time to log messages")
|
flag.BoolVar(&flagLogDateTime, "logdate", false, "Set this flag to add date and time to log messages")
|
||||||
@@ -43,14 +142,54 @@ func main() {
|
|||||||
flag.StringVar(&flagRemoveBefore, "remove-before", "", "Remove all jobs with start time before date (Format: 2006-Jan-04)")
|
flag.StringVar(&flagRemoveBefore, "remove-before", "", "Remove all jobs with start time before date (Format: 2006-Jan-04)")
|
||||||
flag.StringVar(&flagRemoveAfter, "remove-after", "", "Remove all jobs with start time after date (Format: 2006-Jan-04)")
|
flag.StringVar(&flagRemoveAfter, "remove-after", "", "Remove all jobs with start time after date (Format: 2006-Jan-04)")
|
||||||
flag.BoolVar(&flagValidate, "validate", false, "Set this flag to validate a job archive against the json schema")
|
flag.BoolVar(&flagValidate, "validate", false, "Set this flag to validate a job archive against the json schema")
|
||||||
|
flag.BoolVar(&flagImport, "import", false, "Import jobs from source archive to destination archive")
|
||||||
|
flag.StringVar(&flagSrcConfig, "src-config", "", "Source archive backend configuration (JSON), e.g. '{\"kind\":\"file\",\"path\":\"./archive\"}'")
|
||||||
|
flag.StringVar(&flagDstConfig, "dst-config", "", "Destination archive backend configuration (JSON), e.g. '{\"kind\":\"sqlite\",\"dbPath\":\"./archive.db\"}'")
|
||||||
flag.Parse()
|
flag.Parse()
|
||||||
|
|
||||||
|
|
||||||
archiveCfg := fmt.Sprintf("{\"kind\": \"file\",\"path\": \"%s\"}", srcPath)
|
archiveCfg := fmt.Sprintf("{\"kind\": \"file\",\"path\": \"%s\"}", srcPath)
|
||||||
|
|
||||||
cclog.Init(flagLogLevel, flagLogDateTime)
|
cclog.Init(flagLogLevel, flagLogDateTime)
|
||||||
|
|
||||||
|
// Handle import mode
|
||||||
|
if flagImport {
|
||||||
|
if flagSrcConfig == "" || flagDstConfig == "" {
|
||||||
|
cclog.Fatal("Both --src-config and --dst-config must be specified for import mode")
|
||||||
|
}
|
||||||
|
|
||||||
|
cclog.Info("Import mode: initializing source and destination backends...")
|
||||||
|
|
||||||
|
// Initialize source backend
|
||||||
|
srcBackend, err := archive.InitBackend(json.RawMessage(flagSrcConfig))
|
||||||
|
if err != nil {
|
||||||
|
cclog.Fatalf("Failed to initialize source backend: %s", err.Error())
|
||||||
|
}
|
||||||
|
cclog.Info("Source backend initialized successfully")
|
||||||
|
|
||||||
|
// Initialize destination backend
|
||||||
|
dstBackend, err := archive.InitBackend(json.RawMessage(flagDstConfig))
|
||||||
|
if err != nil {
|
||||||
|
cclog.Fatalf("Failed to initialize destination backend: %s", err.Error())
|
||||||
|
}
|
||||||
|
cclog.Info("Destination backend initialized successfully")
|
||||||
|
|
||||||
|
// Perform import
|
||||||
|
imported, failed, err := importArchive(srcBackend, dstBackend)
|
||||||
|
if err != nil {
|
||||||
|
cclog.Errorf("Import completed with errors: %s", err.Error())
|
||||||
|
if failed > 0 {
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
cclog.Infof("Import finished successfully: %d jobs imported", imported)
|
||||||
|
os.Exit(0)
|
||||||
|
}
|
||||||
|
|
||||||
ccconf.Init(flagConfigFile)
|
ccconf.Init(flagConfigFile)
|
||||||
|
|
||||||
|
|
||||||
// Load and check main configuration
|
// Load and check main configuration
|
||||||
if cfg := ccconf.GetPackageConfig("main"); cfg != nil {
|
if cfg := ccconf.GetPackageConfig("main"); cfg != nil {
|
||||||
if clustercfg := ccconf.GetPackageConfig("clusters"); clustercfg != nil {
|
if clustercfg := ccconf.GetPackageConfig("clusters"); clustercfg != nil {
|
||||||
|
|||||||
@@ -0,0 +1,133 @@
|
|||||||
|
# Archive Migration Tool
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
The `archive-migration` tool migrates job archives from old schema versions to the current schema version. It handles schema changes such as the `exclusive` → `shared` field transformation and adds/removes fields as needed.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- **Parallel Processing**: Uses worker pool for fast migration
|
||||||
|
- **Dry-Run Mode**: Preview changes without modifying files
|
||||||
|
- **Safe Transformations**: Applies well-defined schema transformations
|
||||||
|
- **Progress Reporting**: Shows real-time migration progress
|
||||||
|
- **Error Handling**: Continues on individual failures, reports at end
|
||||||
|
|
||||||
|
## Schema Transformations
|
||||||
|
|
||||||
|
### Exclusive → Shared
|
||||||
|
Converts the old `exclusive` integer field to the new `shared` string field:
|
||||||
|
- `0` → `"multi_user"`
|
||||||
|
- `1` → `"none"`
|
||||||
|
- `2` → `"single_user"`
|
||||||
|
|
||||||
|
### Missing Fields
|
||||||
|
Adds fields required by current schema:
|
||||||
|
- `submitTime`: Defaults to `startTime` if missing
|
||||||
|
- `energy`: Defaults to `0.0`
|
||||||
|
- `requestedMemory`: Defaults to `0`
|
||||||
|
- `shared`: Defaults to `"none"` if still missing after transformation
|
||||||
|
|
||||||
|
### Deprecated Fields
|
||||||
|
Removes fields no longer in schema:
|
||||||
|
- `mem_used_max`, `flops_any_avg`, `mem_bw_avg`
|
||||||
|
- `load_avg`, `net_bw_avg`, `net_data_vol_total`
|
||||||
|
- `file_bw_avg`, `file_data_vol_total`
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
### Build
|
||||||
|
```bash
|
||||||
|
cd /Users/jan/prg/cc-backend/tools/archive-migration
|
||||||
|
go build
|
||||||
|
```
|
||||||
|
|
||||||
|
### Dry Run (Preview Changes)
|
||||||
|
```bash
|
||||||
|
./archive-migration --archive /path/to/archive --dry-run
|
||||||
|
```
|
||||||
|
|
||||||
|
### Migrate Archive
|
||||||
|
```bash
|
||||||
|
# IMPORTANT: Backup your archive first!
|
||||||
|
cp -r /path/to/archive /path/to/archive-backup
|
||||||
|
|
||||||
|
# Run migration
|
||||||
|
./archive-migration --archive /path/to/archive
|
||||||
|
```
|
||||||
|
|
||||||
|
### Command-Line Options
|
||||||
|
|
||||||
|
- `--archive <path>`: Path to job archive (required)
|
||||||
|
- `--dry-run`: Preview changes without modifying files
|
||||||
|
- `--workers <n>`: Number of parallel workers (default: 4)
|
||||||
|
- `--loglevel <level>`: Logging level: debug, info, warn, err, fatal, crit (default: info)
|
||||||
|
- `--logdate`: Add timestamps to log messages
|
||||||
|
|
||||||
|
## Examples
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Preview what would change
|
||||||
|
./archive-migration --archive ./var/job-archive --dry-run
|
||||||
|
|
||||||
|
# Migrate with verbose logging
|
||||||
|
./archive-migration --archive ./var/job-archive --loglevel debug
|
||||||
|
|
||||||
|
# Migrate with 8 workers for faster processing
|
||||||
|
./archive-migration --archive ./var/job-archive --workers 8
|
||||||
|
```
|
||||||
|
|
||||||
|
## Safety
|
||||||
|
|
||||||
|
> [!CAUTION]
|
||||||
|
> **Always backup your archive before running migration!**
|
||||||
|
|
||||||
|
The tool modifies `meta.json` files in place. While transformations are designed to be safe, unexpected issues could occur. Follow these safety practices:
|
||||||
|
|
||||||
|
1. **Always run with `--dry-run` first** to preview changes
|
||||||
|
2. **Backup your archive** before migration
|
||||||
|
3. **Test on a copy** of your archive first
|
||||||
|
4. **Verify results** after migration
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
After migration, verify the archive:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Use archive-manager to check the archive
|
||||||
|
cd ../archive-manager
|
||||||
|
./archive-manager -s /path/to/migrated-archive
|
||||||
|
|
||||||
|
# Or validate specific jobs
|
||||||
|
./archive-manager -s /path/to/migrated-archive --validate
|
||||||
|
```
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Migration Failures
|
||||||
|
|
||||||
|
If individual jobs fail to migrate:
|
||||||
|
- Check the error messages for specific files
|
||||||
|
- Examine the failing `meta.json` files manually
|
||||||
|
- Fix invalid JSON or unexpected field types
|
||||||
|
- Re-run migration (already-migrated jobs will be processed again)
|
||||||
|
|
||||||
|
### Performance
|
||||||
|
|
||||||
|
For large archives:
|
||||||
|
- Increase `--workers` for more parallelism
|
||||||
|
- Use `--loglevel warn` to reduce log output
|
||||||
|
- Monitor disk I/O if migration is slow
|
||||||
|
|
||||||
|
## Technical Details
|
||||||
|
|
||||||
|
The migration process:
|
||||||
|
1. Walks archive directory recursively
|
||||||
|
2. Finds all `meta.json` files
|
||||||
|
3. Distributes jobs to worker pool
|
||||||
|
4. For each job:
|
||||||
|
- Reads JSON file
|
||||||
|
- Applies transformations in order
|
||||||
|
- Writes back migrated data (if not dry-run)
|
||||||
|
5. Reports statistics and errors
|
||||||
|
|
||||||
|
Transformations are idempotent - running migration multiple times is safe (though not recommended for performance).
|
||||||
Executable
BIN
Binary file not shown.
@@ -0,0 +1,67 @@
|
|||||||
|
// 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 main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
cclog "github.com/ClusterCockpit/cc-lib/ccLogger"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
var archivePath string
|
||||||
|
var dryRun bool
|
||||||
|
var numWorkers int
|
||||||
|
var flagLogLevel string
|
||||||
|
var flagLogDateTime bool
|
||||||
|
|
||||||
|
flag.StringVar(&archivePath, "archive", "", "Path to job archive to migrate (required)")
|
||||||
|
flag.BoolVar(&dryRun, "dry-run", false, "Preview changes without modifying files")
|
||||||
|
flag.IntVar(&numWorkers, "workers", 4, "Number of parallel workers")
|
||||||
|
flag.StringVar(&flagLogLevel, "loglevel", "info", "Sets the logging level: `[debug,info,warn (default),err,fatal,crit]`")
|
||||||
|
flag.BoolVar(&flagLogDateTime, "logdate", false, "Add date and time to log messages")
|
||||||
|
flag.Parse()
|
||||||
|
|
||||||
|
// Initialize logger
|
||||||
|
cclog.Init(flagLogLevel, flagLogDateTime)
|
||||||
|
|
||||||
|
// Validate inputs
|
||||||
|
if archivePath == "" {
|
||||||
|
fmt.Fprintf(os.Stderr, "Error: --archive flag is required\n\n")
|
||||||
|
flag.Usage()
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if archive path exists
|
||||||
|
if _, err := os.Stat(archivePath); os.IsNotExist(err) {
|
||||||
|
cclog.Fatalf("Archive path does not exist: %s", archivePath)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Display warning for non-dry-run mode
|
||||||
|
if !dryRun {
|
||||||
|
cclog.Warn("WARNING: This will modify files in the archive!")
|
||||||
|
cclog.Warn("It is strongly recommended to backup your archive first.")
|
||||||
|
cclog.Warn("Run with --dry-run first to preview changes.")
|
||||||
|
cclog.Info("")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run migration
|
||||||
|
migrated, failed, err := migrateArchive(archivePath, dryRun, numWorkers)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
cclog.Errorf("Migration completed with errors: %s", err.Error())
|
||||||
|
if failed > 0 {
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if dryRun {
|
||||||
|
cclog.Infof("Dry run completed: %d jobs would be migrated", migrated)
|
||||||
|
} else {
|
||||||
|
cclog.Infof("Migration completed successfully: %d jobs migrated", migrated)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,232 @@
|
|||||||
|
// 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 main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
|
|
||||||
|
cclog "github.com/ClusterCockpit/cc-lib/ccLogger"
|
||||||
|
)
|
||||||
|
|
||||||
|
// transformExclusiveToShared converts the old 'exclusive' field to the new 'shared' field
|
||||||
|
// Mapping: 0 -> "multi_user", 1 -> "none", 2 -> "single_user"
|
||||||
|
func transformExclusiveToShared(jobData map[string]interface{}) error {
|
||||||
|
// Check if 'exclusive' field exists
|
||||||
|
if exclusive, ok := jobData["exclusive"]; ok {
|
||||||
|
var exclusiveVal int
|
||||||
|
|
||||||
|
// Handle both int and float64 (JSON unmarshaling can produce float64)
|
||||||
|
switch v := exclusive.(type) {
|
||||||
|
case float64:
|
||||||
|
exclusiveVal = int(v)
|
||||||
|
case int:
|
||||||
|
exclusiveVal = v
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("exclusive field has unexpected type: %T", exclusive)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Map exclusive to shared
|
||||||
|
var shared string
|
||||||
|
switch exclusiveVal {
|
||||||
|
case 0:
|
||||||
|
shared = "multi_user"
|
||||||
|
case 1:
|
||||||
|
shared = "none"
|
||||||
|
case 2:
|
||||||
|
shared = "single_user"
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("invalid exclusive value: %d", exclusiveVal)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add shared field and remove exclusive
|
||||||
|
jobData["shared"] = shared
|
||||||
|
delete(jobData, "exclusive")
|
||||||
|
|
||||||
|
cclog.Debugf("Transformed exclusive=%d to shared=%s", exclusiveVal, shared)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// addMissingFields adds fields that are required in the current schema but might be missing in old archives
|
||||||
|
func addMissingFields(jobData map[string]interface{}) error {
|
||||||
|
// Add submitTime if missing (default to startTime)
|
||||||
|
if _, ok := jobData["submitTime"]; !ok {
|
||||||
|
if startTime, ok := jobData["startTime"]; ok {
|
||||||
|
jobData["submitTime"] = startTime
|
||||||
|
cclog.Debug("Added submitTime (defaulted to startTime)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add energy if missing (default to 0.0)
|
||||||
|
if _, ok := jobData["energy"]; !ok {
|
||||||
|
jobData["energy"] = 0.0
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add requestedMemory if missing (default to 0)
|
||||||
|
if _, ok := jobData["requestedMemory"]; !ok {
|
||||||
|
jobData["requestedMemory"] = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure shared field exists (if still missing, default to "none")
|
||||||
|
if _, ok := jobData["shared"]; !ok {
|
||||||
|
jobData["shared"] = "none"
|
||||||
|
cclog.Debug("Added default shared field: none")
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// removeDeprecatedFields removes fields that are no longer in the current schema
|
||||||
|
func removeDeprecatedFields(jobData map[string]interface{}) error {
|
||||||
|
// List of deprecated fields to remove
|
||||||
|
deprecatedFields := []string{
|
||||||
|
"mem_used_max",
|
||||||
|
"flops_any_avg",
|
||||||
|
"mem_bw_avg",
|
||||||
|
"load_avg",
|
||||||
|
"net_bw_avg",
|
||||||
|
"net_data_vol_total",
|
||||||
|
"file_bw_avg",
|
||||||
|
"file_data_vol_total",
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, field := range deprecatedFields {
|
||||||
|
if _, ok := jobData[field]; ok {
|
||||||
|
delete(jobData, field)
|
||||||
|
cclog.Debugf("Removed deprecated field: %s", field)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// migrateJobMetadata applies all transformations to a job metadata map
|
||||||
|
func migrateJobMetadata(jobData map[string]interface{}) error {
|
||||||
|
// Apply transformations in order
|
||||||
|
if err := transformExclusiveToShared(jobData); err != nil {
|
||||||
|
return fmt.Errorf("transformExclusiveToShared failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := addMissingFields(jobData); err != nil {
|
||||||
|
return fmt.Errorf("addMissingFields failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := removeDeprecatedFields(jobData); err != nil {
|
||||||
|
return fmt.Errorf("removeDeprecatedFields failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// processJob reads, migrates, and writes a job metadata file
|
||||||
|
func processJob(metaPath string, dryRun bool) error {
|
||||||
|
// Read the meta.json file
|
||||||
|
data, err := os.ReadFile(metaPath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to read %s: %w", metaPath, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse JSON
|
||||||
|
var jobData map[string]interface{}
|
||||||
|
if err := json.Unmarshal(data, &jobData); err != nil {
|
||||||
|
return fmt.Errorf("failed to parse JSON from %s: %w", metaPath, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply migrations
|
||||||
|
if err := migrateJobMetadata(jobData); err != nil {
|
||||||
|
return fmt.Errorf("migration failed for %s: %w", metaPath, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// If dry-run, just report what would change
|
||||||
|
if dryRun {
|
||||||
|
cclog.Infof("Would migrate: %s", metaPath)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write back the migrated data
|
||||||
|
migratedData, err := json.MarshalIndent(jobData, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to marshal migrated data: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.WriteFile(metaPath, migratedData, 0644); err != nil {
|
||||||
|
return fmt.Errorf("failed to write %s: %w", metaPath, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// migrateArchive walks through an archive directory and migrates all meta.json files
|
||||||
|
func migrateArchive(archivePath string, dryRun bool, numWorkers int) (int, int, error) {
|
||||||
|
cclog.Infof("Starting archive migration at %s", archivePath)
|
||||||
|
if dryRun {
|
||||||
|
cclog.Info("DRY RUN MODE - no files will be modified")
|
||||||
|
}
|
||||||
|
|
||||||
|
var migrated int32
|
||||||
|
var failed int32
|
||||||
|
|
||||||
|
// Channel for job paths
|
||||||
|
jobs :=make(chan string, numWorkers*2)
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
|
||||||
|
// Start worker goroutines
|
||||||
|
for i := 0; i < numWorkers; i++ {
|
||||||
|
wg.Add(1)
|
||||||
|
go func(workerID int) {
|
||||||
|
defer wg.Done()
|
||||||
|
|
||||||
|
for metaPath := range jobs {
|
||||||
|
if err := processJob(metaPath, dryRun); err != nil {
|
||||||
|
cclog.Errorf("Failed to migrate %s: %s", metaPath, err.Error())
|
||||||
|
atomic.AddInt32(&failed, 1)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
newCount := atomic.AddInt32(&migrated, 1)
|
||||||
|
if newCount%100 == 0 {
|
||||||
|
cclog.Infof("Progress: %d jobs migrated, %d failed", newCount, atomic.LoadInt32(&failed))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}(i)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Walk the archive directory and find all meta.json files
|
||||||
|
go func() {
|
||||||
|
filepath.Walk(archivePath, func(path string, info os.FileInfo, err error) error {
|
||||||
|
if err != nil {
|
||||||
|
cclog.Errorf("Error accessing path %s: %s", path, err.Error())
|
||||||
|
return nil // Continue walking
|
||||||
|
}
|
||||||
|
|
||||||
|
if !info.IsDir() && info.Name() == "meta.json" {
|
||||||
|
jobs <- path
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
close(jobs)
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Wait for all workers to complete
|
||||||
|
wg.Wait()
|
||||||
|
|
||||||
|
finalMigrated := int(atomic.LoadInt32(&migrated))
|
||||||
|
finalFailed := int(atomic.LoadInt32(&failed))
|
||||||
|
|
||||||
|
cclog.Infof("Migration completed: %d jobs migrated, %d failed", finalMigrated, finalFailed)
|
||||||
|
|
||||||
|
if finalFailed > 0 {
|
||||||
|
return finalMigrated, finalFailed, fmt.Errorf("%d jobs failed to migrate", finalFailed)
|
||||||
|
}
|
||||||
|
|
||||||
|
return finalMigrated, finalFailed, nil
|
||||||
|
}
|
||||||
Executable
+74
@@ -0,0 +1,74 @@
|
|||||||
|
#!/usr/bin/env perl
|
||||||
|
|
||||||
|
my $filename = $ARGV[0];
|
||||||
|
my $Tday = $ARGV[1];
|
||||||
|
|
||||||
|
open FILE,"<$filename";
|
||||||
|
|
||||||
|
my %startedJob;
|
||||||
|
my %stoppedJob;
|
||||||
|
|
||||||
|
foreach ( <FILE> ) {
|
||||||
|
if ( /Oct ([0-9]+) .*new job \(id: ([0-9]+)\): cluster=([a-z]+), jobId=([0-9]+), user=([a-z0-9]+),/ ) {
|
||||||
|
my $day = $1;
|
||||||
|
my $id = $2;
|
||||||
|
my $cluster = $3;
|
||||||
|
my $jobId = $4;
|
||||||
|
my $user = $5;
|
||||||
|
|
||||||
|
if ( $cluster eq 'woody' && $day eq $Tday ) {
|
||||||
|
$startedJob{$id} = {
|
||||||
|
'day' => $day,
|
||||||
|
'cluster' => $cluster,
|
||||||
|
'jobId' => $jobId,
|
||||||
|
'user' => $user
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ( /Oct ([0-9]+) .*archiving job... \(dbid: ([0-9]+)\): cluster=([a-z]+), jobId=([0-9]+), user=([a-z0-9]+),/ ) {
|
||||||
|
my $day = $1;
|
||||||
|
my $id = $2;
|
||||||
|
my $cluster = $3;
|
||||||
|
my $jobId = $4;
|
||||||
|
my $user = $5;
|
||||||
|
|
||||||
|
if ( $cluster eq 'woody' ) {
|
||||||
|
$stoppedJob{$id} = {
|
||||||
|
'day' => $day,
|
||||||
|
'cluster' => $cluster,
|
||||||
|
'jobId' => $jobId,
|
||||||
|
'user' => $user
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
close FILE;
|
||||||
|
|
||||||
|
my $started = 0;
|
||||||
|
my $count = 0;
|
||||||
|
my %users;
|
||||||
|
|
||||||
|
foreach my $key (keys %startedJob) {
|
||||||
|
$started++;
|
||||||
|
if ( not exists $stoppedJob{$key} ) {
|
||||||
|
$count++;
|
||||||
|
|
||||||
|
if ( not exists $users{$startedJob{$key}->{'user'}} ) {
|
||||||
|
$users{$startedJob{$key}->{'user'}} = 1;
|
||||||
|
} else {
|
||||||
|
$users{$startedJob{$key}->{'user'}}++;
|
||||||
|
}
|
||||||
|
|
||||||
|
print <<END;
|
||||||
|
======
|
||||||
|
jobID: $startedJob{$key}->{'jobId'} User: $startedJob{$key}->{'user'}
|
||||||
|
======
|
||||||
|
END
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach my $key ( keys %users ) {
|
||||||
|
print "$key => $users{$key}\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
print "Not stopped: $count of $started\n";
|
||||||
Generated
+232
-210
@@ -9,7 +9,7 @@
|
|||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@rollup/plugin-replace": "^6.0.2",
|
"@rollup/plugin-replace": "^6.0.3",
|
||||||
"@sveltestrap/sveltestrap": "^7.1.0",
|
"@sveltestrap/sveltestrap": "^7.1.0",
|
||||||
"@urql/svelte": "^4.2.3",
|
"@urql/svelte": "^4.2.3",
|
||||||
"chart.js": "^4.5.1",
|
"chart.js": "^4.5.1",
|
||||||
@@ -24,16 +24,16 @@
|
|||||||
"@rollup/plugin-node-resolve": "^16.0.1",
|
"@rollup/plugin-node-resolve": "^16.0.1",
|
||||||
"@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.41.1",
|
"rollup": "^4.53.3",
|
||||||
"rollup-plugin-css-only": "^4.5.2",
|
"rollup-plugin-css-only": "^4.5.5",
|
||||||
"rollup-plugin-svelte": "^7.2.2",
|
"rollup-plugin-svelte": "^7.2.3",
|
||||||
"svelte": "^5.33.14"
|
"svelte": "^5.44.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@0no-co/graphql.web": {
|
"node_modules/@0no-co/graphql.web": {
|
||||||
"version": "1.1.2",
|
"version": "1.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/@0no-co/graphql.web/-/graphql.web-1.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/@0no-co/graphql.web/-/graphql.web-1.2.0.tgz",
|
||||||
"integrity": "sha512-N2NGsU5FLBhT8NZ+3l2YrzZSHITjNXNuDhC4iDiikv0IujaJ0Xc6xIxQZ/Ek3Cb+rgPjnLHYyJm11tInuJn+cw==",
|
"integrity": "sha512-/1iHy9TTr63gE1YcR5idjx8UREz1s0kFhydf3bBLCXyqjhkIc6igAzTOx3zPifCwFR87tsh/4Pa9cNts6d2otw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"graphql": "^14.0.0 || ^15.0.0 || ^16.0.0"
|
"graphql": "^14.0.0 || ^15.0.0 || ^16.0.0"
|
||||||
@@ -44,40 +44,33 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@ampproject/remapping": {
|
|
||||||
"version": "2.3.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz",
|
|
||||||
"integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==",
|
|
||||||
"license": "Apache-2.0",
|
|
||||||
"dependencies": {
|
|
||||||
"@jridgewell/gen-mapping": "^0.3.5",
|
|
||||||
"@jridgewell/trace-mapping": "^0.3.24"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=6.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@babel/runtime": {
|
"node_modules/@babel/runtime": {
|
||||||
"version": "7.27.6",
|
"version": "7.28.4",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.27.6.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.4.tgz",
|
||||||
"integrity": "sha512-vbavdySgbTTrmFE+EsiqUTzlOr5bzlnJtUv9PynGCAKvfQqjIXbvFdumPM/GxMDfyuGMJaJAU6TO4zc1Jf1i8Q==",
|
"integrity": "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=6.9.0"
|
"node": ">=6.9.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@jridgewell/gen-mapping": {
|
"node_modules/@jridgewell/gen-mapping": {
|
||||||
"version": "0.3.8",
|
"version": "0.3.13",
|
||||||
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.8.tgz",
|
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
|
||||||
"integrity": "sha512-imAbBGkb+ebQyxKgzv5Hu2nmROxoDOXHh80evxdoXNOrvAnVx7zimzc1Oo5h9RlfV4vPXaE2iM5pOFbvOCClWA==",
|
"integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@jridgewell/set-array": "^1.2.1",
|
"@jridgewell/sourcemap-codec": "^1.5.0",
|
||||||
"@jridgewell/sourcemap-codec": "^1.4.10",
|
"@jridgewell/trace-mapping": "^0.3.24"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@jridgewell/remapping": {
|
||||||
|
"version": "2.3.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz",
|
||||||
|
"integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@jridgewell/gen-mapping": "^0.3.5",
|
||||||
"@jridgewell/trace-mapping": "^0.3.24"
|
"@jridgewell/trace-mapping": "^0.3.24"
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=6.0.0"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@jridgewell/resolve-uri": {
|
"node_modules/@jridgewell/resolve-uri": {
|
||||||
@@ -89,19 +82,10 @@
|
|||||||
"node": ">=6.0.0"
|
"node": ">=6.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@jridgewell/set-array": {
|
|
||||||
"version": "1.2.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.2.1.tgz",
|
|
||||||
"integrity": "sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=6.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@jridgewell/source-map": {
|
"node_modules/@jridgewell/source-map": {
|
||||||
"version": "0.3.6",
|
"version": "0.3.11",
|
||||||
"resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.6.tgz",
|
"resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.11.tgz",
|
||||||
"integrity": "sha512-1ZJTZebgqllO79ue2bm3rIGud/bOe0pP5BjSRCRxxYkEZS8STV7zN84UBbiYu7jy+eCKSnVIUgoWWE/tt+shMQ==",
|
"integrity": "sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -110,15 +94,15 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@jridgewell/sourcemap-codec": {
|
"node_modules/@jridgewell/sourcemap-codec": {
|
||||||
"version": "1.5.0",
|
"version": "1.5.5",
|
||||||
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz",
|
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
|
||||||
"integrity": "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==",
|
"integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@jridgewell/trace-mapping": {
|
"node_modules/@jridgewell/trace-mapping": {
|
||||||
"version": "0.3.25",
|
"version": "0.3.31",
|
||||||
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.25.tgz",
|
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
|
||||||
"integrity": "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==",
|
"integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@jridgewell/resolve-uri": "^3.1.0",
|
"@jridgewell/resolve-uri": "^3.1.0",
|
||||||
@@ -169,9 +153,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/plugin-node-resolve": {
|
"node_modules/@rollup/plugin-node-resolve": {
|
||||||
"version": "16.0.1",
|
"version": "16.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/plugin-node-resolve/-/plugin-node-resolve-16.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/plugin-node-resolve/-/plugin-node-resolve-16.0.3.tgz",
|
||||||
"integrity": "sha512-tk5YCxJWIG81umIvNkSod2qK5KyQW19qcBF/B78n1bjtOON6gzKoVeSzAE8yHCZEDmqkHKkxplExA8KzdJLJpA==",
|
"integrity": "sha512-lUYM3UBGuM93CnMPG1YocWu7X802BrNF3jW2zny5gQyLQgRFJhV1Sq0Zi74+dh/6NBx1DxFC4b4GXg9wUCG5Qg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -194,9 +178,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/plugin-replace": {
|
"node_modules/@rollup/plugin-replace": {
|
||||||
"version": "6.0.2",
|
"version": "6.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/plugin-replace/-/plugin-replace-6.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/plugin-replace/-/plugin-replace-6.0.3.tgz",
|
||||||
"integrity": "sha512-7QaYCf8bqF04dOy7w/eHmJeNExxTYwvKAmlSAH/EaWWUzbT0h5sbF6bktFoX/0F/0qwng5/dWFMyf3gzaM8DsQ==",
|
"integrity": "sha512-J4RZarRvQAm5IF0/LwUUg+obsm+xZhYnbMXmXROyoSE1ATJe3oXSb9L5MMppdxP2ylNSjv6zFBwKYjcKMucVfA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@rollup/pluginutils": "^5.0.1",
|
"@rollup/pluginutils": "^5.0.1",
|
||||||
@@ -238,9 +222,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/pluginutils": {
|
"node_modules/@rollup/pluginutils": {
|
||||||
"version": "5.1.4",
|
"version": "5.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.1.4.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.3.0.tgz",
|
||||||
"integrity": "sha512-USm05zrsFxYLPdWWq+K3STlWiT/3ELn3RcV5hJMghpeAIhxfsUIg6mt12CBJBInWMV4VneoV7SfGv8xIwo2qNQ==",
|
"integrity": "sha512-5EdhGZtnu3V88ces7s53hhfK5KSASnJZv8Lulpc04cWO3REESroJXg73DFsOmgbU2BhwV0E20bu2IDZb3VKW4Q==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/estree": "^1.0.0",
|
"@types/estree": "^1.0.0",
|
||||||
@@ -260,9 +244,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-android-arm-eabi": {
|
"node_modules/@rollup/rollup-android-arm-eabi": {
|
||||||
"version": "4.41.1",
|
"version": "4.53.3",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.41.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.53.3.tgz",
|
||||||
"integrity": "sha512-NELNvyEWZ6R9QMkiytB4/L4zSEaBC03KIXEghptLGLZWJ6VPrL63ooZQCOnlx36aQPGhzuOMwDerC1Eb2VmrLw==",
|
"integrity": "sha512-mRSi+4cBjrRLoaal2PnqH82Wqyb+d3HsPUN/W+WslCXsZsyHa9ZeQQX/pQsZaVIWDkPcpV6jJ+3KLbTbgnwv8w==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
@@ -274,9 +258,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-android-arm64": {
|
"node_modules/@rollup/rollup-android-arm64": {
|
||||||
"version": "4.41.1",
|
"version": "4.53.3",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.41.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.53.3.tgz",
|
||||||
"integrity": "sha512-DXdQe1BJ6TK47ukAoZLehRHhfKnKg9BjnQYUu9gzhI8Mwa1d2fzxA1aw2JixHVl403bwp1+/o/NhhHtxWJBgEA==",
|
"integrity": "sha512-CbDGaMpdE9sh7sCmTrTUyllhrg65t6SwhjlMJsLr+J8YjFuPmCEjbBSx4Z/e4SmDyH3aB5hGaJUP2ltV/vcs4w==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -288,9 +272,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-darwin-arm64": {
|
"node_modules/@rollup/rollup-darwin-arm64": {
|
||||||
"version": "4.41.1",
|
"version": "4.53.3",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.41.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.53.3.tgz",
|
||||||
"integrity": "sha512-5afxvwszzdulsU2w8JKWwY8/sJOLPzf0e1bFuvcW5h9zsEg+RQAojdW0ux2zyYAz7R8HvvzKCjLNJhVq965U7w==",
|
"integrity": "sha512-Nr7SlQeqIBpOV6BHHGZgYBuSdanCXuw09hon14MGOLGmXAFYjx1wNvquVPmpZnl0tLjg25dEdr4IQ6GgyToCUA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -302,9 +286,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-darwin-x64": {
|
"node_modules/@rollup/rollup-darwin-x64": {
|
||||||
"version": "4.41.1",
|
"version": "4.53.3",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.41.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.53.3.tgz",
|
||||||
"integrity": "sha512-egpJACny8QOdHNNMZKf8xY0Is6gIMz+tuqXlusxquWu3F833DcMwmGM7WlvCO9sB3OsPjdC4U0wHw5FabzCGZg==",
|
"integrity": "sha512-DZ8N4CSNfl965CmPktJ8oBnfYr3F8dTTNBQkRlffnUarJ2ohudQD17sZBa097J8xhQ26AwhHJ5mvUyQW8ddTsQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -316,9 +300,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-freebsd-arm64": {
|
"node_modules/@rollup/rollup-freebsd-arm64": {
|
||||||
"version": "4.41.1",
|
"version": "4.53.3",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.41.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.53.3.tgz",
|
||||||
"integrity": "sha512-DBVMZH5vbjgRk3r0OzgjS38z+atlupJ7xfKIDJdZZL6sM6wjfDNo64aowcLPKIx7LMQi8vybB56uh1Ftck/Atg==",
|
"integrity": "sha512-yMTrCrK92aGyi7GuDNtGn2sNW+Gdb4vErx4t3Gv/Tr+1zRb8ax4z8GWVRfr3Jw8zJWvpGHNpss3vVlbF58DZ4w==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -330,9 +314,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-freebsd-x64": {
|
"node_modules/@rollup/rollup-freebsd-x64": {
|
||||||
"version": "4.41.1",
|
"version": "4.53.3",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.41.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.53.3.tgz",
|
||||||
"integrity": "sha512-3FkydeohozEskBxNWEIbPfOE0aqQgB6ttTkJ159uWOFn42VLyfAiyD9UK5mhu+ItWzft60DycIN1Xdgiy8o/SA==",
|
"integrity": "sha512-lMfF8X7QhdQzseM6XaX0vbno2m3hlyZFhwcndRMw8fbAGUGL3WFMBdK0hbUBIUYcEcMhVLr1SIamDeuLBnXS+Q==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -344,9 +328,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-linux-arm-gnueabihf": {
|
"node_modules/@rollup/rollup-linux-arm-gnueabihf": {
|
||||||
"version": "4.41.1",
|
"version": "4.53.3",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.41.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.53.3.tgz",
|
||||||
"integrity": "sha512-wC53ZNDgt0pqx5xCAgNunkTzFE8GTgdZ9EwYGVcg+jEjJdZGtq9xPjDnFgfFozQI/Xm1mh+D9YlYtl+ueswNEg==",
|
"integrity": "sha512-k9oD15soC/Ln6d2Wv/JOFPzZXIAIFLp6B+i14KhxAfnq76ajt0EhYc5YPeX6W1xJkAdItcVT+JhKl1QZh44/qw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
@@ -358,9 +342,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-linux-arm-musleabihf": {
|
"node_modules/@rollup/rollup-linux-arm-musleabihf": {
|
||||||
"version": "4.41.1",
|
"version": "4.53.3",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.41.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.53.3.tgz",
|
||||||
"integrity": "sha512-jwKCca1gbZkZLhLRtsrka5N8sFAaxrGz/7wRJ8Wwvq3jug7toO21vWlViihG85ei7uJTpzbXZRcORotE+xyrLA==",
|
"integrity": "sha512-vTNlKq+N6CK/8UktsrFuc+/7NlEYVxgaEgRXVUVK258Z5ymho29skzW1sutgYjqNnquGwVUObAaxae8rZ6YMhg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
@@ -372,9 +356,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-linux-arm64-gnu": {
|
"node_modules/@rollup/rollup-linux-arm64-gnu": {
|
||||||
"version": "4.41.1",
|
"version": "4.53.3",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.41.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.53.3.tgz",
|
||||||
"integrity": "sha512-g0UBcNknsmmNQ8V2d/zD2P7WWfJKU0F1nu0k5pW4rvdb+BIqMm8ToluW/eeRmxCared5dD76lS04uL4UaNgpNA==",
|
"integrity": "sha512-RGrFLWgMhSxRs/EWJMIFM1O5Mzuz3Xy3/mnxJp/5cVhZ2XoCAxJnmNsEyeMJtpK+wu0FJFWz+QF4mjCA7AUQ3w==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -386,9 +370,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-linux-arm64-musl": {
|
"node_modules/@rollup/rollup-linux-arm64-musl": {
|
||||||
"version": "4.41.1",
|
"version": "4.53.3",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.41.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.53.3.tgz",
|
||||||
"integrity": "sha512-XZpeGB5TKEZWzIrj7sXr+BEaSgo/ma/kCgrZgL0oo5qdB1JlTzIYQKel/RmhT6vMAvOdM2teYlAaOGJpJ9lahg==",
|
"integrity": "sha512-kASyvfBEWYPEwe0Qv4nfu6pNkITLTb32p4yTgzFCocHnJLAHs+9LjUu9ONIhvfT/5lv4YS5muBHyuV84epBo/A==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -399,10 +383,10 @@
|
|||||||
"linux"
|
"linux"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-linux-loongarch64-gnu": {
|
"node_modules/@rollup/rollup-linux-loong64-gnu": {
|
||||||
"version": "4.41.1",
|
"version": "4.53.3",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loongarch64-gnu/-/rollup-linux-loongarch64-gnu-4.41.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.53.3.tgz",
|
||||||
"integrity": "sha512-bkCfDJ4qzWfFRCNt5RVV4DOw6KEgFTUZi2r2RuYhGWC8WhCA8lCAJhDeAmrM/fdiAH54m0mA0Vk2FGRPyzI+tw==",
|
"integrity": "sha512-JiuKcp2teLJwQ7vkJ95EwESWkNRFJD7TQgYmCnrPtlu50b4XvT5MOmurWNrCj3IFdyjBQ5p9vnrX4JM6I8OE7g==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"loong64"
|
"loong64"
|
||||||
],
|
],
|
||||||
@@ -413,10 +397,10 @@
|
|||||||
"linux"
|
"linux"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-linux-powerpc64le-gnu": {
|
"node_modules/@rollup/rollup-linux-ppc64-gnu": {
|
||||||
"version": "4.41.1",
|
"version": "4.53.3",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.41.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.53.3.tgz",
|
||||||
"integrity": "sha512-3mr3Xm+gvMX+/8EKogIZSIEF0WUu0HL9di+YWlJpO8CQBnoLAEL/roTCxuLncEdgcfJcvA4UMOf+2dnjl4Ut1A==",
|
"integrity": "sha512-EoGSa8nd6d3T7zLuqdojxC20oBfNT8nexBbB/rkxgKj5T5vhpAQKKnD+h3UkoMuTyXkP5jTjK/ccNRmQrPNDuw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"ppc64"
|
"ppc64"
|
||||||
],
|
],
|
||||||
@@ -428,9 +412,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-linux-riscv64-gnu": {
|
"node_modules/@rollup/rollup-linux-riscv64-gnu": {
|
||||||
"version": "4.41.1",
|
"version": "4.53.3",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.41.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.53.3.tgz",
|
||||||
"integrity": "sha512-3rwCIh6MQ1LGrvKJitQjZFuQnT2wxfU+ivhNBzmxXTXPllewOF7JR1s2vMX/tWtUYFgphygxjqMl76q4aMotGw==",
|
"integrity": "sha512-4s+Wped2IHXHPnAEbIB0YWBv7SDohqxobiiPA1FIWZpX+w9o2i4LezzH/NkFUl8LRci/8udci6cLq+jJQlh+0g==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"riscv64"
|
"riscv64"
|
||||||
],
|
],
|
||||||
@@ -442,9 +426,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-linux-riscv64-musl": {
|
"node_modules/@rollup/rollup-linux-riscv64-musl": {
|
||||||
"version": "4.41.1",
|
"version": "4.53.3",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.41.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.53.3.tgz",
|
||||||
"integrity": "sha512-LdIUOb3gvfmpkgFZuccNa2uYiqtgZAz3PTzjuM5bH3nvuy9ty6RGc/Q0+HDFrHrizJGVpjnTZ1yS5TNNjFlklw==",
|
"integrity": "sha512-68k2g7+0vs2u9CxDt5ktXTngsxOQkSEV/xBbwlqYcUrAVh6P9EgMZvFsnHy4SEiUl46Xf0IObWVbMvPrr2gw8A==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"riscv64"
|
"riscv64"
|
||||||
],
|
],
|
||||||
@@ -456,9 +440,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-linux-s390x-gnu": {
|
"node_modules/@rollup/rollup-linux-s390x-gnu": {
|
||||||
"version": "4.41.1",
|
"version": "4.53.3",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.41.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.53.3.tgz",
|
||||||
"integrity": "sha512-oIE6M8WC9ma6xYqjvPhzZYk6NbobIURvP/lEbh7FWplcMO6gn7MM2yHKA1eC/GvYwzNKK/1LYgqzdkZ8YFxR8g==",
|
"integrity": "sha512-VYsFMpULAz87ZW6BVYw3I6sWesGpsP9OPcyKe8ofdg9LHxSbRMd7zrVrr5xi/3kMZtpWL/wC+UIJWJYVX5uTKg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"s390x"
|
"s390x"
|
||||||
],
|
],
|
||||||
@@ -470,9 +454,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-linux-x64-gnu": {
|
"node_modules/@rollup/rollup-linux-x64-gnu": {
|
||||||
"version": "4.41.1",
|
"version": "4.53.3",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.41.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.53.3.tgz",
|
||||||
"integrity": "sha512-cWBOvayNvA+SyeQMp79BHPK8ws6sHSsYnK5zDcsC3Hsxr1dgTABKjMnMslPq1DvZIp6uO7kIWhiGwaTdR4Og9A==",
|
"integrity": "sha512-3EhFi1FU6YL8HTUJZ51imGJWEX//ajQPfqWLI3BQq4TlvHy4X0MOr5q3D2Zof/ka0d5FNdPwZXm3Yyib/UEd+w==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -484,9 +468,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-linux-x64-musl": {
|
"node_modules/@rollup/rollup-linux-x64-musl": {
|
||||||
"version": "4.41.1",
|
"version": "4.53.3",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.41.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.53.3.tgz",
|
||||||
"integrity": "sha512-y5CbN44M+pUCdGDlZFzGGBSKCA4A/J2ZH4edTYSSxFg7ce1Xt3GtydbVKWLlzL+INfFIZAEg1ZV6hh9+QQf9YQ==",
|
"integrity": "sha512-eoROhjcc6HbZCJr+tvVT8X4fW3/5g/WkGvvmwz/88sDtSJzO7r/blvoBDgISDiCjDRZmHpwud7h+6Q9JxFwq1Q==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -497,10 +481,24 @@
|
|||||||
"linux"
|
"linux"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"node_modules/@rollup/rollup-openharmony-arm64": {
|
||||||
|
"version": "4.53.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.53.3.tgz",
|
||||||
|
"integrity": "sha512-OueLAWgrNSPGAdUdIjSWXw+u/02BRTcnfw9PN41D2vq/JSEPnJnVuBgw18VkN8wcd4fjUs+jFHVM4t9+kBSNLw==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"openharmony"
|
||||||
|
]
|
||||||
|
},
|
||||||
"node_modules/@rollup/rollup-win32-arm64-msvc": {
|
"node_modules/@rollup/rollup-win32-arm64-msvc": {
|
||||||
"version": "4.41.1",
|
"version": "4.53.3",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.41.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.53.3.tgz",
|
||||||
"integrity": "sha512-lZkCxIrjlJlMt1dLO/FbpZbzt6J/A8p4DnqzSa4PWqPEUUUnzXLeki/iyPLfV0BmHItlYgHUqJe+3KiyydmiNQ==",
|
"integrity": "sha512-GOFuKpsxR/whszbF/bzydebLiXIHSgsEUp6M0JI8dWvi+fFa1TD6YQa4aSZHtpmh2/uAlj/Dy+nmby3TJ3pkTw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -512,9 +510,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-win32-ia32-msvc": {
|
"node_modules/@rollup/rollup-win32-ia32-msvc": {
|
||||||
"version": "4.41.1",
|
"version": "4.53.3",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.41.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.53.3.tgz",
|
||||||
"integrity": "sha512-+psFT9+pIh2iuGsxFYYa/LhS5MFKmuivRsx9iPJWNSGbh2XVEjk90fmpUEjCnILPEPJnikAU6SFDiEUyOv90Pg==",
|
"integrity": "sha512-iah+THLcBJdpfZ1TstDFbKNznlzoxa8fmnFYK4V67HvmuNYkVdAywJSoteUszvBQ9/HqN2+9AZghbajMsFT+oA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"ia32"
|
"ia32"
|
||||||
],
|
],
|
||||||
@@ -525,10 +523,24 @@
|
|||||||
"win32"
|
"win32"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"node_modules/@rollup/rollup-win32-x64-gnu": {
|
||||||
|
"version": "4.53.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.53.3.tgz",
|
||||||
|
"integrity": "sha512-J9QDiOIZlZLdcot5NXEepDkstocktoVjkaKUtqzgzpt2yWjGlbYiKyp05rWwk4nypbYUNoFAztEgixoLaSETkg==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
]
|
||||||
|
},
|
||||||
"node_modules/@rollup/rollup-win32-x64-msvc": {
|
"node_modules/@rollup/rollup-win32-x64-msvc": {
|
||||||
"version": "4.41.1",
|
"version": "4.53.3",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.41.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.53.3.tgz",
|
||||||
"integrity": "sha512-Wq2zpapRYLfi4aKxf2Xff0tN+7slj2d4R87WEzqw7ZLsVvO5zwYCIuEGSZYiK41+GlwUo1HiR+GdkLEJnCKTCw==",
|
"integrity": "sha512-UhTd8u31dXadv0MopwGgNOBpUVROFKWVQgAg5N1ESyCz8AuBcMqm4AuTjrwgQKGDfoFuz02EuMRHQIw/frmYKQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -540,9 +552,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@sveltejs/acorn-typescript": {
|
"node_modules/@sveltejs/acorn-typescript": {
|
||||||
"version": "1.0.5",
|
"version": "1.0.7",
|
||||||
"resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.7.tgz",
|
||||||
"integrity": "sha512-IwQk4yfwLdibDlrXVE04jTZYlLnwsTT2PIOQQGNLWfjavGifnk1JD1LcZjZaBTRcxZu2FfPfNLOE04DSu9lqtQ==",
|
"integrity": "sha512-znp1A/Y1Jj4l/Zy7PX5DZKBE0ZNY+5QBngiE21NJkfSTyzzC5iKNWOtwFXKtIrn7MXEFBck4jD95iBNkGjK92Q==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"acorn": "^8.9.0"
|
"acorn": "^8.9.0"
|
||||||
@@ -581,12 +593,12 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@urql/core": {
|
"node_modules/@urql/core": {
|
||||||
"version": "5.1.1",
|
"version": "5.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/@urql/core/-/core-5.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/@urql/core/-/core-5.2.0.tgz",
|
||||||
"integrity": "sha512-aGh024z5v2oINGD/In6rAtVKTm4VmQ2TxKQBAtk2ZSME5dunZFcjltw4p5ENQg+5CBhZ3FHMzl0Oa+rwqiWqlg==",
|
"integrity": "sha512-/n0ieD0mvvDnVAXEQgX/7qJiVcvYvNkOHeBvkwtylfjydar123caCXcl58PXFY11oU1oquJocVXHxLAbtv4x1A==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@0no-co/graphql.web": "^1.0.5",
|
"@0no-co/graphql.web": "^1.0.13",
|
||||||
"wonka": "^6.3.2"
|
"wonka": "^6.3.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -605,10 +617,11 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/acorn": {
|
"node_modules/acorn": {
|
||||||
"version": "8.14.1",
|
"version": "8.15.0",
|
||||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.1.tgz",
|
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
|
||||||
"integrity": "sha512-OvQ/2pUDKmgfCg++xsTX1wGxfTaszcHVcTctW4UJB4hibJx2HXxxO5UmVgyjMa+ZDsiaf5wWLXYpRWMmBI0QHg==",
|
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"bin": {
|
"bin": {
|
||||||
"acorn": "bin/acorn"
|
"acorn": "bin/acorn"
|
||||||
},
|
},
|
||||||
@@ -677,9 +690,9 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/complex.js": {
|
"node_modules/complex.js": {
|
||||||
"version": "2.4.2",
|
"version": "2.4.3",
|
||||||
"resolved": "https://registry.npmjs.org/complex.js/-/complex.js-2.4.2.tgz",
|
"resolved": "https://registry.npmjs.org/complex.js/-/complex.js-2.4.3.tgz",
|
||||||
"integrity": "sha512-qtx7HRhPGSCBtGiST4/WGHuW+zeaND/6Ld+db6PbrulIB1i2Ev/2UPiqcmpQNPSyfBKraC0EOvOKCB5dGZKt3g==",
|
"integrity": "sha512-UrQVSUur14tNX6tiP4y8T4w4FeJAX3bi2cIv0pu/DTLFNxoq7z2Yh83Vfzztj6Px3X/lubqQ9IrPp7Bpn6p4MQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "*"
|
"node": "*"
|
||||||
@@ -700,9 +713,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/decimal.js": {
|
"node_modules/decimal.js": {
|
||||||
"version": "10.5.0",
|
"version": "10.6.0",
|
||||||
"resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.5.0.tgz",
|
"resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz",
|
||||||
"integrity": "sha512-8vDa8Qxvr/+d94hSh5P3IJwI5t8/c0KsMp+g8bNw9cY2icONa5aPfvKeieW1WlG0WQYwwhJ7mjui2xtiePQSXw==",
|
"integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/deepmerge": {
|
"node_modules/deepmerge": {
|
||||||
@@ -715,6 +728,12 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/devalue": {
|
||||||
|
"version": "5.5.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/devalue/-/devalue-5.5.0.tgz",
|
||||||
|
"integrity": "sha512-69sM5yrHfFLJt0AZ9QqZXGCPfJ7fQjvpln3Rq5+PS03LD32Ost1Q9N+eEnaQwGRIriKkMImXD56ocjQmfjbV3w==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/escape-latex": {
|
"node_modules/escape-latex": {
|
||||||
"version": "1.2.0",
|
"version": "1.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/escape-latex/-/escape-latex-1.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/escape-latex/-/escape-latex-1.2.0.tgz",
|
||||||
@@ -728,9 +747,9 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/esrap": {
|
"node_modules/esrap": {
|
||||||
"version": "1.4.7",
|
"version": "2.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/esrap/-/esrap-1.4.7.tgz",
|
"resolved": "https://registry.npmjs.org/esrap/-/esrap-2.1.3.tgz",
|
||||||
"integrity": "sha512-0ZxW6guTF/AeKeKi7he93lmgv7Hx7giD1tBrOeVqkqsZGQJd2/kfnL7LdIsr9FT/AtkBK9XeDTov+gxprBqdEg==",
|
"integrity": "sha512-T/Dhhv/QH+yYmiaLz9SA3PW+YyenlnRKDNdtlYJrSOBmNsH4nvPux+mTwx7p+wAedlJrGoZtXNI0a0MjQ2QkVg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@jridgewell/sourcemap-codec": "^1.4.15"
|
"@jridgewell/sourcemap-codec": "^1.4.15"
|
||||||
@@ -743,11 +762,14 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/fdir": {
|
"node_modules/fdir": {
|
||||||
"version": "6.4.5",
|
"version": "6.5.0",
|
||||||
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.4.5.tgz",
|
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
|
||||||
"integrity": "sha512-4BG7puHpVsIYxZUbiUE3RqGloLaSSwzYie5jvasC4LWuBWzZawynvYouhjbQKw2JuIGYdm0DzIxl8iVidKlUEw==",
|
"integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12.0.0"
|
||||||
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"picomatch": "^3 || ^4"
|
"picomatch": "^3 || ^4"
|
||||||
},
|
},
|
||||||
@@ -758,12 +780,12 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/fraction.js": {
|
"node_modules/fraction.js": {
|
||||||
"version": "5.2.2",
|
"version": "5.3.4",
|
||||||
"resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz",
|
||||||
"integrity": "sha512-uXBDv5knpYmv/2gLzWQ5mBHGBRk9wcKTeWu6GLTUEQfjCxO09uM/mHDrojlL+Q1mVGIIFo149Gba7od1XPgSzQ==",
|
"integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 12"
|
"node": "*"
|
||||||
},
|
},
|
||||||
"funding": {
|
"funding": {
|
||||||
"type": "github",
|
"type": "github",
|
||||||
@@ -800,6 +822,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/graphql/-/graphql-16.12.0.tgz",
|
"resolved": "https://registry.npmjs.org/graphql/-/graphql-16.12.0.tgz",
|
||||||
"integrity": "sha512-DKKrynuQRne0PNpEbzuEdHlYOMksHSUI8Zc9Unei5gTsMNA2/vMpoMz/yKba50pejK56qj98qM0SjYxAKi13gQ==",
|
"integrity": "sha512-DKKrynuQRne0PNpEbzuEdHlYOMksHSUI8Zc9Unei5gTsMNA2/vMpoMz/yKba50pejK56qj98qM0SjYxAKi13gQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0"
|
"node": "^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0"
|
||||||
}
|
}
|
||||||
@@ -863,18 +886,18 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/magic-string": {
|
"node_modules/magic-string": {
|
||||||
"version": "0.30.17",
|
"version": "0.30.21",
|
||||||
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.17.tgz",
|
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
|
||||||
"integrity": "sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA==",
|
"integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@jridgewell/sourcemap-codec": "^1.5.0"
|
"@jridgewell/sourcemap-codec": "^1.5.5"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/mathjs": {
|
"node_modules/mathjs": {
|
||||||
"version": "15.0.0",
|
"version": "15.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/mathjs/-/mathjs-15.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/mathjs/-/mathjs-15.1.0.tgz",
|
||||||
"integrity": "sha512-eXXXRKEl/htny5T/Ce/hbmqa8WZi2RmaCEHBOVtTeYcYyyGvz1UYSdK2ypydDepFF6F7ue0OygXRRIx8lLq/uw==",
|
"integrity": "sha512-HfnAcScQm9drGryodlDqeS3WAl4gUTYGDcOtcqL/8s23MZ28Ib1i8XnYK3ZdjNuaW/L4BAp9lIp8vxAMrcuu1w==",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/runtime": "^7.26.10",
|
"@babel/runtime": "^7.26.10",
|
||||||
@@ -902,10 +925,11 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/picomatch": {
|
"node_modules/picomatch": {
|
||||||
"version": "4.0.2",
|
"version": "4.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
|
||||||
"integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==",
|
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
},
|
},
|
||||||
@@ -924,13 +948,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/resolve": {
|
"node_modules/resolve": {
|
||||||
"version": "1.22.10",
|
"version": "1.22.11",
|
||||||
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz",
|
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz",
|
||||||
"integrity": "sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w==",
|
"integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"is-core-module": "^2.16.0",
|
"is-core-module": "^2.16.1",
|
||||||
"path-parse": "^1.0.7",
|
"path-parse": "^1.0.7",
|
||||||
"supports-preserve-symlinks-flag": "^1.0.0"
|
"supports-preserve-symlinks-flag": "^1.0.0"
|
||||||
},
|
},
|
||||||
@@ -955,13 +979,14 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/rollup": {
|
"node_modules/rollup": {
|
||||||
"version": "4.41.1",
|
"version": "4.53.3",
|
||||||
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.41.1.tgz",
|
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.53.3.tgz",
|
||||||
"integrity": "sha512-cPmwD3FnFv8rKMBc1MxWCwVQFxwf1JEmSX3iQXrRVVG15zerAIXRjMFVWnd5Q5QvgKF7Aj+5ykXFhUl+QGnyOw==",
|
"integrity": "sha512-w8GmOxZfBmKknvdXU1sdM9NHcoQejwF/4mNgj2JuEEdRaHwwF12K7e9eXn1nLZ07ad+du76mkVsyeb2rKGllsA==",
|
||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/estree": "1.0.7"
|
"@types/estree": "1.0.8"
|
||||||
},
|
},
|
||||||
"bin": {
|
"bin": {
|
||||||
"rollup": "dist/bin/rollup"
|
"rollup": "dist/bin/rollup"
|
||||||
@@ -971,33 +996,35 @@
|
|||||||
"npm": ">=8.0.0"
|
"npm": ">=8.0.0"
|
||||||
},
|
},
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
"@rollup/rollup-android-arm-eabi": "4.41.1",
|
"@rollup/rollup-android-arm-eabi": "4.53.3",
|
||||||
"@rollup/rollup-android-arm64": "4.41.1",
|
"@rollup/rollup-android-arm64": "4.53.3",
|
||||||
"@rollup/rollup-darwin-arm64": "4.41.1",
|
"@rollup/rollup-darwin-arm64": "4.53.3",
|
||||||
"@rollup/rollup-darwin-x64": "4.41.1",
|
"@rollup/rollup-darwin-x64": "4.53.3",
|
||||||
"@rollup/rollup-freebsd-arm64": "4.41.1",
|
"@rollup/rollup-freebsd-arm64": "4.53.3",
|
||||||
"@rollup/rollup-freebsd-x64": "4.41.1",
|
"@rollup/rollup-freebsd-x64": "4.53.3",
|
||||||
"@rollup/rollup-linux-arm-gnueabihf": "4.41.1",
|
"@rollup/rollup-linux-arm-gnueabihf": "4.53.3",
|
||||||
"@rollup/rollup-linux-arm-musleabihf": "4.41.1",
|
"@rollup/rollup-linux-arm-musleabihf": "4.53.3",
|
||||||
"@rollup/rollup-linux-arm64-gnu": "4.41.1",
|
"@rollup/rollup-linux-arm64-gnu": "4.53.3",
|
||||||
"@rollup/rollup-linux-arm64-musl": "4.41.1",
|
"@rollup/rollup-linux-arm64-musl": "4.53.3",
|
||||||
"@rollup/rollup-linux-loongarch64-gnu": "4.41.1",
|
"@rollup/rollup-linux-loong64-gnu": "4.53.3",
|
||||||
"@rollup/rollup-linux-powerpc64le-gnu": "4.41.1",
|
"@rollup/rollup-linux-ppc64-gnu": "4.53.3",
|
||||||
"@rollup/rollup-linux-riscv64-gnu": "4.41.1",
|
"@rollup/rollup-linux-riscv64-gnu": "4.53.3",
|
||||||
"@rollup/rollup-linux-riscv64-musl": "4.41.1",
|
"@rollup/rollup-linux-riscv64-musl": "4.53.3",
|
||||||
"@rollup/rollup-linux-s390x-gnu": "4.41.1",
|
"@rollup/rollup-linux-s390x-gnu": "4.53.3",
|
||||||
"@rollup/rollup-linux-x64-gnu": "4.41.1",
|
"@rollup/rollup-linux-x64-gnu": "4.53.3",
|
||||||
"@rollup/rollup-linux-x64-musl": "4.41.1",
|
"@rollup/rollup-linux-x64-musl": "4.53.3",
|
||||||
"@rollup/rollup-win32-arm64-msvc": "4.41.1",
|
"@rollup/rollup-openharmony-arm64": "4.53.3",
|
||||||
"@rollup/rollup-win32-ia32-msvc": "4.41.1",
|
"@rollup/rollup-win32-arm64-msvc": "4.53.3",
|
||||||
"@rollup/rollup-win32-x64-msvc": "4.41.1",
|
"@rollup/rollup-win32-ia32-msvc": "4.53.3",
|
||||||
|
"@rollup/rollup-win32-x64-gnu": "4.53.3",
|
||||||
|
"@rollup/rollup-win32-x64-msvc": "4.53.3",
|
||||||
"fsevents": "~2.3.2"
|
"fsevents": "~2.3.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/rollup-plugin-css-only": {
|
"node_modules/rollup-plugin-css-only": {
|
||||||
"version": "4.5.2",
|
"version": "4.5.5",
|
||||||
"resolved": "https://registry.npmjs.org/rollup-plugin-css-only/-/rollup-plugin-css-only-4.5.2.tgz",
|
"resolved": "https://registry.npmjs.org/rollup-plugin-css-only/-/rollup-plugin-css-only-4.5.5.tgz",
|
||||||
"integrity": "sha512-7rj9+jB17Pz8LNcPgtMUb16JcgD8lxQMK9HcGfAVhMK3na/WXes3oGIo5QsrQQVqtgAU6q6KnQNXJrYunaUIQQ==",
|
"integrity": "sha512-O2m2Sj8qsAtjUVqZyGTDXJypaOFFNV4knz8OlS6wJBws6XEICIiLsXmI56SbQEmWDqYU5TgRgWmslGj4THofJQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -1011,9 +1038,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/rollup-plugin-svelte": {
|
"node_modules/rollup-plugin-svelte": {
|
||||||
"version": "7.2.2",
|
"version": "7.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/rollup-plugin-svelte/-/rollup-plugin-svelte-7.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/rollup-plugin-svelte/-/rollup-plugin-svelte-7.2.3.tgz",
|
||||||
"integrity": "sha512-hgnIblTRewaBEVQD6N0Q43o+y6q1TmDRhBjaEzQCi50bs8TXqjc+d1zFZyE8tsfgcfNHZQzclh4RxlFUB85H8Q==",
|
"integrity": "sha512-LlniP+h00DfM+E4eav/Kk8uGjgPUjGIBfrAS/IxQvsuFdqSM0Y2sXf31AdxuIGSW9GsmocDqOfaxR5QNno/Tgw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -1055,13 +1082,6 @@
|
|||||||
"url": "https://github.com/sponsors/jonschlinkert"
|
"url": "https://github.com/sponsors/jonschlinkert"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/rollup/node_modules/@types/estree": {
|
|
||||||
"version": "1.0.7",
|
|
||||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.7.tgz",
|
|
||||||
"integrity": "sha512-w28IoSUCJpidD/TGviZwwMJckNESJZXFu7NBZ5YJ4mEUnNraUn9Pm8HSZm/jDF1pDWYKspWE7oVphigUPRakIQ==",
|
|
||||||
"devOptional": true,
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/safe-buffer": {
|
"node_modules/safe-buffer": {
|
||||||
"version": "5.2.1",
|
"version": "5.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
|
||||||
@@ -1141,12 +1161,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/svelte": {
|
"node_modules/svelte": {
|
||||||
"version": "5.33.14",
|
"version": "5.44.0",
|
||||||
"resolved": "https://registry.npmjs.org/svelte/-/svelte-5.33.14.tgz",
|
"resolved": "https://registry.npmjs.org/svelte/-/svelte-5.44.0.tgz",
|
||||||
"integrity": "sha512-kRlbhIlMTijbFmVDQFDeKXPLlX1/ovXwV0I162wRqQhRcygaqDIcu1d/Ese3H2uI+yt3uT8E7ndgDthQv5v5BA==",
|
"integrity": "sha512-R7387No2zEGw4CtYtI2rgsui6BqjFARzoZFGLiLN5OPla0Pq4Ra2WwcP/zBomP3MYalhSNvF1fzDMuU0P0zPJw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ampproject/remapping": "^2.3.0",
|
"@jridgewell/remapping": "^2.3.4",
|
||||||
"@jridgewell/sourcemap-codec": "^1.5.0",
|
"@jridgewell/sourcemap-codec": "^1.5.0",
|
||||||
"@sveltejs/acorn-typescript": "^1.0.5",
|
"@sveltejs/acorn-typescript": "^1.0.5",
|
||||||
"@types/estree": "^1.0.5",
|
"@types/estree": "^1.0.5",
|
||||||
@@ -1154,8 +1175,9 @@
|
|||||||
"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",
|
||||||
"esm-env": "^1.2.1",
|
"esm-env": "^1.2.1",
|
||||||
"esrap": "^1.4.6",
|
"esrap": "^2.1.0",
|
||||||
"is-reference": "^3.0.3",
|
"is-reference": "^3.0.3",
|
||||||
"locate-character": "^3.0.0",
|
"locate-character": "^3.0.0",
|
||||||
"magic-string": "^0.30.11",
|
"magic-string": "^0.30.11",
|
||||||
@@ -1175,14 +1197,14 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/terser": {
|
"node_modules/terser": {
|
||||||
"version": "5.41.0",
|
"version": "5.44.1",
|
||||||
"resolved": "https://registry.npmjs.org/terser/-/terser-5.41.0.tgz",
|
"resolved": "https://registry.npmjs.org/terser/-/terser-5.44.1.tgz",
|
||||||
"integrity": "sha512-H406eLPXpZbAX14+B8psIuvIr8+3c+2hkuYzpMkoE0ij+NdsVATbA78vb8neA/eqrj7rywa2pIkdmWRsXW6wmw==",
|
"integrity": "sha512-t/R3R/n0MSwnnazuPpPNVO60LX0SKL45pyl9YlvxIdkH0Of7D5qM2EVe+yASRIlY5pZ73nclYJfNANGWPwFDZw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "BSD-2-Clause",
|
"license": "BSD-2-Clause",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@jridgewell/source-map": "^0.3.3",
|
"@jridgewell/source-map": "^0.3.3",
|
||||||
"acorn": "^8.14.0",
|
"acorn": "^8.15.0",
|
||||||
"commander": "^2.20.0",
|
"commander": "^2.20.0",
|
||||||
"source-map-support": "~0.5.20"
|
"source-map-support": "~0.5.20"
|
||||||
},
|
},
|
||||||
@@ -1221,9 +1243,9 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/zimmerframe": {
|
"node_modules/zimmerframe": {
|
||||||
"version": "1.1.2",
|
"version": "1.1.4",
|
||||||
"resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz",
|
||||||
"integrity": "sha512-rAbqEGa8ovJy4pyBxZM70hg4pE6gDgaQ0Sl9M3enG3I0d6H4XSAM3GeNGLKnsBpuijUow064sf7ww1nutC5/3w==",
|
"integrity": "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,13 +11,13 @@
|
|||||||
"@rollup/plugin-node-resolve": "^16.0.1",
|
"@rollup/plugin-node-resolve": "^16.0.1",
|
||||||
"@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.41.1",
|
"rollup": "^4.53.3",
|
||||||
"rollup-plugin-css-only": "^4.5.2",
|
"rollup-plugin-css-only": "^4.5.5",
|
||||||
"rollup-plugin-svelte": "^7.2.2",
|
"rollup-plugin-svelte": "^7.2.3",
|
||||||
"svelte": "^5.33.14"
|
"svelte": "^5.44.0"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@rollup/plugin-replace": "^6.0.2",
|
"@rollup/plugin-replace": "^6.0.3",
|
||||||
"@sveltestrap/sveltestrap": "^7.1.0",
|
"@sveltestrap/sveltestrap": "^7.1.0",
|
||||||
"@urql/svelte": "^4.2.3",
|
"@urql/svelte": "^4.2.3",
|
||||||
"chart.js": "^4.5.1",
|
"chart.js": "^4.5.1",
|
||||||
|
|||||||
@@ -12,9 +12,6 @@ const plugins = [
|
|||||||
compilerOptions: {
|
compilerOptions: {
|
||||||
// Enable run-time checks when not in production
|
// Enable run-time checks when not in production
|
||||||
dev: !production,
|
dev: !production,
|
||||||
// Enable Svelte 5-specific features
|
|
||||||
hydratable: true, // If using server-side rendering
|
|
||||||
immutable: true, // Optimize updates for immutable data
|
|
||||||
// As of sveltestrap 7.1.0, filtered warnings would appear for imported sveltestrap components
|
// As of sveltestrap 7.1.0, filtered warnings would appear for imported sveltestrap components
|
||||||
warningFilter: (warning) => (
|
warningFilter: (warning) => (
|
||||||
warning.code !== 'element_invalid_self_closing_tag' &&
|
warning.code !== 'element_invalid_self_closing_tag' &&
|
||||||
|
|||||||
@@ -76,7 +76,7 @@
|
|||||||
|
|
||||||
/* 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
|
||||||
let cluster = $state(filterPresets?.cluster);
|
let cluster = $state({});
|
||||||
let rooflineMaxY = $state(0);
|
let rooflineMaxY = $state(0);
|
||||||
let maxY = $state(-1);
|
let maxY = $state(-1);
|
||||||
let colWidth1 = $state(0);
|
let colWidth1 = $state(0);
|
||||||
@@ -370,7 +370,7 @@
|
|||||||
</Col>
|
</Col>
|
||||||
</Row>
|
</Row>
|
||||||
{:else if $statsQuery.data}
|
{:else if $statsQuery.data}
|
||||||
<Row cols={3} class="mb-4">
|
<Row cols={3} style="margin-bottom: 2rem;">
|
||||||
<Col>
|
<Col>
|
||||||
<Table>
|
<Table>
|
||||||
<tr>
|
<tr>
|
||||||
@@ -419,7 +419,7 @@
|
|||||||
{:else}
|
{:else}
|
||||||
<Pie
|
<Pie
|
||||||
canvasId={`pie-${groupSelection.key}`}
|
canvasId={`pie-${groupSelection.key}`}
|
||||||
size={colWidth1}
|
size={colWidth1 / 1.9}
|
||||||
sliceLabel={sortSelection.label}
|
sliceLabel={sortSelection.label}
|
||||||
quantities={$topQuery.data.topList.map(
|
quantities={$topQuery.data.topList.map(
|
||||||
(t) => t[sortSelection.key],
|
(t) => t[sortSelection.key],
|
||||||
@@ -490,13 +490,21 @@
|
|||||||
{:else if $rooflineQuery.error}
|
{:else if $rooflineQuery.error}
|
||||||
<Card body color="danger">{$rooflineQuery.error.message}</Card>
|
<Card body color="danger">{$rooflineQuery.error.message}</Card>
|
||||||
{:else if $rooflineQuery.data && cluster}
|
{:else if $rooflineQuery.data && cluster}
|
||||||
|
<div class="d-flex justify-content-center align-baseline">
|
||||||
|
<h5>Job Roofline Heatmap</h5>
|
||||||
|
<Icon
|
||||||
|
style="cursor:help; margin-left:0.5rem;"
|
||||||
|
name="info-circle"
|
||||||
|
title="Most Populated Areas By Selected Jobs' Average Values"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<div bind:clientWidth={colWidth2}>
|
<div bind:clientWidth={colWidth2}>
|
||||||
{#key $rooflineQuery.data}
|
{#key $rooflineQuery.data}
|
||||||
<RooflineHeatmap
|
<RooflineHeatmap
|
||||||
width={colWidth2}
|
width={colWidth2}
|
||||||
height={300}
|
height={280}
|
||||||
tiles={$rooflineQuery.data.rooflineHeatmap}
|
tiles={$rooflineQuery.data.rooflineHeatmap}
|
||||||
subCluster={cluster.subClusters.length == 1
|
subCluster={cluster.subClusters.length >= 1
|
||||||
? cluster.subClusters[0]
|
? cluster.subClusters[0]
|
||||||
: null}
|
: null}
|
||||||
maxY={rooflineMaxY}
|
maxY={rooflineMaxY}
|
||||||
|
|||||||
@@ -145,10 +145,9 @@
|
|||||||
<NavbarToggler onclick={() => (isOpen = !isOpen)} />
|
<NavbarToggler onclick={() => (isOpen = !isOpen)} />
|
||||||
<Collapse
|
<Collapse
|
||||||
style="justify-content: space-between"
|
style="justify-content: space-between"
|
||||||
|
expand="md"
|
||||||
{isOpen}
|
{isOpen}
|
||||||
navbar
|
navbar
|
||||||
expand="md"
|
|
||||||
onupdate={({ detail }) => (isOpen = detail.isOpen)}
|
|
||||||
>
|
>
|
||||||
<Nav navbar>
|
<Nav navbar>
|
||||||
{#if showMax || showBrg}
|
{#if showMax || showBrg}
|
||||||
|
|||||||
@@ -79,6 +79,11 @@
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
// Load Metric-Selection for last selected cluster
|
||||||
|
metrics = selectedCluster ? ccconfig[`metricConfig_jobListMetrics:${selectedCluster}`] : ccconfig.metricConfig_jobListMetrics
|
||||||
|
});
|
||||||
|
|
||||||
/* 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.
|
||||||
|
|||||||
@@ -16,6 +16,7 @@
|
|||||||
Row,
|
Row,
|
||||||
Col,
|
Col,
|
||||||
Input,
|
Input,
|
||||||
|
Button,
|
||||||
InputGroup,
|
InputGroup,
|
||||||
InputGroupText,
|
InputGroupText,
|
||||||
Icon,
|
Icon,
|
||||||
@@ -63,6 +64,7 @@
|
|||||||
query ($cluster: String!, $nodes: [String!], $from: Time!, $to: Time!) {
|
query ($cluster: String!, $nodes: [String!], $from: Time!, $to: Time!) {
|
||||||
nodeMetrics(cluster: $cluster, nodes: $nodes, from: $from, to: $to) {
|
nodeMetrics(cluster: $cluster, nodes: $nodes, from: $from, to: $to) {
|
||||||
host
|
host
|
||||||
|
state
|
||||||
subCluster
|
subCluster
|
||||||
metrics {
|
metrics {
|
||||||
name
|
name
|
||||||
@@ -97,6 +99,16 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
// Node State Colors
|
||||||
|
const stateColors = {
|
||||||
|
allocated: 'success',
|
||||||
|
reserved: 'info',
|
||||||
|
idle: 'primary',
|
||||||
|
mixed: 'warning',
|
||||||
|
down: 'danger',
|
||||||
|
unknown: 'dark',
|
||||||
|
notindb: 'secondary'
|
||||||
|
}
|
||||||
|
|
||||||
/* State Init */
|
/* State Init */
|
||||||
let from = $state(presetFrom ? presetFrom : new Date(nowEpoch - (4 * 3600 * 1000)));
|
let from = $state(presetFrom ? presetFrom : new Date(nowEpoch - (4 * 3600 * 1000)));
|
||||||
@@ -123,6 +135,8 @@
|
|||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const thisNodeState = $derived($nodeMetricsData?.data?.nodeMetrics[0]?.state ? $nodeMetricsData.data.nodeMetrics[0].state : 'notindb');
|
||||||
|
|
||||||
/* Effect */
|
/* Effect */
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
loadUnits($initialized);
|
loadUnits($initialized);
|
||||||
@@ -138,7 +152,7 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Row cols={{ xs: 2, lg: 4 }}>
|
<Row cols={{ xs: 2, lg: 5 }}>
|
||||||
{#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.fetching}
|
{:else if $initq.fetching}
|
||||||
@@ -149,19 +163,18 @@
|
|||||||
<InputGroup>
|
<InputGroup>
|
||||||
<InputGroupText><Icon name="hdd" /></InputGroupText>
|
<InputGroupText><Icon name="hdd" /></InputGroupText>
|
||||||
<InputGroupText>Selected Node</InputGroupText>
|
<InputGroupText>Selected Node</InputGroupText>
|
||||||
<Input style="background-color: white;"type="text" value="{hostname} [{cluster} ({$nodeMetricsData?.data ? $nodeMetricsData.data.nodeMetrics[0].subCluster : ''})]" disabled/>
|
<Input style="background-color: white;" type="text" value="{hostname} [{cluster} {$nodeMetricsData?.data ? `(${$nodeMetricsData.data.nodeMetrics[0].subCluster})` : ''}]" disabled/>
|
||||||
</InputGroup>
|
</InputGroup>
|
||||||
</Col>
|
</Col>
|
||||||
<!-- Time Col -->
|
<!-- State Col -->
|
||||||
<Col>
|
<Col>
|
||||||
<TimeSelection
|
<InputGroup>
|
||||||
presetFrom={from}
|
<InputGroupText><Icon name="clipboard2-pulse" /></InputGroupText>
|
||||||
presetTo={to}
|
<InputGroupText>Node State</InputGroupText>
|
||||||
applyTime={(newFrom, newTo) => {
|
<Button class="flex-grow-1 text-center" color={stateColors[thisNodeState]} disabled>
|
||||||
from = newFrom;
|
{thisNodeState}
|
||||||
to = newTo;
|
</Button>
|
||||||
}}
|
</InputGroup>
|
||||||
/>
|
|
||||||
</Col>
|
</Col>
|
||||||
<!-- Concurrent Col -->
|
<!-- Concurrent Col -->
|
||||||
<Col class="mt-2 mt-lg-0">
|
<Col class="mt-2 mt-lg-0">
|
||||||
@@ -184,6 +197,17 @@
|
|||||||
</InputGroup>
|
</InputGroup>
|
||||||
{/if}
|
{/if}
|
||||||
</Col>
|
</Col>
|
||||||
|
<!-- Time Col -->
|
||||||
|
<Col>
|
||||||
|
<TimeSelection
|
||||||
|
presetFrom={from}
|
||||||
|
presetTo={to}
|
||||||
|
applyTime={(newFrom, newTo) => {
|
||||||
|
from = newFrom;
|
||||||
|
to = newTo;
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Col>
|
||||||
<!-- Refresh Col-->
|
<!-- Refresh Col-->
|
||||||
<Col class="mt-2 mt-lg-0">
|
<Col class="mt-2 mt-lg-0">
|
||||||
<Refresher
|
<Refresher
|
||||||
@@ -217,6 +241,7 @@
|
|||||||
cluster={clusters.find((c) => c.name == cluster)}
|
cluster={clusters.find((c) => c.name == cluster)}
|
||||||
subCluster={$nodeMetricsData.data.nodeMetrics[0].subCluster}
|
subCluster={$nodeMetricsData.data.nodeMetrics[0].subCluster}
|
||||||
series={item.metric.series}
|
series={item.metric.series}
|
||||||
|
enableFlip
|
||||||
forNode
|
forNode
|
||||||
/>
|
/>
|
||||||
{:else if item.disabled === true && item.metric}
|
{:else if item.disabled === true && item.metric}
|
||||||
|
|||||||
@@ -9,13 +9,17 @@
|
|||||||
import {
|
import {
|
||||||
getContext
|
getContext
|
||||||
} from "svelte"
|
} from "svelte"
|
||||||
|
import {
|
||||||
|
init,
|
||||||
|
} from "./generic/utils.js";
|
||||||
import {
|
import {
|
||||||
Row,
|
Row,
|
||||||
Col,
|
Col,
|
||||||
Card,
|
Card,
|
||||||
CardBody,
|
CardBody,
|
||||||
TabContent,
|
TabContent,
|
||||||
TabPane
|
TabPane,
|
||||||
|
Spinner
|
||||||
} from "@sveltestrap/sveltestrap";
|
} from "@sveltestrap/sveltestrap";
|
||||||
|
|
||||||
import StatusDash from "./status/StatusDash.svelte";
|
import StatusDash from "./status/StatusDash.svelte";
|
||||||
@@ -28,8 +32,8 @@
|
|||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
/*Const Init */
|
/*Const Init */
|
||||||
|
const { query: initq } = init();
|
||||||
const useCbColors = getContext("cc-config")?.plotConfiguration_colorblindMode || false
|
const useCbColors = getContext("cc-config")?.plotConfiguration_colorblindMode || false
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<!-- Loading indicator & Refresh -->
|
<!-- Loading indicator & Refresh -->
|
||||||
@@ -40,24 +44,39 @@
|
|||||||
</Col>
|
</Col>
|
||||||
</Row>
|
</Row>
|
||||||
|
|
||||||
<Card class="overflow-auto" style="height: auto;">
|
|
||||||
<TabContent>
|
|
||||||
<TabPane tabId="status-dash" tab="Status" active>
|
|
||||||
<CardBody>
|
|
||||||
<StatusDash {presetCluster} {useCbColors} useAltColors></StatusDash>
|
|
||||||
</CardBody>
|
|
||||||
</TabPane>
|
|
||||||
|
|
||||||
<TabPane tabId="usage-dash" tab="Usage">
|
{#if $initq.fetching}
|
||||||
<CardBody>
|
<Row cols={1} class="text-center mt-3">
|
||||||
<UsageDash {presetCluster} {useCbColors}></UsageDash>
|
<Col>
|
||||||
</CardBody>
|
<Spinner />
|
||||||
</TabPane>
|
</Col>
|
||||||
|
</Row>
|
||||||
|
{:else if $initq.error}
|
||||||
|
<Row cols={1} class="text-center mt-3">
|
||||||
|
<Col>
|
||||||
|
<Card body color="danger">{$initq.error.message}</Card>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
{:else}
|
||||||
|
<Card class="overflow-auto" style="height: auto;">
|
||||||
|
<TabContent>
|
||||||
|
<TabPane tabId="status-dash" tab="Status" active>
|
||||||
|
<CardBody>
|
||||||
|
<StatusDash clusters={$initq.data.clusters} {presetCluster} {useCbColors} useAltColors></StatusDash>
|
||||||
|
</CardBody>
|
||||||
|
</TabPane>
|
||||||
|
|
||||||
<TabPane tabId="metric-dash" tab="Statistics">
|
<TabPane tabId="usage-dash" tab="Usage">
|
||||||
<CardBody>
|
<CardBody>
|
||||||
<StatisticsDash {presetCluster} {useCbColors}></StatisticsDash>
|
<UsageDash {presetCluster} {useCbColors}></UsageDash>
|
||||||
</CardBody>
|
</CardBody>
|
||||||
</TabPane>
|
</TabPane>
|
||||||
</TabContent>
|
|
||||||
</Card>
|
<TabPane tabId="metric-dash" tab="Statistics">
|
||||||
|
<CardBody>
|
||||||
|
<StatisticsDash {presetCluster} {useCbColors}></StatisticsDash>
|
||||||
|
</CardBody>
|
||||||
|
</TabPane>
|
||||||
|
</TabContent>
|
||||||
|
</Card>
|
||||||
|
{/if}
|
||||||
|
|||||||
@@ -59,6 +59,7 @@
|
|||||||
|
|
||||||
const resampleResolutions = resampleConfig ? [...resampleConfig.resolutions] : [];
|
const resampleResolutions = resampleConfig ? [...resampleConfig.resolutions] : [];
|
||||||
const resampleDefault = resampleConfig ? Math.max(...resampleConfig.resolutions) : 0;
|
const resampleDefault = resampleConfig ? Math.max(...resampleConfig.resolutions) : 0;
|
||||||
|
const stateOptions = ['all', 'allocated', 'idle', 'reserved', 'mixed', 'down', 'unknown', 'notindb'];
|
||||||
const nowDate = new Date(Date.now());
|
const nowDate = new Date(Date.now());
|
||||||
|
|
||||||
/* Var Init */
|
/* Var Init */
|
||||||
@@ -69,6 +70,7 @@
|
|||||||
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("");
|
||||||
|
let hoststateFilter = $state("all");
|
||||||
let pendingHostnameFilter = $state("");
|
let pendingHostnameFilter = $state("");
|
||||||
let isMetricsSelectionOpen = $state(false);
|
let isMetricsSelectionOpen = $state(false);
|
||||||
|
|
||||||
@@ -154,8 +156,8 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<!-- ROW1: Tools-->
|
<!-- ROW1: Tools-->
|
||||||
<Row cols={{ xs: 2, lg: !displayNodeOverview ? (resampleConfig ? 5 : 4) : 4 }} class="mb-3">
|
<Row cols={{ xs: 2, lg: !displayNodeOverview ? (resampleConfig ? 6 : 5) : 5 }} class="mb-3">
|
||||||
{#if $initq.data}
|
{#if $initq?.data}
|
||||||
<!-- List Metric Select Col-->
|
<!-- List Metric Select Col-->
|
||||||
{#if !displayNodeOverview}
|
{#if !displayNodeOverview}
|
||||||
<Col>
|
<Col>
|
||||||
@@ -191,7 +193,7 @@
|
|||||||
<Col class="mt-2 mt-lg-0">
|
<Col class="mt-2 mt-lg-0">
|
||||||
<InputGroup>
|
<InputGroup>
|
||||||
<InputGroupText><Icon name="hdd" /></InputGroupText>
|
<InputGroupText><Icon name="hdd" /></InputGroupText>
|
||||||
<InputGroupText>Find Node(s)</InputGroupText>
|
<InputGroupText>Node(s)</InputGroupText>
|
||||||
<Input
|
<Input
|
||||||
placeholder="Filter hostname ..."
|
placeholder="Filter hostname ..."
|
||||||
type="text"
|
type="text"
|
||||||
@@ -200,6 +202,18 @@
|
|||||||
/>
|
/>
|
||||||
</InputGroup>
|
</InputGroup>
|
||||||
</Col>
|
</Col>
|
||||||
|
<!-- State Col-->
|
||||||
|
<Col class="mt-2 mt-lg-0">
|
||||||
|
<InputGroup>
|
||||||
|
<InputGroupText><Icon name="clipboard2-pulse" /></InputGroupText>
|
||||||
|
<InputGroupText>State</InputGroupText>
|
||||||
|
<Input type="select" bind:value={hoststateFilter}>
|
||||||
|
{#each stateOptions as so}
|
||||||
|
<option value={so}>{so.charAt(0).toUpperCase() + so.slice(1)}</option>
|
||||||
|
{/each}
|
||||||
|
</Input>
|
||||||
|
</InputGroup>
|
||||||
|
</Col>
|
||||||
<!-- Range Col-->
|
<!-- Range Col-->
|
||||||
<Col>
|
<Col>
|
||||||
<TimeSelection
|
<TimeSelection
|
||||||
@@ -252,10 +266,10 @@
|
|||||||
{:else}
|
{:else}
|
||||||
{#if displayNodeOverview}
|
{#if displayNodeOverview}
|
||||||
<!-- ROW2-1: Node Overview (Grid Included)-->
|
<!-- ROW2-1: Node Overview (Grid Included)-->
|
||||||
<NodeOverview {cluster} {ccconfig} {selectedMetric} {from} {to} {hostnameFilter}/>
|
<NodeOverview {cluster} {ccconfig} {selectedMetric} {from} {to} {hostnameFilter} {hoststateFilter}/>
|
||||||
{:else}
|
{:else}
|
||||||
<!-- ROW2-2: Node List (Grid Included)-->
|
<!-- ROW2-2: Node List (Grid Included)-->
|
||||||
<NodeList {cluster} {subCluster} {ccconfig} {selectedMetrics} {selectedResolution} {hostnameFilter} {from} {to} {presetSystemUnits}/>
|
<NodeList {cluster} {subCluster} {ccconfig} {selectedMetrics} {selectedResolution} {hostnameFilter} {hoststateFilter} {from} {to} {presetSystemUnits}/>
|
||||||
{/if}
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
|||||||
@@ -133,6 +133,11 @@
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
// Load Metric-Selection for last selected cluster
|
||||||
|
metrics = selectedCluster ? ccconfig[`metricConfig_jobListMetrics:${selectedCluster}`] : ccconfig.metricConfig_jobListMetrics
|
||||||
|
});
|
||||||
|
|
||||||
/* On Mount */
|
/* On Mount */
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
filterComponent.updateFilters();
|
filterComponent.updateFilters();
|
||||||
@@ -348,6 +353,7 @@
|
|||||||
ylabel="Number of Jobs"
|
ylabel="Number of Jobs"
|
||||||
yunit="Jobs"
|
yunit="Jobs"
|
||||||
usesBins
|
usesBins
|
||||||
|
enableFlip
|
||||||
/>
|
/>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
|
|
||||||
|
|||||||
@@ -27,7 +27,7 @@
|
|||||||
function refreshIntervalChanged() {
|
function refreshIntervalChanged() {
|
||||||
if (refreshIntervalId != null) clearInterval(refreshIntervalId);
|
if (refreshIntervalId != null) clearInterval(refreshIntervalId);
|
||||||
if (refreshInterval == null) return;
|
if (refreshInterval == null) return;
|
||||||
refreshIntervalId = setInterval(() => onRefresh(), refreshInterval);
|
refreshIntervalId = setInterval(() => onRefresh(refreshInterval), refreshInterval);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Svelte 5 onMount */
|
/* Svelte 5 onMount */
|
||||||
@@ -51,7 +51,7 @@
|
|||||||
</Input>
|
</Input>
|
||||||
<Button
|
<Button
|
||||||
outline
|
outline
|
||||||
onclick={() => onRefresh()}
|
onclick={() => onRefresh(refreshInterval)}
|
||||||
disabled={refreshInterval != null}
|
disabled={refreshInterval != null}
|
||||||
>
|
>
|
||||||
<Icon name="arrow-clockwise" /> Refresh
|
<Icon name="arrow-clockwise" /> Refresh
|
||||||
|
|||||||
@@ -39,7 +39,7 @@
|
|||||||
yunit = "",
|
yunit = "",
|
||||||
title = "",
|
title = "",
|
||||||
forResources = false,
|
forResources = false,
|
||||||
plotSync,
|
plotSync = null,
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
/* Const Init */
|
/* Const Init */
|
||||||
@@ -204,11 +204,7 @@
|
|||||||
live: true,
|
live: true,
|
||||||
},
|
},
|
||||||
cursor: {
|
cursor: {
|
||||||
drag: { x: true, y: true },
|
drag: { x: true, y: true }
|
||||||
sync: {
|
|
||||||
key: plotSync.key,
|
|
||||||
scales: ["x", null],
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -275,9 +271,12 @@
|
|||||||
|
|
||||||
function update(u) {
|
function update(u) {
|
||||||
const { left, top } = u.cursor;
|
const { left, top } = u.cursor;
|
||||||
const width = u?.over?.querySelector(".u-legend")?.offsetWidth ? u.over.querySelector(".u-legend").offsetWidth : 0;
|
const internalWidth = u?.over?.querySelector(".u-legend")?.offsetWidth ? u.over.querySelector(".u-legend").offsetWidth : 0;
|
||||||
legendEl.style.transform =
|
if (left < (width/2)) {
|
||||||
"translate(" + (left - width - 15) + "px, " + (top + 15) + "px)";
|
legendEl.style.transform = "translate(" + (left + 15) + "px, " + (top + 15) + "px)";
|
||||||
|
} else {
|
||||||
|
legendEl.style.transform = "translate(" + (left - internalWidth - 15) + "px, " + (top + 15) + "px)";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -293,6 +292,14 @@
|
|||||||
if (!uplot) {
|
if (!uplot) {
|
||||||
opts.width = ren_width;
|
opts.width = ren_width;
|
||||||
opts.height = ren_height;
|
opts.height = ren_height;
|
||||||
|
|
||||||
|
if (plotSync) {
|
||||||
|
opts.cursor.sync = {
|
||||||
|
key: plotSync.key,
|
||||||
|
scales: ["x", null],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
uplot = new uPlot(opts, data, plotWrapper); // Data is uplot formatted [[X][Ymin][Yavg][Ymax]]
|
uplot = new uPlot(opts, data, plotWrapper); // Data is uplot formatted [[X][Ymin][Yavg][Ymax]]
|
||||||
plotSync.sub(uplot)
|
plotSync.sub(uplot)
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -34,6 +34,7 @@
|
|||||||
xtime = false,
|
xtime = false,
|
||||||
ylabel = "",
|
ylabel = "",
|
||||||
yunit = "",
|
yunit = "",
|
||||||
|
enableFlip = false,
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
/* Const Init */
|
/* Const Init */
|
||||||
@@ -117,8 +118,12 @@
|
|||||||
|
|
||||||
function update(u) {
|
function update(u) {
|
||||||
const { left, top } = u.cursor;
|
const { left, top } = u.cursor;
|
||||||
legendEl.style.transform =
|
const internalWidth = u?.over?.querySelector(".u-legend")?.offsetWidth ? u.over.querySelector(".u-legend").offsetWidth : 0;
|
||||||
"translate(" + (left + 15) + "px, " + (top + 15) + "px)";
|
if (enableFlip && (left < (width/2))) {
|
||||||
|
legendEl.style.transform = "translate(" + (left + 15) + "px, " + (top + 15) + "px)";
|
||||||
|
} else {
|
||||||
|
legendEl.style.transform = "translate(" + (left - internalWidth - 15) + "px, " + (top + 15) + "px)";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
- `statisticsSeries [GraphQL.StatisticsSeries]?`: Min/Max/Median representation of metric data [Default: null]
|
- `statisticsSeries [GraphQL.StatisticsSeries]?`: Min/Max/Median representation of metric data [Default: null]
|
||||||
- `cluster String?`: Cluster name of the parent job / data [Default: ""]
|
- `cluster String?`: Cluster name of the parent job / data [Default: ""]
|
||||||
- `subCluster String`: Name of the subCluster of the parent job
|
- `subCluster String`: Name of the subCluster of the parent job
|
||||||
- `isShared Bool?`: If this job used shared resources; will adapt threshold indicators accordingly [Default: false]
|
- `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]
|
- `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]
|
- `numhwthreads Number?`: Number of job HWThreads [Default: 0]
|
||||||
- `numaccs Number?`: Number of job Accelerators [Default: 0]
|
- `numaccs Number?`: Number of job Accelerators [Default: 0]
|
||||||
@@ -48,6 +48,8 @@
|
|||||||
zoomState = null,
|
zoomState = null,
|
||||||
thresholdState = null,
|
thresholdState = null,
|
||||||
extendedLegendData = null,
|
extendedLegendData = null,
|
||||||
|
plotSync = null,
|
||||||
|
enableFlip = false,
|
||||||
onZoom
|
onZoom
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
@@ -83,7 +85,6 @@
|
|||||||
subClusterTopology,
|
subClusterTopology,
|
||||||
metricConfig,
|
metricConfig,
|
||||||
scope,
|
scope,
|
||||||
isShared,
|
|
||||||
numhwthreads,
|
numhwthreads,
|
||||||
numaccs
|
numaccs
|
||||||
));
|
));
|
||||||
@@ -277,7 +278,6 @@
|
|||||||
subClusterTopology,
|
subClusterTopology,
|
||||||
metricConfig,
|
metricConfig,
|
||||||
scope,
|
scope,
|
||||||
isShared,
|
|
||||||
numhwthreads,
|
numhwthreads,
|
||||||
numaccs
|
numaccs
|
||||||
) {
|
) {
|
||||||
@@ -293,32 +293,35 @@
|
|||||||
scope = statParts[0]
|
scope = statParts[0]
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (metricConfig?.aggregation == "avg") {
|
||||||
(scope == "node" && isShared == false) ||
|
// Return as Configured
|
||||||
metricConfig?.aggregation == "avg"
|
return {
|
||||||
) {
|
normal: metricConfig.normal,
|
||||||
return {
|
caution: metricConfig.caution,
|
||||||
normal: metricConfig.normal,
|
alert: metricConfig.alert,
|
||||||
caution: metricConfig.caution,
|
peak: metricConfig.peak,
|
||||||
alert: metricConfig.alert,
|
};
|
||||||
peak: metricConfig.peak,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (metricConfig?.aggregation == "sum") {
|
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;
|
let divisor;
|
||||||
if (isShared == true) { // Shared
|
// Exclusive: Fraction = 1; Shared: Fraction > 1
|
||||||
if (numaccs > 0) divisor = subClusterTopology.accelerators.length / numaccs;
|
if (scope == 'node') divisor = fraction;
|
||||||
else if (numhwthreads > 0) divisor = subClusterTopology.core.length / numhwthreads;
|
// 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 == 'node') divisor = 1; // Use as configured for nodes
|
else if (scope == "memoryDomain") divisor = (fraction < subClusterTopology.memoryDomain.length) ? subClusterTopology.socket.length : fraction;
|
||||||
else if (scope == 'socket') divisor = subClusterTopology.socket.length;
|
// Use Maximum Division for Smallest Scopes
|
||||||
else if (scope == "memoryDomain") divisor = subClusterTopology.memoryDomain.length;
|
|
||||||
else if (scope == "core") divisor = subClusterTopology.core.length;
|
else if (scope == "core") divisor = subClusterTopology.core.length;
|
||||||
else if (scope == "hwthread") divisor = subClusterTopology.core.length; // alt. name for core
|
else if (scope == "hwthread") divisor = subClusterTopology.core.length; // alt. name for core
|
||||||
else if (scope == "accelerator") divisor = subClusterTopology.accelerators.length;
|
else if (scope == "accelerator") divisor = subClusterTopology.accelerators.length;
|
||||||
else {
|
else {
|
||||||
console.log('Unknown scope, return default aggregation thresholds ', scope)
|
console.log('Unknown scope, return default aggregation thresholds for sum', scope)
|
||||||
divisor = 1;
|
divisor = 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -395,9 +398,12 @@
|
|||||||
|
|
||||||
function update(u) {
|
function update(u) {
|
||||||
const { left, top } = u.cursor;
|
const { left, top } = u.cursor;
|
||||||
const width = u?.over?.querySelector(".u-legend")?.offsetWidth ? u.over.querySelector(".u-legend").offsetWidth : 0;
|
const internalWidth = u?.over?.querySelector(".u-legend")?.offsetWidth ? u.over.querySelector(".u-legend").offsetWidth : 0;
|
||||||
legendEl.style.transform =
|
if (enableFlip && (left < (width/2))) {
|
||||||
"translate(" + (left - width - 15) + "px, " + (top + 15) + "px)";
|
legendEl.style.transform = "translate(" + (left + 15) + "px, " + (top + 15) + "px)";
|
||||||
|
} else {
|
||||||
|
legendEl.style.transform = "translate(" + (left - internalWidth - 15) + "px, " + (top + 15) + "px)";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (dataSize <= 12 || useStatsSeries) {
|
if (dataSize <= 12 || useStatsSeries) {
|
||||||
@@ -576,6 +582,14 @@
|
|||||||
if (!uplot) {
|
if (!uplot) {
|
||||||
opts.width = ren_width;
|
opts.width = ren_width;
|
||||||
opts.height = ren_height;
|
opts.height = ren_height;
|
||||||
|
|
||||||
|
if (plotSync) {
|
||||||
|
opts.cursor.sync = {
|
||||||
|
key: plotSync.key,
|
||||||
|
scales: ["x", null],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (zoomState && metricConfig?.aggregation == "avg") {
|
if (zoomState && metricConfig?.aggregation == "avg") {
|
||||||
opts.scales = {...zoomState}
|
opts.scales = {...zoomState}
|
||||||
} else if (zoomState && metricConfig?.aggregation == "sum") {
|
} else if (zoomState && metricConfig?.aggregation == "sum") {
|
||||||
@@ -584,6 +598,7 @@
|
|||||||
if ((thresholdState === thresholds?.normal)) { opts.scales = {...zoomState} };
|
if ((thresholdState === thresholds?.normal)) { opts.scales = {...zoomState} };
|
||||||
} // else: reset scaling to default
|
} // else: reset scaling to default
|
||||||
}
|
}
|
||||||
|
|
||||||
uplot = new uPlot(opts, plotData, plotWrapper);
|
uplot = new uPlot(opts, plotData, plotWrapper);
|
||||||
} else {
|
} else {
|
||||||
uplot.setSize({ width: ren_width, height: ren_height });
|
uplot.setSize({ width: ren_width, height: ren_height });
|
||||||
|
|||||||
@@ -276,13 +276,13 @@
|
|||||||
// Nodes: Color based on Idle vs. Allocated
|
// Nodes: Color based on Idle vs. Allocated
|
||||||
} else if (nodesData) {
|
} else if (nodesData) {
|
||||||
// console.log('In Plot Handler NodesData', nodesData)
|
// console.log('In Plot Handler NodesData', nodesData)
|
||||||
if (nodesData[i]?.nodeState == "idle") {
|
if (nodesData[i]?.schedulerState == "idle") {
|
||||||
//u.ctx.strokeStyle = "rgb(0, 0, 255)";
|
//u.ctx.strokeStyle = "rgb(0, 0, 255)";
|
||||||
u.ctx.fillStyle = "rgba(0, 0, 255, 0.5)";
|
u.ctx.fillStyle = "rgba(0, 0, 255, 0.5)";
|
||||||
} else if (nodesData[i]?.nodeState == "allocated") {
|
} else if (nodesData[i]?.schedulerState == "allocated") {
|
||||||
//u.ctx.strokeStyle = "rgb(0, 255, 0)";
|
//u.ctx.strokeStyle = "rgb(0, 255, 0)";
|
||||||
u.ctx.fillStyle = "rgba(0, 255, 0, 0.5)";
|
u.ctx.fillStyle = "rgba(0, 255, 0, 0.5)";
|
||||||
} else if (nodesData[i]?.nodeState == "notindb") {
|
} else if (nodesData[i]?.schedulerState == "notindb") {
|
||||||
//u.ctx.strokeStyle = "rgb(0, 0, 0)";
|
//u.ctx.strokeStyle = "rgb(0, 0, 0)";
|
||||||
u.ctx.fillStyle = "rgba(0, 0, 0, 0.5)";
|
u.ctx.fillStyle = "rgba(0, 0, 0, 0.5)";
|
||||||
} else { // Fallback: All other DEFINED states
|
} else { // Fallback: All other DEFINED states
|
||||||
@@ -436,11 +436,11 @@
|
|||||||
tooltip.style.borderColor = getRGB(u.data[2][i]);
|
tooltip.style.borderColor = getRGB(u.data[2][i]);
|
||||||
// Nodes: Color based on Idle vs. Allocated
|
// Nodes: Color based on Idle vs. Allocated
|
||||||
} else if (nodesData) {
|
} else if (nodesData) {
|
||||||
if (nodesData[i]?.nodeState == "idle") {
|
if (nodesData[i]?.schedulerState == "idle") {
|
||||||
tooltip.style.borderColor = "rgb(0, 0, 255)";
|
tooltip.style.borderColor = "rgb(0, 0, 255)";
|
||||||
} else if (nodesData[i]?.nodeState == "allocated") {
|
} else if (nodesData[i]?.schedulerState == "allocated") {
|
||||||
tooltip.style.borderColor = "rgb(0, 255, 0)";
|
tooltip.style.borderColor = "rgb(0, 255, 0)";
|
||||||
} else if (nodesData[i]?.nodeState == "notindb") { // Missing from DB table
|
} else if (nodesData[i]?.schedulerState == "notindb") { // Missing from DB table
|
||||||
tooltip.style.borderColor = "rgb(0, 0, 0)";
|
tooltip.style.borderColor = "rgb(0, 0, 0)";
|
||||||
} else { // Fallback: All other DEFINED states
|
} else { // Fallback: All other DEFINED states
|
||||||
tooltip.style.borderColor = "rgb(255, 0, 0)";
|
tooltip.style.borderColor = "rgb(255, 0, 0)";
|
||||||
@@ -459,7 +459,7 @@
|
|||||||
} else if (nodesData && useColors) {
|
} else if (nodesData && useColors) {
|
||||||
tooltip.textContent = (
|
tooltip.textContent = (
|
||||||
// Tooltip Content as String for Node
|
// Tooltip Content as String for Node
|
||||||
`Host: ${getLegendData(u, i).nodeName}\nState: ${getLegendData(u, i).nodeState}\nJobs: ${getLegendData(u, i).numJobs}`
|
`Host: ${getLegendData(u, i).nodeName}\nState: ${getLegendData(u, i).schedulerState}\nJobs: ${getLegendData(u, i).numJobs}`
|
||||||
);
|
);
|
||||||
} else if (nodesData && !useColors) {
|
} else if (nodesData && !useColors) {
|
||||||
tooltip.textContent = (
|
tooltip.textContent = (
|
||||||
|
|||||||
@@ -34,7 +34,7 @@
|
|||||||
const paddingLeft = 40;
|
const paddingLeft = 40;
|
||||||
const paddingRight = 10;
|
const paddingRight = 10;
|
||||||
const paddingTop = 10;
|
const paddingTop = 10;
|
||||||
const paddingBottom = 5;
|
const paddingBottom = 40;
|
||||||
|
|
||||||
/* Var Init */
|
/* Var Init */
|
||||||
let timeoutId = null;
|
let timeoutId = null;
|
||||||
@@ -124,7 +124,7 @@
|
|||||||
if (data.xLabel) {
|
if (data.xLabel) {
|
||||||
ctx.font = `${labelFontSize}px ${fontFamily}`
|
ctx.font = `${labelFontSize}px ${fontFamily}`
|
||||||
let textWidth = ctx.measureText(data.xLabel).width
|
let textWidth = ctx.measureText(data.xLabel).width
|
||||||
ctx.fillText(data.xLabel, Math.floor((width / 2) - (textWidth / 2)), height - 20)
|
ctx.fillText(data.xLabel, Math.floor((width / 2) - (textWidth / 2)), height - paddingBottom + 30)
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx.textAlign = 'center'
|
ctx.textAlign = 'center'
|
||||||
|
|||||||
@@ -1,34 +1,25 @@
|
|||||||
<!--
|
<!--
|
||||||
@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
|
||||||
|
|
||||||
Only width/height should change reactively.
|
|
||||||
|
|
||||||
Properties:
|
Properties:
|
||||||
- `metric String?`: The metric name [Default: ""]
|
|
||||||
- `width Number?`: The plot width [Default: 0]
|
- `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]
|
||||||
- `title String?`: Plot title [Default: ""]
|
|
||||||
- `xlabel String?`: Plot X axis label [Default: ""]
|
- `xlabel String?`: Plot X axis label [Default: ""]
|
||||||
- `ylabel String?`: Plot Y axis label [Default: ""]
|
- `ylabel String?`: Plot Y axis label [Default: ""]
|
||||||
- `yunit String?`: Plot Y axis unit [Default: ""]
|
- `yunit String?`: Plot Y axis unit [Default: ""]
|
||||||
- `xticks Array`: Array containing jobIDs [Default: []]
|
- `title String?`: Plot title [Default: ""]
|
||||||
- `xinfo Array`: Array containing job information [Default: []]
|
- `stateType String?`: Which states to render, affects plot render config [Options: Health, Node; Default: ""]
|
||||||
- `forResources Bool?`: Render this plot for allocated jobResources [Default: false]
|
|
||||||
- `plot Sync Object!`: uPlot cursor synchronization key
|
|
||||||
-->
|
-->
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
import uPlot from "uplot";
|
import uPlot from "uplot";
|
||||||
import { roundTwoDigits, formatDurationTime, formatUnixTime, formatNumber } from "../units.js";
|
import { formatUnixTime } from "../units.js";
|
||||||
import { getContext, onMount, onDestroy } from "svelte";
|
import { getContext, onMount, onDestroy } from "svelte";
|
||||||
import { Card } from "@sveltestrap/sveltestrap";
|
import { Card } from "@sveltestrap/sveltestrap";
|
||||||
|
|
||||||
// NOTE: Metric Thresholds non-required, Cluster Mixing Allowed
|
|
||||||
|
|
||||||
/* Svelte 5 Props */
|
/* Svelte 5 Props */
|
||||||
let {
|
let {
|
||||||
cluster = "",
|
|
||||||
width = 0,
|
width = 0,
|
||||||
height = 300,
|
height = 300,
|
||||||
data = null,
|
data = null,
|
||||||
@@ -36,16 +27,86 @@
|
|||||||
ylabel = "",
|
ylabel = "",
|
||||||
yunit = "",
|
yunit = "",
|
||||||
title = "",
|
title = "",
|
||||||
stateType = "" // Health, Slurm, Both
|
stateType = "" // Health, Node
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
/* Const Init */
|
/* Const Init */
|
||||||
const clusterCockpitConfig = getContext("cc-config");
|
const clusterCockpitConfig = getContext("cc-config");
|
||||||
const lineWidth = clusterCockpitConfig?.plotConfiguration_lineWidth / window.devicePixelRatio || 2;
|
const lineWidth = clusterCockpitConfig?.plotConfiguration_lineWidth / window.devicePixelRatio || 2;
|
||||||
const cbmode = clusterCockpitConfig?.plotConfiguration_colorblindMode || false;
|
const cbmode = clusterCockpitConfig?.plotConfiguration_colorblindMode || false;
|
||||||
|
const seriesConfig = {
|
||||||
|
full: {
|
||||||
|
label: "Full",
|
||||||
|
scale: "y",
|
||||||
|
width: lineWidth,
|
||||||
|
fill: cbmode ? "rgba(0, 110, 0, 0.4)" : "rgba(0, 128, 0, 0.4)",
|
||||||
|
stroke: cbmode ? "rgb(0, 110, 0)" : "green",
|
||||||
|
},
|
||||||
|
partial: {
|
||||||
|
label: "Partial",
|
||||||
|
scale: "y",
|
||||||
|
width: lineWidth,
|
||||||
|
fill: cbmode ? "rgba(235, 172, 35, 0.4)" : "rgba(255, 215, 0, 0.4)",
|
||||||
|
stroke: cbmode ? "rgb(235, 172, 35)" : "gold",
|
||||||
|
},
|
||||||
|
failed: {
|
||||||
|
label: "Failed",
|
||||||
|
scale: "y",
|
||||||
|
width: lineWidth,
|
||||||
|
fill: cbmode ? "rgb(181, 29, 20, 0.4)" : "rgba(255, 0, 0, 0.4)",
|
||||||
|
stroke: cbmode ? "rgb(181, 29, 20)" : "red",
|
||||||
|
},
|
||||||
|
idle: {
|
||||||
|
label: "Idle",
|
||||||
|
scale: "y",
|
||||||
|
width: lineWidth,
|
||||||
|
fill: cbmode ? "rgba(0, 140, 249, 0.4)" : "rgba(0, 0, 255, 0.4)",
|
||||||
|
stroke: cbmode ? "rgb(0, 140, 249)" : "blue",
|
||||||
|
},
|
||||||
|
allocated: {
|
||||||
|
label: "Allocated",
|
||||||
|
scale: "y",
|
||||||
|
width: lineWidth,
|
||||||
|
fill: cbmode ? "rgba(0, 110, 0, 0.4)" : "rgba(0, 128, 0, 0.4)",
|
||||||
|
stroke: cbmode ? "rgb(0, 110, 0)" : "green",
|
||||||
|
},
|
||||||
|
reserved: {
|
||||||
|
label: "Reserved",
|
||||||
|
scale: "y",
|
||||||
|
width: lineWidth,
|
||||||
|
fill: cbmode ? "rgba(209, 99, 230, 0.4)" : "rgba(255, 0, 255, 0.4)",
|
||||||
|
stroke: cbmode ? "rgb(209, 99, 230)" : "magenta",
|
||||||
|
},
|
||||||
|
mixed: {
|
||||||
|
label: "Mixed",
|
||||||
|
scale: "y",
|
||||||
|
width: lineWidth,
|
||||||
|
fill: cbmode ? "rgba(235, 172, 35, 0.4)" : "rgba(255, 215, 0, 0.4)",
|
||||||
|
stroke: cbmode ? "rgb(235, 172, 35)" : "gold",
|
||||||
|
},
|
||||||
|
down: {
|
||||||
|
label: "Down",
|
||||||
|
scale: "y",
|
||||||
|
width: lineWidth,
|
||||||
|
fill: cbmode ? "rgba(181, 29 ,20, 0.4)" : "rgba(255, 0, 0, 0.4)",
|
||||||
|
stroke: cbmode ? "rgb(181, 29, 20)" : "red",
|
||||||
|
},
|
||||||
|
unknown: {
|
||||||
|
label: "Unknown",
|
||||||
|
scale: "y",
|
||||||
|
width: lineWidth,
|
||||||
|
fill: "rgba(0, 0, 0, 0.4)",
|
||||||
|
stroke: "black",
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 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 = 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 = [];
|
||||||
let bands = [];
|
let bands = [];
|
||||||
@@ -74,23 +135,46 @@
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function getOpts(title, series) {
|
function getStackedOpts(title, width, height, series, data) {
|
||||||
return {
|
let opts = {
|
||||||
scales: {
|
width,
|
||||||
x: {
|
height,
|
||||||
time: false,
|
title,
|
||||||
|
plugins: [legendAsTooltipPlugin()],
|
||||||
|
series,
|
||||||
|
axes: [
|
||||||
|
{
|
||||||
|
scale: "x",
|
||||||
|
space: 25, // Tick Spacing
|
||||||
|
rotate: 30,
|
||||||
|
show: true,
|
||||||
|
label: xlabel,
|
||||||
|
values(self, splits) {
|
||||||
|
return splits.map(s => formatUnixTime(s));
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
scale: "y",
|
||||||
|
grid: { show: true },
|
||||||
|
labelFont: "sans-serif",
|
||||||
|
label: ylabel + (yunit ? ` (${yunit})` : ''),
|
||||||
|
// values: (u, vals) => vals.map((v) => formatNumber(v)),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
padding: [5, 10, 0, 0],
|
||||||
|
scales: {
|
||||||
|
x: { time: false },
|
||||||
|
y: { auto: true, distr: 1 },
|
||||||
},
|
},
|
||||||
series
|
legend: {
|
||||||
|
show: true,
|
||||||
|
},
|
||||||
|
cursor: {
|
||||||
|
drag: { x: true, y: true },
|
||||||
|
}
|
||||||
};
|
};
|
||||||
}
|
|
||||||
|
|
||||||
function getStackedOpts(title, series, data, interp) {
|
let stacked = stack(data, i => false);
|
||||||
let opts = getOpts(title, series);
|
|
||||||
|
|
||||||
let interped = interp ? interp(data) : data;
|
|
||||||
|
|
||||||
let stacked = stack(interped, i => false);
|
|
||||||
opts.bands = stacked.bands;
|
opts.bands = stacked.bands;
|
||||||
|
|
||||||
opts.cursor = opts.cursor || {};
|
opts.cursor = opts.cursor || {};
|
||||||
@@ -99,7 +183,8 @@
|
|||||||
};
|
};
|
||||||
|
|
||||||
opts.series.forEach(s => {
|
opts.series.forEach(s => {
|
||||||
s.value = (u, v, si, i) => data[si][i];
|
// Format Time Info from Unix TS to LocalTimeString
|
||||||
|
s.value = (u, v, si, i) => (si === 0) ? formatUnixTime(data[si][i]) : data[si][i];
|
||||||
|
|
||||||
s.points = s.points || {};
|
s.points = s.points || {};
|
||||||
|
|
||||||
@@ -138,331 +223,7 @@
|
|||||||
return {opts, data: stacked.data};
|
return {opts, data: stacked.data};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// UPLOT PLUGIN: Converts the legend into a simple tooltip
|
||||||
function stack2(series) {
|
|
||||||
// for uplot data
|
|
||||||
let data = Array(series.length);
|
|
||||||
let bands = [];
|
|
||||||
|
|
||||||
let dataLen = series[0].values.length;
|
|
||||||
|
|
||||||
let zeroArr = Array(dataLen).fill(0);
|
|
||||||
|
|
||||||
let stackGroups = new Map();
|
|
||||||
let seriesStackKeys = Array(series.length);
|
|
||||||
|
|
||||||
series.forEach((s, si) => {
|
|
||||||
let vals = s.values.slice();
|
|
||||||
|
|
||||||
// apply negY
|
|
||||||
if (s.negY) {
|
|
||||||
for (let i = 0; i < vals.length; i++) {
|
|
||||||
if (vals[i] != null)
|
|
||||||
vals[i] *= -1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (s.stacking.mode != 'none') {
|
|
||||||
let hasPos = vals.some(v => v > 0);
|
|
||||||
// derive stacking key
|
|
||||||
let stackKey = seriesStackKeys[si] = s.stacking.mode + s.scaleKey + s.stacking.group + (hasPos ? '+' : '-');
|
|
||||||
let group = stackGroups.get(stackKey);
|
|
||||||
|
|
||||||
// initialize stacking group
|
|
||||||
if (group == null) {
|
|
||||||
group = {
|
|
||||||
series: [],
|
|
||||||
acc: zeroArr.slice(),
|
|
||||||
dir: hasPos ? -1 : 1,
|
|
||||||
};
|
|
||||||
stackGroups.set(stackKey, group);
|
|
||||||
}
|
|
||||||
|
|
||||||
// push for bands gen
|
|
||||||
group.series.unshift(si);
|
|
||||||
|
|
||||||
let stacked = data[si] = Array(dataLen);
|
|
||||||
let { acc } = group;
|
|
||||||
|
|
||||||
for (let i = 0; i < dataLen; i++) {
|
|
||||||
let v = vals[i];
|
|
||||||
|
|
||||||
if (v != null)
|
|
||||||
stacked[i] = (acc[i] += v);
|
|
||||||
else
|
|
||||||
stacked[i] = v; // we may want to coerce to 0 here
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else
|
|
||||||
data[si] = vals;
|
|
||||||
});
|
|
||||||
|
|
||||||
// re-compute by percent
|
|
||||||
series.forEach((s, si) => {
|
|
||||||
if (s.stacking.mode == 'percent') {
|
|
||||||
let group = stackGroups.get(seriesStackKeys[si]);
|
|
||||||
let { acc } = group;
|
|
||||||
|
|
||||||
// re-negatify percent
|
|
||||||
let sign = group.dir * -1;
|
|
||||||
|
|
||||||
let stacked = data[si];
|
|
||||||
|
|
||||||
for (let i = 0; i < dataLen; i++) {
|
|
||||||
let v = stacked[i];
|
|
||||||
|
|
||||||
if (v != null)
|
|
||||||
stacked[i] = sign * (v / acc[i]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// generate bands between adjacent group series
|
|
||||||
stackGroups.forEach(group => {
|
|
||||||
let { series, dir } = group;
|
|
||||||
let lastIdx = series.length - 1;
|
|
||||||
|
|
||||||
series.forEach((si, i) => {
|
|
||||||
if (i != lastIdx) {
|
|
||||||
let nextIdx = series[i + 1];
|
|
||||||
bands.push({
|
|
||||||
// since we're not passing x series[0] for stacking, real idxs are actually +1
|
|
||||||
series: [si + 1, nextIdx + 1],
|
|
||||||
dir,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
data,
|
|
||||||
bands,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// UPLOT SERIES INIT //
|
|
||||||
|
|
||||||
const plotSeries = [
|
|
||||||
{
|
|
||||||
label: "Time",
|
|
||||||
scale: "x",
|
|
||||||
value: (u, ts, sidx, didx) =>
|
|
||||||
(didx == null) ? null : formatUnixTime(ts),
|
|
||||||
}
|
|
||||||
]
|
|
||||||
|
|
||||||
if (stateType === "slurm") {
|
|
||||||
const resSeries = [
|
|
||||||
{
|
|
||||||
label: "Idle",
|
|
||||||
scale: "y",
|
|
||||||
width: lineWidth,
|
|
||||||
stroke: cbmode ? "rgb(136, 204, 238)" : "lightblue",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Allocated",
|
|
||||||
scale: "y",
|
|
||||||
width: lineWidth,
|
|
||||||
stroke: cbmode ? "rgb(30, 136, 229)" : "green",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Reserved",
|
|
||||||
scale: "y",
|
|
||||||
width: lineWidth,
|
|
||||||
stroke: cbmode ? "rgb(211, 95, 183)" : "magenta",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Mixed",
|
|
||||||
scale: "y",
|
|
||||||
width: lineWidth,
|
|
||||||
stroke: cbmode ? "rgb(239, 230, 69)" : "yellow",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Down",
|
|
||||||
scale: "y",
|
|
||||||
width: lineWidth,
|
|
||||||
stroke: cbmode ? "rgb(225, 86, 44)" : "red",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Unknown",
|
|
||||||
scale: "y",
|
|
||||||
width: lineWidth,
|
|
||||||
stroke: "black",
|
|
||||||
}
|
|
||||||
];
|
|
||||||
plotSeries.push(...resSeries)
|
|
||||||
} else if (stateType === "health") {
|
|
||||||
const resSeries = [
|
|
||||||
{
|
|
||||||
label: "Full",
|
|
||||||
scale: "y",
|
|
||||||
width: lineWidth,
|
|
||||||
stroke: cbmode ? "rgb(30, 136, 229)" : "green",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Partial",
|
|
||||||
scale: "y",
|
|
||||||
width: lineWidth,
|
|
||||||
stroke: cbmode ? "rgb(239, 230, 69)" : "yellow",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Failed",
|
|
||||||
scale: "y",
|
|
||||||
width: lineWidth,
|
|
||||||
stroke: cbmode ? "rgb(225, 86, 44)" : "red",
|
|
||||||
}
|
|
||||||
];
|
|
||||||
plotSeries.push(...resSeries)
|
|
||||||
} else {
|
|
||||||
const resSeries = [
|
|
||||||
{
|
|
||||||
label: "Full",
|
|
||||||
scale: "y",
|
|
||||||
width: lineWidth,
|
|
||||||
stroke: cbmode ? "rgb(30, 136, 229)" : "green",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Partial",
|
|
||||||
scale: "y",
|
|
||||||
width: lineWidth,
|
|
||||||
stroke: cbmode ? "rgb(239, 230, 69)" : "yellow",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Failed",
|
|
||||||
scale: "y",
|
|
||||||
width: lineWidth,
|
|
||||||
stroke: cbmode ? "rgb(225, 86, 44)" : "red",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Idle",
|
|
||||||
scale: "y",
|
|
||||||
width: lineWidth,
|
|
||||||
stroke: cbmode ? "rgb(136, 204, 238)" : "lightblue",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Allocated",
|
|
||||||
scale: "y",
|
|
||||||
width: lineWidth,
|
|
||||||
stroke: cbmode ? "rgb(30, 136, 229)" : "green",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Reserved",
|
|
||||||
scale: "y",
|
|
||||||
width: lineWidth,
|
|
||||||
stroke: cbmode ? "rgb(211, 95, 183)" : "magenta",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Mixed",
|
|
||||||
scale: "y",
|
|
||||||
width: lineWidth,
|
|
||||||
stroke: cbmode ? "rgb(239, 230, 69)" : "yellow",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Down",
|
|
||||||
scale: "y",
|
|
||||||
width: lineWidth,
|
|
||||||
stroke: cbmode ? "rgb(225, 86, 44)" : "red",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Unknown",
|
|
||||||
scale: "y",
|
|
||||||
width: lineWidth,
|
|
||||||
stroke: "black",
|
|
||||||
}
|
|
||||||
];
|
|
||||||
plotSeries.push(...resSeries)
|
|
||||||
}
|
|
||||||
|
|
||||||
// UPLOT BAND COLORS //
|
|
||||||
// const plotBands = [
|
|
||||||
// { series: [5, 4], fill: cbmode ? "rgba(0,0,255,0.1)" : "rgba(0,255,0,0.1)" },
|
|
||||||
// { series: [4, 3], fill: cbmode ? "rgba(0,255,0,0.1)" : "rgba(255,0,0,0.1)" },
|
|
||||||
// ];
|
|
||||||
|
|
||||||
// UPLOT OPTIONS //
|
|
||||||
const opts = {
|
|
||||||
width,
|
|
||||||
height,
|
|
||||||
title,
|
|
||||||
plugins: [legendAsTooltipPlugin()],
|
|
||||||
series: plotSeries,
|
|
||||||
axes: [
|
|
||||||
{
|
|
||||||
scale: "x",
|
|
||||||
space: 25, // Tick Spacing
|
|
||||||
rotate: 30,
|
|
||||||
show: true,
|
|
||||||
label: xlabel,
|
|
||||||
// values(self, splits) {
|
|
||||||
// return splits.map(s => xticks[s]);
|
|
||||||
// }
|
|
||||||
},
|
|
||||||
{
|
|
||||||
scale: "y",
|
|
||||||
grid: { show: true },
|
|
||||||
labelFont: "sans-serif",
|
|
||||||
label: ylabel + (yunit ? ` (${yunit})` : ''),
|
|
||||||
// values: (u, vals) => vals.map((v) => formatNumber(v)),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
// bands: forResources ? [] : plotBands,
|
|
||||||
padding: [5, 10, 0, 0],
|
|
||||||
// hooks: {
|
|
||||||
// draw: [
|
|
||||||
// (u) => {
|
|
||||||
// // Draw plot type label:
|
|
||||||
// let textl = forResources ? "Job Resources by Type" : "Metric Min/Avg/Max for Job Duration";
|
|
||||||
// let textr = "Earlier <- StartTime -> Later";
|
|
||||||
// u.ctx.save();
|
|
||||||
// u.ctx.textAlign = "start";
|
|
||||||
// u.ctx.fillStyle = "black";
|
|
||||||
// u.ctx.fillText(textl, u.bbox.left + 10, u.bbox.top + 10);
|
|
||||||
// u.ctx.textAlign = "end";
|
|
||||||
// u.ctx.fillStyle = "black";
|
|
||||||
// u.ctx.fillText(
|
|
||||||
// textr,
|
|
||||||
// u.bbox.left + u.bbox.width - 10,
|
|
||||||
// u.bbox.top + 10,
|
|
||||||
// );
|
|
||||||
// u.ctx.restore();
|
|
||||||
// return;
|
|
||||||
// },
|
|
||||||
// ]
|
|
||||||
// },
|
|
||||||
scales: {
|
|
||||||
x: { time: false },
|
|
||||||
y: {auto: true, distr: 1},
|
|
||||||
},
|
|
||||||
legend: {
|
|
||||||
// Display legend
|
|
||||||
show: true,
|
|
||||||
live: true,
|
|
||||||
},
|
|
||||||
cursor: {
|
|
||||||
drag: { x: true, y: true },
|
|
||||||
// sync: {
|
|
||||||
// key: plotSync.key,
|
|
||||||
// scales: ["x", null],
|
|
||||||
// }
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/* Var Init */
|
|
||||||
let timeoutId = null;
|
|
||||||
let uplot = null;
|
|
||||||
|
|
||||||
/* State Init */
|
|
||||||
let plotWrapper = $state(null);
|
|
||||||
|
|
||||||
/* Effects */
|
|
||||||
$effect(() => {
|
|
||||||
if (plotWrapper) {
|
|
||||||
onSizeChange(width, height);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
/* Functions */
|
|
||||||
// UPLOT PLUGIN // converts the legend into a simple tooltip
|
|
||||||
function legendAsTooltipPlugin({
|
function legendAsTooltipPlugin({
|
||||||
className,
|
className,
|
||||||
style = { backgroundColor: "rgba(255, 249, 196, 0.92)", color: "black" },
|
style = { backgroundColor: "rgba(255, 249, 196, 0.92)", color: "black" },
|
||||||
@@ -476,7 +237,7 @@
|
|||||||
className && legendEl.classList.add(className);
|
className && legendEl.classList.add(className);
|
||||||
|
|
||||||
uPlot.assign(legendEl.style, {
|
uPlot.assign(legendEl.style, {
|
||||||
minWidth: "100px",
|
minWidth: "175px",
|
||||||
textAlign: "left",
|
textAlign: "left",
|
||||||
pointerEvents: "none",
|
pointerEvents: "none",
|
||||||
display: "none",
|
display: "none",
|
||||||
@@ -489,9 +250,9 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
// hide series color markers:
|
// hide series color markers:
|
||||||
const idents = legendEl.querySelectorAll(".u-marker");
|
// const idents = legendEl.querySelectorAll(".u-marker");
|
||||||
for (let i = 0; i < idents.length; i++)
|
// for (let i = 0; i < idents.length; i++)
|
||||||
idents[i].style.display = "none";
|
// idents[i].style.display = "none";
|
||||||
|
|
||||||
const overEl = u.over;
|
const overEl = u.over;
|
||||||
overEl.style.overflow = "visible";
|
overEl.style.overflow = "visible";
|
||||||
@@ -510,9 +271,12 @@
|
|||||||
|
|
||||||
function update(u) {
|
function update(u) {
|
||||||
const { left, top } = u.cursor;
|
const { left, top } = u.cursor;
|
||||||
const width = u?.over?.querySelector(".u-legend")?.offsetWidth ? u.over.querySelector(".u-legend").offsetWidth : 0;
|
const internalWidth = u?.over?.querySelector(".u-legend")?.offsetWidth ? u.over.querySelector(".u-legend").offsetWidth : 0;
|
||||||
legendEl.style.transform =
|
if (left < (width/2)) {
|
||||||
"translate(" + (left - width - 15) + "px, " + (top + 15) + "px)";
|
legendEl.style.transform = "translate(" + (left + 15) + "px, " + (top + 15) + "px)";
|
||||||
|
} else {
|
||||||
|
legendEl.style.transform = "translate(" + (left - internalWidth - 15) + "px, " + (top + 15) + "px)";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -523,13 +287,34 @@
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// RENDER HANDLING
|
// UPLOT SERIES INIT
|
||||||
|
const plotSeries = [
|
||||||
|
{
|
||||||
|
label: "Time",
|
||||||
|
scale: "x"
|
||||||
|
},
|
||||||
|
...collectLabel.map(l => seriesConfig[l])
|
||||||
|
]
|
||||||
|
|
||||||
|
/* Var Init */
|
||||||
|
let timeoutId = null;
|
||||||
|
let uplot = null;
|
||||||
|
|
||||||
|
/* State Init */
|
||||||
|
let plotWrapper = $state(null);
|
||||||
|
|
||||||
|
/* Effects */
|
||||||
|
$effect(() => {
|
||||||
|
if (plotWrapper) {
|
||||||
|
onSizeChange(width, height);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/* Functions */
|
||||||
function render(ren_width, ren_height) {
|
function render(ren_width, ren_height) {
|
||||||
if (!uplot) {
|
if (!uplot) {
|
||||||
opts.width = ren_width;
|
let { opts, data } = getStackedOpts(title, ren_width, ren_height, plotSeries, collectData);
|
||||||
opts.height = ren_height;
|
uplot = new uPlot(opts, data, plotWrapper); // Data is uplot formatted [[X][Y1][Y2]...]
|
||||||
uplot = new uPlot(opts, data, plotWrapper); // Data is uplot formatted [[X][Ymin][Yavg][Ymax]]
|
|
||||||
plotSync.sub(uplot)
|
|
||||||
} else {
|
} else {
|
||||||
uplot.setSize({ width: ren_width, height: ren_height });
|
uplot.setSize({ width: ren_width, height: ren_height });
|
||||||
}
|
}
|
||||||
@@ -559,12 +344,12 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<!-- Define $width Wrapper and NoData Card -->
|
<!-- Define $width Wrapper and NoData Card -->
|
||||||
{#if data && data[0].length > 0}
|
{#if data && collectData[0].length > 0}
|
||||||
<div bind:this={plotWrapper} bind:clientWidth={width}
|
<div bind:this={plotWrapper} bind:clientWidth={width}
|
||||||
style="background-color: rgba(255, 255, 255, 1.0);" class="rounded"
|
style="background-color: rgba(255, 255, 255, 1.0);" class="rounded"
|
||||||
></div>
|
></div>
|
||||||
{:else}
|
{:else}
|
||||||
<Card body color="warning" class="mx-4 my-2"
|
<Card body color="warning" class="mx-4 my-2"
|
||||||
>Cannot render plot: No series data returned for <code>{metric?metric:'job resources'}</code></Card
|
>Cannot render plot: No series data returned for <code>{stateType} State Stacked Chart</code></Card
|
||||||
>
|
>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -98,7 +98,12 @@
|
|||||||
if (!cluster) {
|
if (!cluster) {
|
||||||
return avail.map((av) => av.cluster).join(', ')
|
return avail.map((av) => av.cluster).join(', ')
|
||||||
} else {
|
} else {
|
||||||
return avail.find((av) => av.cluster === cluster).subClusters.join(', ')
|
const subAvail = avail.find((av) => av.cluster === cluster)?.subClusters
|
||||||
|
if (subAvail) {
|
||||||
|
return subAvail.join(', ')
|
||||||
|
} else {
|
||||||
|
return `Not available for ${cluster}`
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -36,7 +36,7 @@
|
|||||||
|
|
||||||
/* Const Init */
|
/* Const Init */
|
||||||
const defaultTo = new Date(Date.now());
|
const defaultTo = new Date(Date.now());
|
||||||
const defaultFrom = new Date(defaultTo.setHours(defaultTo.getHours() - 4));
|
const defaultFrom = new Date(new Date(Date.now()).setHours(defaultTo.getHours() - 4));
|
||||||
|
|
||||||
/* State Init */
|
/* State Init */
|
||||||
let timeType = $state("range");
|
let timeType = $state("range");
|
||||||
|
|||||||
@@ -51,12 +51,13 @@ export function formatDurationTime(t, forNode = false) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formatUnixTime(t) {
|
export function formatUnixTime(t, withDate = false) {
|
||||||
if (t !== null) {
|
if (t !== null) {
|
||||||
if (isNaN(t)) {
|
if (isNaN(t)) {
|
||||||
return t;
|
return t;
|
||||||
} else {
|
} else {
|
||||||
return new Date(t * 1000).toLocaleString()
|
if (withDate) return new Date(t * 1000).toLocaleString();
|
||||||
|
else return new Date(t * 1000).toLocaleTimeString();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -374,7 +374,7 @@ export function findJobFootprintThresholds(job, stat, metricConfig) {
|
|||||||
For shared jobs, scale thresholds by the fraction of the job's HWThreads to the node's HWThreads.
|
For shared jobs, scale thresholds by the fraction of the job's HWThreads to the node's HWThreads.
|
||||||
'stat' is one of: avg, min, max
|
'stat' is one of: avg, min, max
|
||||||
*/
|
*/
|
||||||
if (job.exclusive === 1 || stat === "avg") {
|
if (job.shared === "none" || stat === "avg") {
|
||||||
return defaultThresholds
|
return defaultThresholds
|
||||||
} else {
|
} else {
|
||||||
const topol = getContext("getHardwareTopology")(job.cluster, job.subCluster)
|
const topol = getContext("getHardwareTopology")(job.cluster, job.subCluster)
|
||||||
|
|||||||
@@ -185,6 +185,7 @@
|
|||||||
{isShared}
|
{isShared}
|
||||||
{zoomState}
|
{zoomState}
|
||||||
{thresholdState}
|
{thresholdState}
|
||||||
|
enableFlip
|
||||||
/>
|
/>
|
||||||
{:else if statsSeries[selectedScopeIndex] != null && patternMatches}
|
{:else if statsSeries[selectedScopeIndex] != null && patternMatches}
|
||||||
<MetricPlot
|
<MetricPlot
|
||||||
@@ -202,6 +203,7 @@
|
|||||||
{thresholdState}
|
{thresholdState}
|
||||||
statisticsSeries={statsSeries[selectedScopeIndex]}
|
statisticsSeries={statsSeries[selectedScopeIndex]}
|
||||||
useStatsSeries={!!statsSeries[selectedScopeIndex]}
|
useStatsSeries={!!statsSeries[selectedScopeIndex]}
|
||||||
|
enableFlip
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
{/key}
|
{/key}
|
||||||
|
|||||||
@@ -21,7 +21,6 @@
|
|||||||
getContextClient,
|
getContextClient,
|
||||||
} from "@urql/svelte";
|
} from "@urql/svelte";
|
||||||
import {
|
import {
|
||||||
init,
|
|
||||||
convert2uplot,
|
convert2uplot,
|
||||||
} from "../generic/utils.js";
|
} from "../generic/utils.js";
|
||||||
import PlotGrid from "../generic/PlotGrid.svelte";
|
import PlotGrid from "../generic/PlotGrid.svelte";
|
||||||
@@ -35,7 +34,6 @@
|
|||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
/* Const Init */
|
/* Const Init */
|
||||||
const { query: initq } = init();
|
|
||||||
const ccconfig = getContext("cc-config");
|
const ccconfig = getContext("cc-config");
|
||||||
const client = getContextClient();
|
const client = getContextClient();
|
||||||
|
|
||||||
@@ -101,25 +99,18 @@
|
|||||||
</Row>
|
</Row>
|
||||||
|
|
||||||
<Row cols={1} class="text-center mt-3">
|
<Row cols={1} class="text-center mt-3">
|
||||||
<Col>
|
{#if $metricStatusQuery.fetching}
|
||||||
{#if $initq.fetching || $metricStatusQuery.fetching}
|
<Col>
|
||||||
<Spinner />
|
<Spinner />
|
||||||
{:else if $initq.error}
|
</Col>
|
||||||
<Card body color="danger">{$initq.error.message}</Card>
|
{:else if $metricStatusQuery.error}
|
||||||
{:else}
|
|
||||||
<!-- ... -->
|
|
||||||
{/if}
|
|
||||||
</Col>
|
|
||||||
</Row>
|
|
||||||
{#if $metricStatusQuery.error}
|
|
||||||
<Row cols={1}>
|
|
||||||
<Col>
|
<Col>
|
||||||
<Card body color="danger">{$metricStatusQuery.error.message}</Card>
|
<Card body color="danger">{$metricStatusQuery.error.message}</Card>
|
||||||
</Col>
|
</Col>
|
||||||
</Row>
|
{/if}
|
||||||
{/if}
|
</Row>
|
||||||
|
|
||||||
{#if $initq.data && $metricStatusQuery.data}
|
{#if $metricStatusQuery.data}
|
||||||
<!-- Selectable Stats as Histograms : Average Values of Running Jobs -->
|
<!-- Selectable Stats as Histograms : Average Values of Running Jobs -->
|
||||||
{#if selectedHistograms}
|
{#if selectedHistograms}
|
||||||
<!-- Note: Ignore '#snippet' Error in IDE -->
|
<!-- Note: Ignore '#snippet' Error in IDE -->
|
||||||
@@ -132,6 +123,7 @@
|
|||||||
ylabel="Number of Jobs"
|
ylabel="Number of Jobs"
|
||||||
yunit="Jobs"
|
yunit="Jobs"
|
||||||
usesBins
|
usesBins
|
||||||
|
enableFlip
|
||||||
/>
|
/>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
|
|
||||||
|
|||||||
@@ -22,34 +22,36 @@
|
|||||||
gql,
|
gql,
|
||||||
getContextClient,
|
getContextClient,
|
||||||
} from "@urql/svelte";
|
} from "@urql/svelte";
|
||||||
import {
|
import { formatDurationTime } from "../generic/units.js";
|
||||||
init,
|
|
||||||
} from "../generic/utils.js";
|
|
||||||
import { scaleNumbers, formatDurationTime } from "../generic/units.js";
|
|
||||||
import Refresher from "../generic/helper/Refresher.svelte";
|
import Refresher from "../generic/helper/Refresher.svelte";
|
||||||
|
import TimeSelection from "../generic/select/TimeSelection.svelte";
|
||||||
import Roofline from "../generic/plots/Roofline.svelte";
|
import Roofline from "../generic/plots/Roofline.svelte";
|
||||||
import Pie, { colors } from "../generic/plots/Pie.svelte";
|
import Pie, { colors } from "../generic/plots/Pie.svelte";
|
||||||
|
import Stacked from "../generic/plots/Stacked.svelte";
|
||||||
|
|
||||||
/* Svelte 5 Props */
|
/* Svelte 5 Props */
|
||||||
let {
|
let {
|
||||||
|
clusters,
|
||||||
presetCluster,
|
presetCluster,
|
||||||
useCbColors = false,
|
useCbColors = false,
|
||||||
useAltColors = false,
|
useAltColors = false,
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
/* Const Init */
|
/* Const Init */
|
||||||
const { query: initq } = init();
|
|
||||||
const client = getContextClient();
|
const client = getContextClient();
|
||||||
|
|
||||||
/* State Init */
|
/* State Init */
|
||||||
let cluster = $state(presetCluster);
|
let cluster = $state(presetCluster);
|
||||||
let pieWidth = $state(0);
|
let pieWidth = $state(0);
|
||||||
let stackedWidth = $state(0);
|
let stackedWidth1 = $state(0);
|
||||||
|
let stackedWidth2 = $state(0);
|
||||||
let plotWidths = $state([]);
|
let plotWidths = $state([]);
|
||||||
let from = $state(new Date(Date.now() - 5 * 60 * 1000));
|
let from = $state(new Date(Date.now() - 5 * 60 * 1000));
|
||||||
let to = $state(new Date(Date.now()));
|
let to = $state(new Date(Date.now()));
|
||||||
|
let stackedFrom = $state(Math.floor(Date.now() / 1000) - 14400);
|
||||||
// Bar Gauges
|
// Bar Gauges
|
||||||
let allocatedNodes = $state({});
|
let allocatedNodes = $state({});
|
||||||
|
let allocatedCores = $state({});
|
||||||
let allocatedAccs = $state({});
|
let allocatedAccs = $state({});
|
||||||
let flopRate = $state({});
|
let flopRate = $state({});
|
||||||
let flopRateUnitPrefix = $state({});
|
let flopRateUnitPrefix = $state({});
|
||||||
@@ -63,46 +65,29 @@
|
|||||||
let totalAccs = $state({});
|
let totalAccs = $state({});
|
||||||
|
|
||||||
/* Derived */
|
/* Derived */
|
||||||
// Accumulated NodeStates for Piecharts
|
// States for Stacked charts
|
||||||
const nodesStateCounts = $derived(queryStore({
|
const statesTimed = $derived(queryStore({
|
||||||
client: client,
|
client: client,
|
||||||
query: gql`
|
query: gql`
|
||||||
query ($filter: [NodeFilter!]) {
|
query ($filter: [NodeFilter!], $typeNode: String!, $typeHealth: String!) {
|
||||||
nodeStates(filter: $filter) {
|
nodeStates: nodeStatesTimed(filter: $filter, type: $typeNode) {
|
||||||
state
|
state
|
||||||
count
|
counts
|
||||||
|
times
|
||||||
|
}
|
||||||
|
healthStates: nodeStatesTimed(filter: $filter, type: $typeHealth) {
|
||||||
|
state
|
||||||
|
counts
|
||||||
|
times
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
`,
|
`,
|
||||||
variables: {
|
variables: {
|
||||||
filter: { cluster: { eq: cluster }}
|
filter: { cluster: { eq: cluster }, timeStart: stackedFrom},
|
||||||
},
|
typeNode: "node",
|
||||||
}));
|
typeHealth: "health"
|
||||||
|
|
||||||
const refinedStateData = $derived.by(() => {
|
|
||||||
return $nodesStateCounts?.data?.nodeStates.filter((e) => ['allocated', 'reserved', 'idle', 'mixed','down', 'unknown'].includes(e.state))
|
|
||||||
});
|
|
||||||
|
|
||||||
const refinedHealthData = $derived.by(() => {
|
|
||||||
return $nodesStateCounts?.data?.nodeStates.filter((e) => ['full', 'partial', 'failed'].includes(e.state))
|
|
||||||
});
|
|
||||||
|
|
||||||
// NodeStates for Stacked charts
|
|
||||||
const nodesStateTimes = $derived(queryStore({
|
|
||||||
client: client,
|
|
||||||
query: gql`
|
|
||||||
query ($filter: [NodeFilter!]) {
|
|
||||||
nodeStatesTimed(filter: $filter) {
|
|
||||||
state
|
|
||||||
type
|
|
||||||
count
|
|
||||||
time
|
|
||||||
}
|
|
||||||
}
|
|
||||||
`,
|
|
||||||
variables: {
|
|
||||||
filter: { cluster: { eq: cluster }, timeStart: Date.now() - (24 * 3600 * 1000)} // Add Selector for Timeframe (4h, 12h, 24h)?
|
|
||||||
},
|
},
|
||||||
|
requestPolicy: "network-only"
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Note: nodeMetrics are requested on configured $timestep resolution
|
// Note: nodeMetrics are requested on configured $timestep resolution
|
||||||
@@ -177,9 +162,14 @@
|
|||||||
hostname
|
hostname
|
||||||
cluster
|
cluster
|
||||||
subCluster
|
subCluster
|
||||||
nodeState
|
schedulerState
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
# Get Current States fir Pie Charts
|
||||||
|
nodeStates(filter: $nodeFilter) {
|
||||||
|
state
|
||||||
|
count
|
||||||
|
}
|
||||||
# totalNodes includes multiples if shared jobs
|
# totalNodes includes multiples if shared jobs
|
||||||
jobsStatistics(
|
jobsStatistics(
|
||||||
filter: $jobFilter
|
filter: $jobFilter
|
||||||
@@ -190,6 +180,7 @@
|
|||||||
id
|
id
|
||||||
totalJobs
|
totalJobs
|
||||||
totalUsers
|
totalUsers
|
||||||
|
totalCores
|
||||||
totalAccs
|
totalAccs
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -207,10 +198,22 @@
|
|||||||
requestPolicy: "network-only"
|
requestPolicy: "network-only"
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
const refinedStateData = $derived.by(() => {
|
||||||
|
return $statusQuery?.data?.nodeStates.
|
||||||
|
filter((e) => ['allocated', 'reserved', 'idle', 'mixed','down', 'unknown'].includes(e.state)).
|
||||||
|
sort((a, b) => b.count - a.count)
|
||||||
|
});
|
||||||
|
|
||||||
|
const refinedHealthData = $derived.by(() => {
|
||||||
|
return $statusQuery?.data?.nodeStates.
|
||||||
|
filter((e) => ['full', 'partial', 'failed'].includes(e.state)).
|
||||||
|
sort((a, b) => b.count - a.count)
|
||||||
|
});
|
||||||
|
|
||||||
/* Effects */
|
/* Effects */
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if ($initq.data && $statusQuery.data) {
|
if ($statusQuery.data) {
|
||||||
let subClusters = $initq.data.clusters.find(
|
let subClusters = clusters.find(
|
||||||
(c) => c.name == cluster,
|
(c) => c.name == cluster,
|
||||||
).subClusters;
|
).subClusters;
|
||||||
for (let subCluster of subClusters) {
|
for (let subCluster of subClusters) {
|
||||||
@@ -219,6 +222,10 @@
|
|||||||
$statusQuery.data.allocatedNodes.find(
|
$statusQuery.data.allocatedNodes.find(
|
||||||
({ name }) => name == subCluster.name,
|
({ name }) => name == subCluster.name,
|
||||||
)?.count || 0;
|
)?.count || 0;
|
||||||
|
allocatedCores[subCluster.name] =
|
||||||
|
$statusQuery.data.jobsStatistics.find(
|
||||||
|
({ id }) => id == subCluster.name,
|
||||||
|
)?.totalCores || 0;
|
||||||
allocatedAccs[subCluster.name] =
|
allocatedAccs[subCluster.name] =
|
||||||
$statusQuery.data.jobsStatistics.find(
|
$statusQuery.data.jobsStatistics.find(
|
||||||
({ id }) => id == subCluster.name,
|
({ id }) => id == subCluster.name,
|
||||||
@@ -348,7 +355,7 @@
|
|||||||
for (let j = 0; j < subClusterData.length; j++) {
|
for (let j = 0; j < subClusterData.length; j++) {
|
||||||
const nodeName = subClusterData[j]?.host ? subClusterData[j].host : "unknown"
|
const nodeName = subClusterData[j]?.host ? subClusterData[j].host : "unknown"
|
||||||
const nodeMatch = $statusQuery?.data?.nodes?.items?.find((n) => n.hostname == nodeName && n.subCluster == subClusterData[j].subCluster);
|
const nodeMatch = $statusQuery?.data?.nodes?.items?.find((n) => n.hostname == nodeName && n.subCluster == subClusterData[j].subCluster);
|
||||||
const nodeState = nodeMatch?.nodeState ? nodeMatch.nodeState : "notindb"
|
const schedulerState = nodeMatch?.schedulerState ? nodeMatch.schedulerState : "notindb"
|
||||||
let numJobs = 0
|
let numJobs = 0
|
||||||
|
|
||||||
if ($statusQuery?.data) {
|
if ($statusQuery?.data) {
|
||||||
@@ -356,7 +363,7 @@
|
|||||||
numJobs = nodeJobs?.length ? nodeJobs.length : 0
|
numJobs = nodeJobs?.length ? nodeJobs.length : 0
|
||||||
}
|
}
|
||||||
|
|
||||||
result.push({nodeName: nodeName, nodeState: nodeState, numJobs: numJobs})
|
result.push({nodeName: nodeName, schedulerState: schedulerState, numJobs: numJobs})
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
return result
|
return result
|
||||||
@@ -378,14 +385,24 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<!-- Refresher and space for other options -->
|
<!-- Refresher and space for other options -->
|
||||||
<Row class="justify-content-end">
|
<Row class="justify-content-between">
|
||||||
|
<Col xs="12" md="5" lg="4" xl="3">
|
||||||
|
<TimeSelection
|
||||||
|
customEnabled={false}
|
||||||
|
applyTime={(newFrom, newTo) => {
|
||||||
|
stackedFrom = Math.floor(newFrom.getTime() / 1000);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Col>
|
||||||
<Col xs="12" md="5" lg="4" xl="3">
|
<Col xs="12" md="5" lg="4" xl="3">
|
||||||
<Refresher
|
<Refresher
|
||||||
initially={120}
|
initially={120}
|
||||||
onRefresh={() => {
|
onRefresh={(interval) => {
|
||||||
console.log('Trigger Refresh StatusTab')
|
|
||||||
from = new Date(Date.now() - 5 * 60 * 1000);
|
from = new Date(Date.now() - 5 * 60 * 1000);
|
||||||
to = new Date(Date.now());
|
to = new Date(Date.now());
|
||||||
|
|
||||||
|
if (interval) stackedFrom += Math.floor(interval / 1000);
|
||||||
|
else stackedFrom += 1 // Workaround: TineSelection not linked, just trigger new data on manual refresh
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</Col>
|
</Col>
|
||||||
@@ -394,43 +411,40 @@
|
|||||||
<hr/>
|
<hr/>
|
||||||
|
|
||||||
<!-- Node Stack Charts Dev-->
|
<!-- Node Stack Charts Dev-->
|
||||||
<!--
|
{#if $statesTimed.data}
|
||||||
{#if $initq.data && $nodesStateTimes.data}
|
<Row cols={{ md: 2 , sm: 1}} class="mb-3 justify-content-center">
|
||||||
<Row cols={{ lg: 4, md: 2 , sm: 1}} class="mb-3 justify-content-center">
|
|
||||||
<Col class="px-3 mt-2 mt-lg-0">
|
<Col class="px-3 mt-2 mt-lg-0">
|
||||||
<div bind:clientWidth={stackedWidth}>
|
<div bind:clientWidth={stackedWidth1}>
|
||||||
{#key $nodesStateTimes.data}
|
{#key $statesTimed?.data?.nodeStates}
|
||||||
<h4 class="text-center">
|
<h4 class="text-center">
|
||||||
{cluster.charAt(0).toUpperCase() + cluster.slice(1)} Node States Over Time
|
{cluster.charAt(0).toUpperCase() + cluster.slice(1)} Node States Over Time
|
||||||
</h4>
|
</h4>
|
||||||
<Stacked
|
<Stacked
|
||||||
{cluster}
|
data={$statesTimed?.data?.nodeStates}
|
||||||
data={$nodesStateTimes?.data}
|
width={stackedWidth1 * 0.95}
|
||||||
width={stackedWidth * 0.55}
|
xlabel="Time"
|
||||||
xLabel="Time"
|
ylabel="Nodes"
|
||||||
yLabel="Nodes"
|
|
||||||
yunit = "#Count"
|
yunit = "#Count"
|
||||||
title = "Slurm States"
|
title = "Node States"
|
||||||
stateType = "slurm"
|
stateType = "Node"
|
||||||
/>
|
/>
|
||||||
{/key}
|
{/key}
|
||||||
</div>
|
</div>
|
||||||
</Col>
|
</Col>
|
||||||
<Col class="px-3 mt-2 mt-lg-0">
|
<Col class="px-3 mt-2 mt-lg-0">
|
||||||
<div bind:clientWidth={stackedWidth}>
|
<div bind:clientWidth={stackedWidth2}>
|
||||||
{#key $nodesStateTimes.data}
|
{#key $statesTimed?.data?.healthStates}
|
||||||
<h4 class="text-center">
|
<h4 class="text-center">
|
||||||
{cluster.charAt(0).toUpperCase() + cluster.slice(1)} Health States Over Time
|
{cluster.charAt(0).toUpperCase() + cluster.slice(1)} Health States Over Time
|
||||||
</h4>
|
</h4>
|
||||||
<Stacked
|
<Stacked
|
||||||
{cluster}
|
data={$statesTimed?.data?.healthStates}
|
||||||
data={$nodesStateTimes?.data}
|
width={stackedWidth2 * 0.95}
|
||||||
width={stackedWidth * 0.55}
|
xlabel="Time"
|
||||||
xLabel="Time"
|
ylabel="Nodes"
|
||||||
yLabel="Nodes"
|
|
||||||
yunit = "#Count"
|
yunit = "#Count"
|
||||||
title = "Health States"
|
title = "Health States"
|
||||||
stateType = "health"
|
stateType = "Health"
|
||||||
/>
|
/>
|
||||||
{/key}
|
{/key}
|
||||||
</div>
|
</div>
|
||||||
@@ -439,17 +453,15 @@
|
|||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<hr/>
|
<hr/>
|
||||||
<hr/>
|
|
||||||
-->
|
|
||||||
|
|
||||||
<!-- Node Health Pis, later Charts -->
|
<!-- Node Health Pis, later Charts -->
|
||||||
{#if $initq.data && $nodesStateCounts.data}
|
{#if $statusQuery?.data?.nodeStates}
|
||||||
<Row cols={{ lg: 4, md: 2 , sm: 1}} class="mb-3 justify-content-center">
|
<Row cols={{ lg: 4, md: 2 , sm: 1}} class="mb-3 justify-content-center">
|
||||||
<Col class="px-3 mt-2 mt-lg-0">
|
<Col class="px-3 mt-2 mt-lg-0">
|
||||||
<div bind:clientWidth={pieWidth}>
|
<div bind:clientWidth={pieWidth}>
|
||||||
{#key refinedStateData}
|
{#key refinedStateData}
|
||||||
<h4 class="text-center">
|
<h4 class="text-center">
|
||||||
{cluster.charAt(0).toUpperCase() + cluster.slice(1)} Node States
|
Current {cluster.charAt(0).toUpperCase() + cluster.slice(1)} Node States
|
||||||
</h4>
|
</h4>
|
||||||
<Pie
|
<Pie
|
||||||
{useAltColors}
|
{useAltColors}
|
||||||
@@ -489,7 +501,7 @@
|
|||||||
<div bind:clientWidth={pieWidth}>
|
<div bind:clientWidth={pieWidth}>
|
||||||
{#key refinedHealthData}
|
{#key refinedHealthData}
|
||||||
<h4 class="text-center">
|
<h4 class="text-center">
|
||||||
{cluster.charAt(0).toUpperCase() + cluster.slice(1)} Node Health
|
Current {cluster.charAt(0).toUpperCase() + cluster.slice(1)} Node Health
|
||||||
</h4>
|
</h4>
|
||||||
<Pie
|
<Pie
|
||||||
{useAltColors}
|
{useAltColors}
|
||||||
@@ -529,8 +541,8 @@
|
|||||||
|
|
||||||
<hr/>
|
<hr/>
|
||||||
<!-- Gauges & Roofline per Subcluster-->
|
<!-- Gauges & Roofline per Subcluster-->
|
||||||
{#if $initq.data && $statusQuery.data}
|
{#if $statusQuery.data}
|
||||||
{#each $initq.data.clusters.find((c) => c.name == cluster).subClusters as subCluster, i}
|
{#each clusters.find((c) => c.name == cluster).subClusters as subCluster, i}
|
||||||
<Row cols={{ lg: 3, md: 1 , sm: 1}} class="mb-3 justify-content-center">
|
<Row cols={{ lg: 3, md: 1 , sm: 1}} class="mb-3 justify-content-center">
|
||||||
<Col class="px-3">
|
<Col class="px-3">
|
||||||
<Card class="h-auto mt-1">
|
<Card class="h-auto mt-1">
|
||||||
@@ -579,6 +591,21 @@
|
|||||||
Nodes</td
|
Nodes</td
|
||||||
>
|
>
|
||||||
</tr>
|
</tr>
|
||||||
|
<tr class="py-2">
|
||||||
|
<th scope="col">Allocated Cores</th>
|
||||||
|
<td style="min-width: 100px;"
|
||||||
|
><div class="col">
|
||||||
|
<Progress
|
||||||
|
value={allocatedCores[subCluster.name]}
|
||||||
|
max={subCluster.socketsPerNode * subCluster.coresPerSocket * subCluster.numberOfNodes}
|
||||||
|
/>
|
||||||
|
</div></td
|
||||||
|
>
|
||||||
|
<td
|
||||||
|
>{allocatedCores[subCluster.name]} / {subCluster.socketsPerNode * subCluster.coresPerSocket * subCluster.numberOfNodes}
|
||||||
|
Cores</td
|
||||||
|
>
|
||||||
|
</tr>
|
||||||
{#if totalAccs[subCluster.name] !== null}
|
{#if totalAccs[subCluster.name] !== null}
|
||||||
<tr class="py-2">
|
<tr class="py-2">
|
||||||
<th scope="col">Allocated Accelerators</th>
|
<th scope="col">Allocated Accelerators</th>
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user