Update dependencies. Rebuild graphql and swagger

This commit is contained in:
2026-01-15 08:32:06 +01:00
parent 8f0bb907ff
commit e1efc68476
24 changed files with 3321 additions and 555 deletions

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

4
go.mod
View File

@@ -11,7 +11,7 @@ tool (
require ( require (
github.com/99designs/gqlgen v0.17.85 github.com/99designs/gqlgen v0.17.85
github.com/ClusterCockpit/cc-lib/v2 v2.0.0 github.com/ClusterCockpit/cc-lib/v2 v2.1.0
github.com/Masterminds/squirrel v1.5.4 github.com/Masterminds/squirrel v1.5.4
github.com/aws/aws-sdk-go-v2 v1.41.1 github.com/aws/aws-sdk-go-v2 v1.41.1
github.com/aws/aws-sdk-go-v2/config v1.32.6 github.com/aws/aws-sdk-go-v2/config v1.32.6
@@ -109,7 +109,6 @@ require (
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/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
github.com/xtgo/set v1.0.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
@@ -119,7 +118,6 @@ require (
golang.org/x/sys v0.39.0 // indirect golang.org/x/sys v0.39.0 // indirect
golang.org/x/text v0.32.0 // indirect golang.org/x/text v0.32.0 // indirect
golang.org/x/tools v0.40.0 // indirect golang.org/x/tools v0.40.0 // indirect
google.golang.org/protobuf v1.36.11 // 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
) )

35
go.sum
View File

@@ -2,12 +2,10 @@ 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.85 h1:EkGx3U2FDcxQm8YDLQSpXIAVmpDyZ3IcBMOJi2nH1S0= github.com/99designs/gqlgen v0.17.85 h1:EkGx3U2FDcxQm8YDLQSpXIAVmpDyZ3IcBMOJi2nH1S0=
github.com/99designs/gqlgen v0.17.85/go.mod h1:yvs8s0bkQlRfqg03YXr3eR4OQUowVhODT/tHzCXnbOU= github.com/99designs/gqlgen v0.17.85/go.mod h1:yvs8s0bkQlRfqg03YXr3eR4OQUowVhODT/tHzCXnbOU=
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-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/v2 v2.0.0 h1:OjDADx8mf9SflqeeKUuhy5pamu4YDucae6wUX6vvNNA= github.com/ClusterCockpit/cc-lib/v2 v2.1.0 h1:B6l6h0IjfEuY9DU6aVM3fSsj24lQ1eudXK9QTKmJjqg=
github.com/ClusterCockpit/cc-lib/v2 v2.0.0/go.mod h1:JuxMAuEOaLLNEnnL9U3ejha8kMvsSatLdKPZEgJw6iw= github.com/ClusterCockpit/cc-lib/v2 v2.1.0/go.mod h1:JuxMAuEOaLLNEnnL9U3ejha8kMvsSatLdKPZEgJw6iw=
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=
@@ -74,10 +72,6 @@ github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6r
github.com/bmatcuk/doublestar v1.1.1/go.mod h1:UD6OnuiIn0yFxxA2le/rnRU1G4RaI4UvFv1sNto9p6w= github.com/bmatcuk/doublestar v1.1.1/go.mod h1:UD6OnuiIn0yFxxA2le/rnRU1G4RaI4UvFv1sNto9p6w=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI=
github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M=
github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE=
github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk=
github.com/coreos/go-oidc/v3 v3.17.0 h1:hWBGaQfbi0iVviX4ibC7bk8OKT5qNr4klBaCHVNvehc= github.com/coreos/go-oidc/v3 v3.17.0 h1:hWBGaQfbi0iVviX4ibC7bk8OKT5qNr4klBaCHVNvehc=
github.com/coreos/go-oidc/v3 v3.17.0/go.mod h1:wqPbKFrVnE90vty060SB40FCJ8fTHTxSwyXJqZH+sI8= github.com/coreos/go-oidc/v3 v3.17.0/go.mod h1:wqPbKFrVnE90vty060SB40FCJ8fTHTxSwyXJqZH+sI8=
github.com/cpuguy83/go-md2man/v2 v2.0.7 h1:zbFlGlXEAKlwXpmvle3d8Oe3YnkKIK4xSRTd3sHPnBo= github.com/cpuguy83/go-md2man/v2 v2.0.7 h1:zbFlGlXEAKlwXpmvle3d8Oe3YnkKIK4xSRTd3sHPnBo=
@@ -89,16 +83,6 @@ github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dgryski/trifles v0.0.0-20230903005119-f50d829f2e54 h1:SG7nF6SRlWhcT7cNTs5R6Hk4V2lcmLz2NsG2VnInyNo= github.com/dgryski/trifles v0.0.0-20230903005119-f50d829f2e54 h1:SG7nF6SRlWhcT7cNTs5R6Hk4V2lcmLz2NsG2VnInyNo=
github.com/dgryski/trifles v0.0.0-20230903005119-f50d829f2e54/go.mod h1:if7Fbed8SFyPtHLHbg49SI7NAdJiC5WIA09pe59rfAA= github.com/dgryski/trifles v0.0.0-20230903005119-f50d829f2e54/go.mod h1:if7Fbed8SFyPtHLHbg49SI7NAdJiC5WIA09pe59rfAA=
github.com/dhui/dktest v0.4.6 h1:+DPKyScKSEp3VLtbMDHcUq6V5Lm5zfZZVb0Sk7Ahom4=
github.com/dhui/dktest v0.4.6/go.mod h1:JHTSYDtKkvFNFHJKqCzVzqXecyv+tKt8EzceOmQOgbU=
github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
github.com/docker/docker v28.3.3+incompatible h1:Dypm25kh4rmk49v1eiVbsAtpAsYURjYkaKubwuBdxEI=
github.com/docker/docker v28.3.3+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj1Br63c=
github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc=
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
github.com/expr-lang/expr v1.17.7 h1:Q0xY/e/2aCIp8g9s/LGvMDCC5PxYlvHgDZRQ4y16JX8= github.com/expr-lang/expr v1.17.7 h1:Q0xY/e/2aCIp8g9s/LGvMDCC5PxYlvHgDZRQ4y16JX8=
github.com/expr-lang/expr v1.17.7/go.mod h1:8/vRC7+7HBzESEqt5kKpYXxrxkr31SaO8r40VO/1IT4= github.com/expr-lang/expr v1.17.7/go.mod h1:8/vRC7+7HBzESEqt5kKpYXxrxkr31SaO8r40VO/1IT4=
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
@@ -236,17 +220,8 @@ github.com/mattn/go-sqlite3 v1.10.0/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsO
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.33 h1:A5blZ5ulQo2AtayQ9/limgHEkFreKj1Dv226a1K73s0= github.com/mattn/go-sqlite3 v1.14.33 h1:A5blZ5ulQo2AtayQ9/limgHEkFreKj1Dv226a1K73s0=
github.com/mattn/go-sqlite3 v1.14.33/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= github.com/mattn/go-sqlite3 v1.14.33/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= github.com/minio/highwayhash v1.0.4-0.20251030100505-070ab1a87a76 h1:KGuD/pM2JpL9FAYvBrnBBeENKZNh6eNtjqytV6TYjnk=
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= github.com/minio/highwayhash v1.0.4-0.20251030100505-070ab1a87a76/go.mod h1:GGYsuwP/fPD6Y9hMiXuapVvlIUEhFhMTh0rxU3ik1LQ=
github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0=
github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/nats-io/jwt/v2 v2.8.0 h1:K7uzyz50+yGZDO5o772eRE7atlcSEENpL7P+b74JV1g= github.com/nats-io/jwt/v2 v2.8.0 h1:K7uzyz50+yGZDO5o772eRE7atlcSEENpL7P+b74JV1g=
@@ -318,8 +293,6 @@ github.com/vektah/gqlparser/v2 v2.5.31 h1:YhWGA1mfTjID7qJhd1+Vxhpk5HTgydrGU9IgkW
github.com/vektah/gqlparser/v2 v2.5.31/go.mod h1:c1I28gSOVNzlfc4WuDlqU7voQnsqI6OG2amkBAFmgts= 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/xtgo/set v1.0.0 h1:6BCNBRv3ORNDQ7fyoJXRv+tstJz3m1JVFQErfeZz2pY=
github.com/xtgo/set v1.0.0/go.mod h1:d3NHzGzSa0NmB2NhFyECA+QdRp29oEn2xbT+TpeFoM8=
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=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=

View File

@@ -27,7 +27,7 @@ type GetClustersAPIResponse struct {
// @description Get a list of all cluster configs. Specific cluster can be requested using query parameter. // @description Get a list of all cluster configs. Specific cluster can be requested using query parameter.
// @produce json // @produce json
// @param cluster query string false "Job Cluster" // @param cluster query string false "Job Cluster"
// @success 200 {object} api.GetClustersApiResponse "Array of clusters" // @success 200 {object} api.GetClustersAPIResponse "Array of clusters"
// @failure 400 {object} api.ErrorResponse "Bad Request" // @failure 400 {object} api.ErrorResponse "Bad Request"
// @failure 401 {object} api.ErrorResponse "Unauthorized" // @failure 401 {object} api.ErrorResponse "Unauthorized"
// @failure 403 {object} api.ErrorResponse "Forbidden" // @failure 403 {object} api.ErrorResponse "Forbidden"

File diff suppressed because it is too large Load Diff

View File

@@ -104,7 +104,7 @@ type JobMetricWithName struct {
// @param items-per-page query int false "Items per page (Default: 25)" // @param items-per-page query int false "Items per page (Default: 25)"
// @param page query int false "Page Number (Default: 1)" // @param page query int false "Page Number (Default: 1)"
// @param with-metadata query bool false "Include metadata (e.g. jobScript) in response" // @param with-metadata query bool false "Include metadata (e.g. jobScript) in response"
// @success 200 {object} api.GetJobsApiResponse "Job array and page info" // @success 200 {object} api.GetJobsAPIResponse "Job array and page info"
// @failure 400 {object} api.ErrorResponse "Bad Request" // @failure 400 {object} api.ErrorResponse "Bad Request"
// @failure 401 {object} api.ErrorResponse "Unauthorized" // @failure 401 {object} api.ErrorResponse "Unauthorized"
// @failure 403 {object} api.ErrorResponse "Forbidden" // @failure 403 {object} api.ErrorResponse "Forbidden"
@@ -232,7 +232,7 @@ func (api *RestAPI) getJobs(rw http.ResponseWriter, r *http.Request) {
// @produce json // @produce json
// @param id path int true "Database ID of Job" // @param id path int true "Database ID of Job"
// @param all-metrics query bool false "Include all available metrics" // @param all-metrics query bool false "Include all available metrics"
// @success 200 {object} api.GetJobApiResponse "Job resource" // @success 200 {object} api.GetJobAPIResponse "Job resource"
// @failure 400 {object} api.ErrorResponse "Bad Request" // @failure 400 {object} api.ErrorResponse "Bad Request"
// @failure 401 {object} api.ErrorResponse "Unauthorized" // @failure 401 {object} api.ErrorResponse "Unauthorized"
// @failure 403 {object} api.ErrorResponse "Forbidden" // @failure 403 {object} api.ErrorResponse "Forbidden"
@@ -324,8 +324,8 @@ func (api *RestAPI) getCompleteJobByID(rw http.ResponseWriter, r *http.Request)
// @accept json // @accept json
// @produce json // @produce json
// @param id path int true "Database ID of Job" // @param id path int true "Database ID of Job"
// @param request body api.GetJobApiRequest true "Array of metric names" // @param request body api.GetJobAPIRequest true "Array of metric names"
// @success 200 {object} api.GetJobApiResponse "Job resource" // @success 200 {object} api.GetJobAPIResponse "Job resource"
// @failure 400 {object} api.ErrorResponse "Bad Request" // @failure 400 {object} api.ErrorResponse "Bad Request"
// @failure 401 {object} api.ErrorResponse "Unauthorized" // @failure 401 {object} api.ErrorResponse "Unauthorized"
// @failure 403 {object} api.ErrorResponse "Forbidden" // @failure 403 {object} api.ErrorResponse "Forbidden"
@@ -478,7 +478,7 @@ func (api *RestAPI) editMeta(rw http.ResponseWriter, r *http.Request) {
// @accept json // @accept json
// @produce json // @produce json
// @param id path int true "Job Database ID" // @param id path int true "Job Database ID"
// @param request body api.TagJobApiRequest true "Array of tag-objects to add" // @param request body api.TagJobAPIRequest true "Array of tag-objects to add"
// @success 200 {object} schema.Job "Updated job resource" // @success 200 {object} schema.Job "Updated job resource"
// @failure 400 {object} api.ErrorResponse "Bad Request" // @failure 400 {object} api.ErrorResponse "Bad Request"
// @failure 401 {object} api.ErrorResponse "Unauthorized" // @failure 401 {object} api.ErrorResponse "Unauthorized"
@@ -542,7 +542,7 @@ func (api *RestAPI) tagJob(rw http.ResponseWriter, r *http.Request) {
// @accept json // @accept json
// @produce json // @produce json
// @param id path int true "Job Database ID" // @param id path int true "Job Database ID"
// @param request body api.TagJobApiRequest true "Array of tag-objects to remove" // @param request body api.TagJobAPIRequest true "Array of tag-objects to remove"
// @success 200 {object} schema.Job "Updated job resource" // @success 200 {object} schema.Job "Updated job resource"
// @failure 400 {object} api.ErrorResponse "Bad Request" // @failure 400 {object} api.ErrorResponse "Bad Request"
// @failure 401 {object} api.ErrorResponse "Unauthorized" // @failure 401 {object} api.ErrorResponse "Unauthorized"
@@ -606,7 +606,7 @@ func (api *RestAPI) removeTagJob(rw http.ResponseWriter, r *http.Request) {
// @description Tag wills be removed from respective archive files. // @description Tag wills be removed from respective archive files.
// @accept json // @accept json
// @produce plain // @produce plain
// @param request body api.TagJobApiRequest true "Array of tag-objects to remove" // @param request body api.TagJobAPIRequest true "Array of tag-objects to remove"
// @success 200 {string} string "Success Response" // @success 200 {string} string "Success Response"
// @failure 400 {object} api.ErrorResponse "Bad Request" // @failure 400 {object} api.ErrorResponse "Bad Request"
// @failure 401 {object} api.ErrorResponse "Unauthorized" // @failure 401 {object} api.ErrorResponse "Unauthorized"
@@ -650,7 +650,7 @@ func (api *RestAPI) removeTags(rw http.ResponseWriter, r *http.Request) {
// @accept json // @accept json
// @produce json // @produce json
// @param request body schema.Job true "Job to add" // @param request body schema.Job true "Job to add"
// @success 201 {object} api.DefaultApiResponse "Job added successfully" // @success 201 {object} api.DefaultAPIResponse "Job added successfully"
// @failure 400 {object} api.ErrorResponse "Bad Request" // @failure 400 {object} api.ErrorResponse "Bad Request"
// @failure 401 {object} api.ErrorResponse "Unauthorized" // @failure 401 {object} api.ErrorResponse "Unauthorized"
// @failure 403 {object} api.ErrorResponse "Forbidden" // @failure 403 {object} api.ErrorResponse "Forbidden"
@@ -728,7 +728,7 @@ func (api *RestAPI) startJob(rw http.ResponseWriter, r *http.Request) {
// @description Job to stop is specified by request body. All fields are required in this case. // @description Job to stop is specified by request body. All fields are required in this case.
// @description Returns full job resource information according to 'Job' scheme. // @description Returns full job resource information according to 'Job' scheme.
// @produce json // @produce json
// @param request body api.StopJobApiRequest true "All fields required" // @param request body api.StopJobAPIRequest true "All fields required"
// @success 200 {object} schema.Job "Success message" // @success 200 {object} schema.Job "Success message"
// @failure 400 {object} api.ErrorResponse "Bad Request" // @failure 400 {object} api.ErrorResponse "Bad Request"
// @failure 401 {object} api.ErrorResponse "Unauthorized" // @failure 401 {object} api.ErrorResponse "Unauthorized"
@@ -754,7 +754,6 @@ func (api *RestAPI) stopJobByRequest(rw http.ResponseWriter, r *http.Request) {
return return
} }
// 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 {
// Try cached jobs if not found in main repository // Try cached jobs if not found in main repository
@@ -776,7 +775,7 @@ func (api *RestAPI) stopJobByRequest(rw http.ResponseWriter, r *http.Request) {
// @description Job to remove is specified by database ID. This will not remove the job from the job archive. // @description Job to remove is specified by database ID. This will not remove the job from the job archive.
// @produce json // @produce json
// @param id path int true "Database ID of Job" // @param id path int true "Database ID of Job"
// @success 200 {object} api.DefaultApiResponse "Success message" // @success 200 {object} api.DefaultAPIResponse "Success message"
// @failure 400 {object} api.ErrorResponse "Bad Request" // @failure 400 {object} api.ErrorResponse "Bad Request"
// @failure 401 {object} api.ErrorResponse "Unauthorized" // @failure 401 {object} api.ErrorResponse "Unauthorized"
// @failure 403 {object} api.ErrorResponse "Forbidden" // @failure 403 {object} api.ErrorResponse "Forbidden"
@@ -820,8 +819,8 @@ func (api *RestAPI) deleteJobByID(rw http.ResponseWriter, r *http.Request) {
// @description Job to delete is specified by request body. All fields are required in this case. // @description Job to delete is specified by request body. All fields are required in this case.
// @accept json // @accept json
// @produce json // @produce json
// @param request body api.DeleteJobApiRequest true "All fields required" // @param request body api.DeleteJobAPIRequest true "All fields required"
// @success 200 {object} api.DefaultApiResponse "Success message" // @success 200 {object} api.DefaultAPIResponse "Success message"
// @failure 400 {object} api.ErrorResponse "Bad Request" // @failure 400 {object} api.ErrorResponse "Bad Request"
// @failure 401 {object} api.ErrorResponse "Unauthorized" // @failure 401 {object} api.ErrorResponse "Unauthorized"
// @failure 403 {object} api.ErrorResponse "Forbidden" // @failure 403 {object} api.ErrorResponse "Forbidden"
@@ -873,7 +872,7 @@ func (api *RestAPI) deleteJobByRequest(rw http.ResponseWriter, r *http.Request)
// @description Remove all jobs with start time before timestamp. The jobs will not be removed from the job archive. // @description Remove all jobs with start time before timestamp. The jobs will not be removed from the job archive.
// @produce json // @produce json
// @param ts path int true "Unix epoch timestamp" // @param ts path int true "Unix epoch timestamp"
// @success 200 {object} api.DefaultApiResponse "Success message" // @success 200 {object} api.DefaultAPIResponse "Success message"
// @failure 400 {object} api.ErrorResponse "Bad Request" // @failure 400 {object} api.ErrorResponse "Bad Request"
// @failure 401 {object} api.ErrorResponse "Unauthorized" // @failure 401 {object} api.ErrorResponse "Unauthorized"
// @failure 403 {object} api.ErrorResponse "Forbidden" // @failure 403 {object} api.ErrorResponse "Forbidden"

View File

@@ -47,7 +47,7 @@ func determineState(states []string) schema.SchedulerState {
// @description Required query-parameter defines if all users or only users with additional special roles are returned. // @description Required query-parameter defines if all users or only users with additional special roles are returned.
// @produce json // @produce json
// @param request body UpdateNodeStatesRequest true "Request body containing nodes and their states" // @param request body UpdateNodeStatesRequest true "Request body containing nodes and their states"
// @success 200 {object} api.DefaultApiResponse "Success message" // @success 200 {object} api.DefaultAPIResponse "Success message"
// @failure 400 {object} api.ErrorResponse "Bad Request" // @failure 400 {object} api.ErrorResponse "Bad Request"
// @failure 401 {object} api.ErrorResponse "Unauthorized" // @failure 401 {object} api.ErrorResponse "Unauthorized"
// @failure 403 {object} api.ErrorResponse "Forbidden" // @failure 403 {object} api.ErrorResponse "Forbidden"

View File

@@ -31,7 +31,7 @@ type APIReturnedUser struct {
// @description Required query-parameter defines if all users or only users with additional special roles are returned. // @description Required query-parameter defines if all users or only users with additional special roles are returned.
// @produce json // @produce json
// @param not-just-user query bool true "If returned list should contain all users or only users with additional special roles" // @param not-just-user query bool true "If returned list should contain all users or only users with additional special roles"
// @success 200 {array} api.ApiReturnedUser "List of users returned successfully" // @success 200 {array} api.APIReturnedUser "List of users returned successfully"
// @failure 400 {string} string "Bad Request" // @failure 400 {string} string "Bad Request"
// @failure 401 {string} string "Unauthorized" // @failure 401 {string} string "Unauthorized"
// @failure 403 {string} string "Forbidden" // @failure 403 {string} string "Forbidden"

View File

@@ -10815,7 +10815,7 @@ func (ec *executionContext) _SubCluster_metricConfig(ctx context.Context, field
return obj.MetricConfig, nil return obj.MetricConfig, nil
}, },
nil, nil,
ec.marshalNMetricConfig2ᚕgithubᚗcomᚋClusterCockpitᚋccᚑlibᚋv2ᚋschemaᚐMetricConfigᚄ, ec.marshalNMetricConfig2ᚕgithubᚗcomᚋClusterCockpitᚋccᚑlibᚋv2ᚋschemaᚐMetricConfigᚄ,
true, true,
true, true,
) )
@@ -18466,11 +18466,7 @@ func (ec *executionContext) marshalNJobsStatistics2ᚖgithubᚗcomᚋClusterCock
return ec._JobsStatistics(ctx, sel, v) return ec._JobsStatistics(ctx, sel, v)
} }
func (ec *executionContext) marshalNMetricConfig2githubᚗcomᚋClusterCockpitᚋccᚑlibᚋv2ᚋschemaᚐMetricConfig(ctx context.Context, sel ast.SelectionSet, v schema.MetricConfig) graphql.Marshaler { func (ec *executionContext) marshalNMetricConfig2ᚕᚖgithubᚗcomᚋClusterCockpitᚋccᚑlibᚋv2ᚋschemaᚐMetricConfig(ctx context.Context, sel ast.SelectionSet, v []*schema.MetricConfig) graphql.Marshaler {
return ec._MetricConfig(ctx, sel, &v)
}
func (ec *executionContext) marshalNMetricConfig2ᚕgithubᚗcomᚋClusterCockpitᚋccᚑlibᚋv2ᚋschemaᚐMetricConfigᚄ(ctx context.Context, sel ast.SelectionSet, v []schema.MetricConfig) graphql.Marshaler {
ret := make(graphql.Array, len(v)) ret := make(graphql.Array, len(v))
var wg sync.WaitGroup var wg sync.WaitGroup
isLen1 := len(v) == 1 isLen1 := len(v) == 1
@@ -18494,7 +18490,7 @@ func (ec *executionContext) marshalNMetricConfig2ᚕgithubᚗcomᚋClusterCockpi
if !isLen1 { if !isLen1 {
defer wg.Done() defer wg.Done()
} }
ret[i] = ec.marshalNMetricConfig2githubᚗcomᚋClusterCockpitᚋccᚑlibᚋv2ᚋschemaᚐMetricConfig(ctx, sel, v[i]) ret[i] = ec.marshalNMetricConfig2githubᚗcomᚋClusterCockpitᚋccᚑlibᚋv2ᚋschemaᚐMetricConfig(ctx, sel, v[i])
} }
if isLen1 { if isLen1 {
f(i) f(i)
@@ -18514,6 +18510,16 @@ func (ec *executionContext) marshalNMetricConfig2ᚕgithubᚗcomᚋClusterCockpi
return ret return ret
} }
func (ec *executionContext) marshalNMetricConfig2ᚖgithubᚗcomᚋClusterCockpitᚋccᚑlibᚋv2ᚋschemaᚐMetricConfig(ctx context.Context, sel ast.SelectionSet, v *schema.MetricConfig) graphql.Marshaler {
if v == nil {
if !graphql.HasFieldError(ctx, graphql.GetFieldContext(ctx)) {
graphql.AddErrorf(ctx, "the requested element is null which the schema does not allow")
}
return graphql.Null
}
return ec._MetricConfig(ctx, sel, v)
}
func (ec *executionContext) marshalNMetricFootprints2ᚕᚖgithubᚗcomᚋClusterCockpitᚋccᚑbackendᚋinternalᚋgraphᚋmodelᚐMetricFootprintsᚄ(ctx context.Context, sel ast.SelectionSet, v []*model.MetricFootprints) graphql.Marshaler { func (ec *executionContext) marshalNMetricFootprints2ᚕᚖgithubᚗcomᚋClusterCockpitᚋccᚑbackendᚋinternalᚋgraphᚋmodelᚐMetricFootprintsᚄ(ctx context.Context, sel ast.SelectionSet, v []*model.MetricFootprints) graphql.Marshaler {
ret := make(graphql.Array, len(v)) ret := make(graphql.Array, len(v))
var wg sync.WaitGroup var wg sync.WaitGroup

View File

@@ -3,7 +3,7 @@ package graph
// This file will be automatically regenerated based on the schema, any resolver // This file will be automatically regenerated based on the schema, any resolver
// implementations // implementations
// will be copied through when generating and any unknown code will be moved to the end. // will be copied through when generating and any unknown code will be moved to the end.
// Code generated by github.com/99designs/gqlgen version v0.17.84 // Code generated by github.com/99designs/gqlgen version v0.17.85
import ( import (
"context" "context"
@@ -283,7 +283,7 @@ func (r *mutationResolver) RemoveTagFromList(ctx context.Context, tagIds []strin
// Test Access: Admins && Admin Tag OR Everyone && Private Tag // Test Access: Admins && Admin Tag OR Everyone && Private Tag
if user.HasRole(schema.RoleAdmin) && (tscope == "global" || tscope == "admin") || user.Username == tscope { if user.HasRole(schema.RoleAdmin) && (tscope == "global" || tscope == "admin") || user.Username == tscope {
// Remove from DB // Remove from DB
if err = r.Repo.RemoveTagById(tid); err != nil { if err = r.Repo.RemoveTagByID(tid); err != nil {
cclog.Warn("Error while removing tag") cclog.Warn("Error while removing tag")
return nil, err return nil, err
} else { } else {

View File

@@ -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 importer package importer
import ( import (

View File

@@ -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 importer package importer
import ( import (

View File

@@ -74,7 +74,7 @@ func Init(rawConfig json.RawMessage, wg *sync.WaitGroup) {
cclog.Debugf("[METRICSTORE]> Using %d workers for checkpoint/archive operations\n", Keys.NumWorkers) cclog.Debugf("[METRICSTORE]> Using %d workers for checkpoint/archive operations\n", Keys.NumWorkers)
// Helper function to add metric configuration // Helper function to add metric configuration
addMetricConfig := func(mc schema.MetricConfig) { addMetricConfig := func(mc *schema.MetricConfig) {
agg, err := AssignAggregationStrategy(mc.Aggregation) agg, err := AssignAggregationStrategy(mc.Aggregation)
if err != nil { if err != nil {
cclog.Warnf("Could not find aggregation strategy for metric config '%s': %s", mc.Name, err.Error()) cclog.Warnf("Could not find aggregation strategy for metric config '%s': %s", mc.Name, err.Error())
@@ -88,7 +88,7 @@ func Init(rawConfig json.RawMessage, wg *sync.WaitGroup) {
for _, c := range archive.Clusters { for _, c := range archive.Clusters {
for _, mc := range c.MetricConfig { for _, mc := range c.MetricConfig {
addMetricConfig(*mc) addMetricConfig(mc)
} }
for _, sc := range c.SubClusters { for _, sc := range c.SubClusters {

View File

@@ -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 (

View File

@@ -686,7 +686,6 @@ func (r *JobRepository) AllocatedNodes(cluster string) (map[string]map[string]in
return subclusters, nil return subclusters, nil
} }
// FIXME: Set duration to requested walltime?
// StopJobsExceedingWalltimeBy marks running jobs as failed if they exceed their walltime limit. // StopJobsExceedingWalltimeBy marks running jobs as failed if they exceed their walltime limit.
// This is typically called periodically to clean up stuck or orphaned jobs. // This is typically called periodically to clean up stuck or orphaned jobs.
// //
@@ -762,7 +761,6 @@ func (r *JobRepository) FindJobIdsByTag(tagID int64) ([]int64, error) {
return jobIds, nil return jobIds, nil
} }
// FIXME: Reconsider filtering short jobs with harcoded threshold
// FindRunningJobs returns all currently running jobs for a specific cluster. // FindRunningJobs returns all currently running jobs for a specific cluster.
// Filters out short-running jobs based on repoConfig.MinRunningJobDuration threshold. // Filters out short-running jobs based on repoConfig.MinRunningJobDuration threshold.
// //

View File

@@ -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 (

View File

@@ -90,13 +90,13 @@ func TestFindJobsBetween(t *testing.T) {
// 2. Create a tag // 2. Create a tag
tagName := fmt.Sprintf("testtag_%d", time.Now().UnixNano()) tagName := fmt.Sprintf("testtag_%d", time.Now().UnixNano())
tagId, err := r.CreateTag("testtype", tagName, "global") tagID, err := r.CreateTag("testtype", tagName, "global")
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
// 3. Link Tag (Manually to avoid archive dependency side-effects in unit test) // 3. Link Tag (Manually to avoid archive dependency side-effects in unit test)
_, err = r.DB.Exec("INSERT INTO jobtag (job_id, tag_id) VALUES (?, ?)", *targetJob.ID, tagId) _, err = r.DB.Exec("INSERT INTO jobtag (job_id, tag_id) VALUES (?, ?)", *targetJob.ID, tagID)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }

View File

@@ -579,7 +579,7 @@ func (r *NodeRepository) GetNodesForList(
queryFilters = append(queryFilters, &model.NodeFilter{Hostname: &model.StringInput{Contains: &nodeFilter}}) queryFilters = append(queryFilters, &model.NodeFilter{Hostname: &model.StringInput{Contains: &nodeFilter}})
} }
if stateFilter != "all" && stateFilter != "notindb" { if stateFilter != "all" && stateFilter != "notindb" {
var queryState schema.SchedulerState = schema.SchedulerState(stateFilter) queryState := schema.SchedulerState(stateFilter)
queryFilters = append(queryFilters, &model.NodeFilter{SchedulerState: &queryState}) queryFilters = append(queryFilters, &model.NodeFilter{SchedulerState: &queryState})
} }
// if healthFilter != "all" { // if healthFilter != "all" {

View File

@@ -46,7 +46,7 @@ func BenchmarkSelect1(b *testing.B) {
} }
func BenchmarkDB_FindJobById(b *testing.B) { func BenchmarkDB_FindJobById(b *testing.B) {
var jobId int64 = 1677322 var jobID int64 = 1677322
b.Run("FindJobById", func(b *testing.B) { b.Run("FindJobById", func(b *testing.B) {
db := setup(b) db := setup(b)
@@ -55,7 +55,7 @@ func BenchmarkDB_FindJobById(b *testing.B) {
b.RunParallel(func(pb *testing.PB) { b.RunParallel(func(pb *testing.PB) {
for pb.Next() { for pb.Next() {
_, err := db.FindByID(getContext(b), jobId) _, err := db.FindByID(getContext(b), jobID)
noErr(b, err) noErr(b, err)
} }
}) })
@@ -63,7 +63,7 @@ func BenchmarkDB_FindJobById(b *testing.B) {
} }
func BenchmarkDB_FindJob(b *testing.B) { func BenchmarkDB_FindJob(b *testing.B) {
var jobId int64 = 107266 var jobID int64 = 107266
var startTime int64 = 1657557241 var startTime int64 = 1657557241
cluster := "fritz" cluster := "fritz"
@@ -74,7 +74,7 @@ func BenchmarkDB_FindJob(b *testing.B) {
b.RunParallel(func(pb *testing.PB) { b.RunParallel(func(pb *testing.PB) {
for pb.Next() { for pb.Next() {
_, err := db.Find(&jobId, &cluster, &startTime) _, err := db.Find(&jobID, &cluster, &startTime)
noErr(b, err) noErr(b, err)
} }
}) })

View File

@@ -2,6 +2,44 @@
// 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.
// This file contains job statistics and histogram generation functionality for the JobRepository.
//
// # Job Statistics
//
// The statistics methods provide aggregated metrics about jobs including total jobs, users,
// walltime, and resource usage (nodes, cores, accelerators). Statistics can be computed:
// - Overall (JobsStats): Single aggregate across all matching jobs
// - Grouped (JobsStatsGrouped): Aggregated by user, project, cluster, or subcluster
// - Counts (JobCountGrouped, AddJobCount): Simple job counts with optional filtering
//
// All statistics methods support filtering via JobFilter and respect security contexts.
//
// # Histograms
//
// Histogram methods generate distribution data for visualization:
// - Duration, nodes, cores, accelerators (AddHistograms)
// - Job metrics like CPU load, memory usage (AddMetricHistograms)
//
// Histograms use intelligent binning:
// - Duration: Variable bin sizes (1m, 10m, 1h, 6h, 12h, 24h) with zero-padding
// - Resources: Natural value-based bins
// - Metrics: Normalized to peak values with configurable bin counts
//
// # Running vs. Completed Jobs
//
// Statistics handle running jobs specially:
// - Duration calculated as (now - start_time) for running jobs
// - Metric histograms for running jobs load data from metric backend instead of footprint
// - Job state filtering distinguishes running/completed jobs
//
// # Performance Considerations
//
// - All queries use prepared statements via stmtCache
// - Complex aggregations use SQL for efficiency
// - Histogram pre-initialization ensures consistent bin ranges
// - Metric histogram queries limited to 500 jobs for running job analysis
package repository package repository
import ( import (
@@ -19,7 +57,9 @@ import (
sq "github.com/Masterminds/squirrel" sq "github.com/Masterminds/squirrel"
) )
// GraphQL validation should make sure that no unkown values can be specified. // groupBy2column maps GraphQL Aggregate enum values to their corresponding database column names.
// Used by JobsStatsGrouped and JobCountGrouped to translate user-facing grouping dimensions
// into SQL GROUP BY clauses. GraphQL validation ensures only valid enum values are accepted.
var groupBy2column = map[model.Aggregate]string{ var groupBy2column = map[model.Aggregate]string{
model.AggregateUser: "job.hpc_user", model.AggregateUser: "job.hpc_user",
model.AggregateProject: "job.project", model.AggregateProject: "job.project",
@@ -27,6 +67,9 @@ var groupBy2column = map[model.Aggregate]string{
model.AggregateSubcluster: "job.subcluster", model.AggregateSubcluster: "job.subcluster",
} }
// sortBy2column maps GraphQL SortByAggregate enum values to their corresponding computed column names.
// Used by JobsStatsGrouped to translate sort preferences into SQL ORDER BY clauses.
// Column names match the AS aliases used in buildStatsQuery.
var sortBy2column = map[model.SortByAggregate]string{ var sortBy2column = map[model.SortByAggregate]string{
model.SortByAggregateTotaljobs: "totalJobs", model.SortByAggregateTotaljobs: "totalJobs",
model.SortByAggregateTotalusers: "totalUsers", model.SortByAggregateTotalusers: "totalUsers",
@@ -39,6 +82,21 @@ var sortBy2column = map[model.SortByAggregate]string{
model.SortByAggregateTotalacchours: "totalAccHours", model.SortByAggregateTotalacchours: "totalAccHours",
} }
// buildCountQuery constructs a SQL query to count jobs with optional grouping and filtering.
//
// Parameters:
// - filter: Job filters to apply (cluster, user, time range, etc.)
// - kind: Special filter - "running" for running jobs only, "short" for jobs under threshold
// - col: Column name to GROUP BY; empty string for total count without grouping
//
// Returns a SelectBuilder that produces either:
// - Single count: COUNT(job.id) when col is empty
// - Grouped counts: col, COUNT(job.id) when col is specified
//
// The kind parameter enables counting specific job categories:
// - "running": Only jobs with job_state = 'running'
// - "short": Only jobs with duration < ShortRunningJobsDuration config value
// - empty: All jobs matching filters
func (r *JobRepository) buildCountQuery( func (r *JobRepository) buildCountQuery(
filter []*model.JobFilter, filter []*model.JobFilter,
kind string, kind string,
@@ -47,10 +105,8 @@ func (r *JobRepository) buildCountQuery(
var query sq.SelectBuilder var query sq.SelectBuilder
if col != "" { if col != "" {
// Scan columns: id, cnt
query = sq.Select(col, "COUNT(job.id)").From("job").GroupBy(col) query = sq.Select(col, "COUNT(job.id)").From("job").GroupBy(col)
} else { } else {
// Scan columns: cnt
query = sq.Select("COUNT(job.id)").From("job") query = sq.Select("COUNT(job.id)").From("job")
} }
@@ -68,6 +124,27 @@ func (r *JobRepository) buildCountQuery(
return query return query
} }
// buildStatsQuery constructs a SQL query to compute comprehensive job statistics with optional grouping.
//
// Parameters:
// - filter: Job filters to apply (cluster, user, time range, etc.)
// - col: Column name to GROUP BY; empty string for overall statistics without grouping
//
// Returns a SelectBuilder that produces comprehensive statistics:
// - totalJobs: Count of jobs
// - totalUsers: Count of distinct users (always 0 when grouping by user)
// - totalWalltime: Sum of job durations in hours
// - totalNodes: Sum of nodes used across all jobs
// - totalNodeHours: Sum of (duration × num_nodes) in hours
// - totalCores: Sum of hardware threads used across all jobs
// - totalCoreHours: Sum of (duration × num_hwthreads) in hours
// - totalAccs: Sum of accelerators used across all jobs
// - totalAccHours: Sum of (duration × num_acc) in hours
//
// Special handling:
// - Running jobs: Duration calculated as (now - start_time) instead of stored duration
// - Grouped queries: Also select grouping column and user's display name from hpc_user table
// - All time values converted from seconds to hours (÷ 3600) and rounded
func (r *JobRepository) buildStatsQuery( func (r *JobRepository) buildStatsQuery(
filter []*model.JobFilter, filter []*model.JobFilter,
col string, col string,
@@ -75,31 +152,29 @@ func (r *JobRepository) buildStatsQuery(
var query sq.SelectBuilder var query sq.SelectBuilder
if col != "" { if col != "" {
// Scan columns: id, name, totalJobs, totalUsers, totalWalltime, totalNodes, totalNodeHours, totalCores, totalCoreHours, totalAccs, totalAccHours
query = sq.Select( query = sq.Select(
col, col,
"name", "name",
"COUNT(job.id) as totalJobs", "COUNT(job.id) as totalJobs",
"COUNT(DISTINCT job.hpc_user) AS totalUsers", "COUNT(DISTINCT job.hpc_user) AS totalUsers",
fmt.Sprintf(`CAST(ROUND(SUM((CASE WHEN job.job_state = "running" THEN %d - job.start_time ELSE job.duration END)) / 3600) as int) as totalWalltime`, time.Now().Unix()), fmt.Sprintf(`CAST(ROUND(SUM((CASE WHEN job.job_state = "running" THEN %d - job.start_time ELSE job.duration END)) / 3600) as int) as totalWalltime`, time.Now().Unix()),
fmt.Sprintf(`CAST(SUM(job.num_nodes) as int) as totalNodes`), `CAST(SUM(job.num_nodes) as int) as totalNodes`,
fmt.Sprintf(`CAST(ROUND(SUM((CASE WHEN job.job_state = "running" THEN %d - job.start_time ELSE job.duration END) * job.num_nodes) / 3600) as int) as totalNodeHours`, time.Now().Unix()), fmt.Sprintf(`CAST(ROUND(SUM((CASE WHEN job.job_state = "running" THEN %d - job.start_time ELSE job.duration END) * job.num_nodes) / 3600) as int) as totalNodeHours`, time.Now().Unix()),
fmt.Sprintf(`CAST(SUM(job.num_hwthreads) as int) as totalCores`), `CAST(SUM(job.num_hwthreads) as int) as totalCores`,
fmt.Sprintf(`CAST(ROUND(SUM((CASE WHEN job.job_state = "running" THEN %d - job.start_time ELSE job.duration END) * job.num_hwthreads) / 3600) as int) as totalCoreHours`, time.Now().Unix()), fmt.Sprintf(`CAST(ROUND(SUM((CASE WHEN job.job_state = "running" THEN %d - job.start_time ELSE job.duration END) * job.num_hwthreads) / 3600) as int) as totalCoreHours`, time.Now().Unix()),
fmt.Sprintf(`CAST(SUM(job.num_acc) as int) as totalAccs`), `CAST(SUM(job.num_acc) as int) as totalAccs`,
fmt.Sprintf(`CAST(ROUND(SUM((CASE WHEN job.job_state = "running" THEN %d - job.start_time ELSE job.duration END) * job.num_acc) / 3600) as int) as totalAccHours`, time.Now().Unix()), fmt.Sprintf(`CAST(ROUND(SUM((CASE WHEN job.job_state = "running" THEN %d - job.start_time ELSE job.duration END) * job.num_acc) / 3600) as int) as totalAccHours`, time.Now().Unix()),
).From("job").LeftJoin("hpc_user ON hpc_user.username = job.hpc_user").GroupBy(col) ).From("job").LeftJoin("hpc_user ON hpc_user.username = job.hpc_user").GroupBy(col)
} else { } else {
// Scan columns: totalJobs, totalUsers, totalWalltime, totalNodes, totalNodeHours, totalCores, totalCoreHours, totalAccs, totalAccHours
query = sq.Select( query = sq.Select(
"COUNT(job.id) as totalJobs", "COUNT(job.id) as totalJobs",
"COUNT(DISTINCT job.hpc_user) AS totalUsers", "COUNT(DISTINCT job.hpc_user) AS totalUsers",
fmt.Sprintf(`CAST(ROUND(SUM((CASE WHEN job.job_state = "running" THEN %d - job.start_time ELSE job.duration END)) / 3600) as int)`, time.Now().Unix()), fmt.Sprintf(`CAST(ROUND(SUM((CASE WHEN job.job_state = "running" THEN %d - job.start_time ELSE job.duration END)) / 3600) as int)`, time.Now().Unix()),
fmt.Sprintf(`CAST(SUM(job.num_nodes) as int)`), `CAST(SUM(job.num_nodes) as int)`,
fmt.Sprintf(`CAST(ROUND(SUM((CASE WHEN job.job_state = "running" THEN %d - job.start_time ELSE job.duration END) * job.num_nodes) / 3600) as int)`, time.Now().Unix()), fmt.Sprintf(`CAST(ROUND(SUM((CASE WHEN job.job_state = "running" THEN %d - job.start_time ELSE job.duration END) * job.num_nodes) / 3600) as int)`, time.Now().Unix()),
fmt.Sprintf(`CAST(SUM(job.num_hwthreads) as int)`), `CAST(SUM(job.num_hwthreads) as int)`,
fmt.Sprintf(`CAST(ROUND(SUM((CASE WHEN job.job_state = "running" THEN %d - job.start_time ELSE job.duration END) * job.num_hwthreads) / 3600) as int)`, time.Now().Unix()), fmt.Sprintf(`CAST(ROUND(SUM((CASE WHEN job.job_state = "running" THEN %d - job.start_time ELSE job.duration END) * job.num_hwthreads) / 3600) as int)`, time.Now().Unix()),
fmt.Sprintf(`CAST(SUM(job.num_acc) as int)`), `CAST(SUM(job.num_acc) as int)`,
fmt.Sprintf(`CAST(ROUND(SUM((CASE WHEN job.job_state = "running" THEN %d - job.start_time ELSE job.duration END) * job.num_acc) / 3600) as int)`, time.Now().Unix()), fmt.Sprintf(`CAST(ROUND(SUM((CASE WHEN job.job_state = "running" THEN %d - job.start_time ELSE job.duration END) * job.num_acc) / 3600) as int)`, time.Now().Unix()),
).From("job") ).From("job")
} }
@@ -111,6 +186,25 @@ func (r *JobRepository) buildStatsQuery(
return query return query
} }
// JobsStatsGrouped computes comprehensive job statistics grouped by a dimension (user, project, cluster, or subcluster).
//
// This is the primary method for generating aggregated statistics views in the UI, providing
// metrics like total jobs, walltime, and resource usage broken down by the specified grouping.
//
// Parameters:
// - ctx: Context for security checks and cancellation
// - filter: Filters to apply (time range, cluster, job state, etc.)
// - page: Optional pagination (ItemsPerPage: -1 disables pagination)
// - sortBy: Optional sort column (totalJobs, totalWalltime, totalCoreHours, etc.)
// - groupBy: Required grouping dimension (User, Project, Cluster, or Subcluster)
//
// Returns a slice of JobsStatistics, one per group, with:
// - ID: The group identifier (username, project name, cluster name, etc.)
// - Name: Display name (for users, from hpc_user.name; empty for other groups)
// - Statistics: totalJobs, totalUsers, totalWalltime, resource usage metrics
//
// Security: Respects user roles via SecurityCheck - users see only their own data unless admin/support.
// Performance: Results are sorted in SQL and pagination applied before scanning rows.
func (r *JobRepository) JobsStatsGrouped( func (r *JobRepository) JobsStatsGrouped(
ctx context.Context, ctx context.Context,
filter []*model.JobFilter, filter []*model.JobFilter,
@@ -230,6 +324,21 @@ func (r *JobRepository) JobsStatsGrouped(
return stats, nil return stats, nil
} }
// JobsStats computes overall job statistics across all matching jobs without grouping.
//
// This method provides a single aggregate view of job metrics, useful for dashboard
// summaries and overall system utilization reports.
//
// Parameters:
// - ctx: Context for security checks and cancellation
// - filter: Filters to apply (time range, cluster, job state, etc.)
//
// Returns a single-element slice containing aggregate statistics:
// - totalJobs, totalUsers, totalWalltime
// - totalNodeHours, totalCoreHours, totalAccHours
//
// Unlike JobsStatsGrouped, this returns overall totals without breaking down by dimension.
// Security checks are applied via SecurityCheck to respect user access levels.
func (r *JobRepository) JobsStats( func (r *JobRepository) JobsStats(
ctx context.Context, ctx context.Context,
filter []*model.JobFilter, filter []*model.JobFilter,
@@ -303,6 +412,17 @@ func LoadJobStat(job *schema.Job, metric string, statType string) float64 {
return 0.0 return 0.0
} }
// JobCountGrouped counts jobs grouped by a dimension without computing detailed statistics.
//
// This is a lightweight alternative to JobsStatsGrouped when only job counts are needed,
// avoiding the overhead of calculating walltime and resource usage metrics.
//
// Parameters:
// - ctx: Context for security checks
// - filter: Filters to apply
// - groupBy: Grouping dimension (User, Project, Cluster, or Subcluster)
//
// Returns JobsStatistics with only ID and TotalJobs populated for each group.
func (r *JobRepository) JobCountGrouped( func (r *JobRepository) JobCountGrouped(
ctx context.Context, ctx context.Context,
filter []*model.JobFilter, filter []*model.JobFilter,
@@ -343,6 +463,20 @@ func (r *JobRepository) JobCountGrouped(
return stats, nil return stats, nil
} }
// AddJobCountGrouped augments existing statistics with additional job counts by category.
//
// This method enriches JobsStatistics returned by JobsStatsGrouped or JobCountGrouped
// with counts of running or short-running jobs, matched by group ID.
//
// Parameters:
// - ctx: Context for security checks
// - filter: Filters to apply
// - groupBy: Grouping dimension (must match the dimension used for stats parameter)
// - stats: Existing statistics to augment (modified in-place by ID matching)
// - kind: "running" to add RunningJobs count, "short" to add ShortJobs count
//
// Returns the same stats slice with RunningJobs or ShortJobs fields populated per group.
// Groups without matching jobs will have 0 for the added field.
func (r *JobRepository) AddJobCountGrouped( func (r *JobRepository) AddJobCountGrouped(
ctx context.Context, ctx context.Context,
filter []*model.JobFilter, filter []*model.JobFilter,
@@ -392,6 +526,18 @@ func (r *JobRepository) AddJobCountGrouped(
return stats, nil return stats, nil
} }
// AddJobCount augments existing overall statistics with additional job counts by category.
//
// Similar to AddJobCountGrouped but for ungrouped statistics. Applies the same count
// to all statistics entries (typically just one).
//
// Parameters:
// - ctx: Context for security checks
// - filter: Filters to apply
// - stats: Existing statistics to augment (modified in-place)
// - kind: "running" to add RunningJobs count, "short" to add ShortJobs count
//
// Returns the same stats slice with RunningJobs or ShortJobs fields set to the total count.
func (r *JobRepository) AddJobCount( func (r *JobRepository) AddJobCount(
ctx context.Context, ctx context.Context,
filter []*model.JobFilter, filter []*model.JobFilter,
@@ -437,6 +583,26 @@ func (r *JobRepository) AddJobCount(
return stats, nil return stats, nil
} }
// AddHistograms augments statistics with distribution histograms for job properties.
//
// Generates histogram data for visualization of job duration, node count, core count,
// and accelerator count distributions. Duration histogram uses intelligent binning based
// on the requested resolution.
//
// Parameters:
// - ctx: Context for security checks
// - filter: Filters to apply to jobs included in histograms
// - stat: Statistics struct to augment (modified in-place)
// - durationBins: Bin size - "1m", "10m", "1h", "6h", "12h", or "24h" (default)
//
// Populates these fields in stat:
// - HistDuration: Job duration distribution (zero-padded bins)
// - HistNumNodes: Node count distribution
// - HistNumCores: Core (hwthread) count distribution
// - HistNumAccs: Accelerator count distribution
//
// Duration bins are pre-initialized with zeros to ensure consistent ranges for visualization.
// Bin size determines both the width and maximum duration displayed (e.g., "1h" = 48 bins × 1h = 48h max).
func (r *JobRepository) AddHistograms( func (r *JobRepository) AddHistograms(
ctx context.Context, ctx context.Context,
filter []*model.JobFilter, filter []*model.JobFilter,
@@ -447,20 +613,20 @@ func (r *JobRepository) AddHistograms(
var targetBinCount int var targetBinCount int
var targetBinSize int var targetBinSize int
switch { switch *durationBins {
case *durationBins == "1m": // 1 Minute Bins + Max 60 Bins -> Max 60 Minutes case "1m": // 1 Minute Bins + Max 60 Bins -> Max 60 Minutes
targetBinCount = 60 targetBinCount = 60
targetBinSize = 60 targetBinSize = 60
case *durationBins == "10m": // 10 Minute Bins + Max 72 Bins -> Max 12 Hours case "10m": // 10 Minute Bins + Max 72 Bins -> Max 12 Hours
targetBinCount = 72 targetBinCount = 72
targetBinSize = 600 targetBinSize = 600
case *durationBins == "1h": // 1 Hour Bins + Max 48 Bins -> Max 48 Hours case "1h": // 1 Hour Bins + Max 48 Bins -> Max 48 Hours
targetBinCount = 48 targetBinCount = 48
targetBinSize = 3600 targetBinSize = 3600
case *durationBins == "6h": // 6 Hour Bins + Max 12 Bins -> Max 3 Days case "6h": // 6 Hour Bins + Max 12 Bins -> Max 3 Days
targetBinCount = 12 targetBinCount = 12
targetBinSize = 21600 targetBinSize = 21600
case *durationBins == "12h": // 12 hour Bins + Max 14 Bins -> Max 7 Days case "12h": // 12 hour Bins + Max 14 Bins -> Max 7 Days
targetBinCount = 14 targetBinCount = 14
targetBinSize = 43200 targetBinSize = 43200
default: // 24h default: // 24h
@@ -499,7 +665,30 @@ func (r *JobRepository) AddHistograms(
return stat, nil return stat, nil
} }
// Requires thresholds for metric from config for cluster? Of all clusters and use largest? split to 10 + 1 for artifacts? // AddMetricHistograms augments statistics with distribution histograms for job metrics.
//
// Generates histogram data for metrics like CPU load, memory usage, etc. Handles running
// and completed jobs differently: running jobs load data from metric backend, completed jobs
// use footprint data from database.
//
// Parameters:
// - ctx: Context for security checks
// - filter: Filters to apply (MUST contain State filter for running jobs)
// - metrics: List of metric names to histogram (e.g., ["cpu_load", "mem_used"])
// - stat: Statistics struct to augment (modified in-place)
// - targetBinCount: Number of histogram bins (default: 10)
//
// Populates HistMetrics field in stat with MetricHistoPoints for each metric.
//
// Binning algorithm:
// - Values normalized to metric's peak value from cluster configuration
// - Bins evenly distributed from 0 to peak
// - Pre-initialized with zeros for consistent visualization
//
// Limitations:
// - Running jobs: Limited to 500 jobs for performance
// - Requires valid cluster configuration with metric peak values
// - Uses footprint statistic (avg/max/min) configured per metric
func (r *JobRepository) AddMetricHistograms( func (r *JobRepository) AddMetricHistograms(
ctx context.Context, ctx context.Context,
filter []*model.JobFilter, filter []*model.JobFilter,
@@ -534,7 +723,16 @@ func (r *JobRepository) AddMetricHistograms(
return stat, nil return stat, nil
} }
// `value` must be the column grouped by, but renamed to "value" // jobsStatisticsHistogram generates a simple histogram by grouping on a column value.
//
// Used for histograms where the column value directly represents the bin (e.g., node count, core count).
// Unlike duration/metric histograms, this doesn't pre-initialize bins with zeros.
//
// Parameters:
// - value: SQL expression that produces the histogram value, aliased as "value"
// - filters: Job filters to apply
//
// Returns histogram points with Value (from column) and Count (number of jobs).
func (r *JobRepository) jobsStatisticsHistogram( func (r *JobRepository) jobsStatisticsHistogram(
ctx context.Context, ctx context.Context,
value string, value string,
@@ -573,6 +771,26 @@ func (r *JobRepository) jobsStatisticsHistogram(
return points, nil return points, nil
} }
// jobsDurationStatisticsHistogram generates a duration histogram with pre-initialized bins.
//
// Bins are zero-padded to provide consistent ranges for visualization, unlike simple
// histograms which only return bins with data. The value parameter should compute
// the bin number from job duration.
//
// Parameters:
// - value: SQL expression computing bin number from duration, aliased as "value"
// - filters: Job filters to apply
// - binSizeSeconds: Width of each bin in seconds
// - targetBinCount: Number of bins to pre-initialize
//
// Returns histogram points with Value (bin_number × binSizeSeconds) and Count.
// All bins from 1 to targetBinCount are returned, with Count=0 for empty bins.
//
// Algorithm:
// 1. Pre-initialize targetBinCount bins with zero counts
// 2. Query database for actual counts per bin
// 3. Match query results to pre-initialized bins by value
// 4. Bins without matches remain at zero
func (r *JobRepository) jobsDurationStatisticsHistogram( func (r *JobRepository) jobsDurationStatisticsHistogram(
ctx context.Context, ctx context.Context,
value string, value string,
@@ -588,7 +806,6 @@ func (r *JobRepository) jobsDurationStatisticsHistogram(
return nil, qerr return nil, qerr
} }
// Initialize histogram bins with zero counts
// Each bin represents a duration range: bin N = [N*binSizeSeconds, (N+1)*binSizeSeconds) // Each bin represents a duration range: bin N = [N*binSizeSeconds, (N+1)*binSizeSeconds)
// Example: binSizeSeconds=3600 (1 hour), bin 1 = 0-1h, bin 2 = 1-2h, etc. // Example: binSizeSeconds=3600 (1 hour), bin 1 = 0-1h, bin 2 = 1-2h, etc.
points := make([]*model.HistoPoint, 0) points := make([]*model.HistoPoint, 0)
@@ -607,8 +824,8 @@ func (r *JobRepository) jobsDurationStatisticsHistogram(
return nil, err return nil, err
} }
// Match query results to pre-initialized bins and fill counts // Match query results to pre-initialized bins.
// Query returns raw duration values that need to be mapped to correct bins // point.Value from query is the bin number; multiply by binSizeSeconds to match bin.Value.
for rows.Next() { for rows.Next() {
point := model.HistoPoint{} point := model.HistoPoint{}
if err := rows.Scan(&point.Value, &point.Count); err != nil { if err := rows.Scan(&point.Value, &point.Count); err != nil {
@@ -616,13 +833,8 @@ func (r *JobRepository) jobsDurationStatisticsHistogram(
return nil, err return nil, err
} }
// Find matching bin and update count
// point.Value is multiplied by binSizeSeconds to match pre-calculated bin.Value
for _, e := range points { for _, e := range points {
if e.Value == (point.Value * binSizeSeconds) { if e.Value == (point.Value * binSizeSeconds) {
// Note: Matching on unmodified integer value (and multiplying point.Value
// by binSizeSeconds after match) causes frontend to loop into highest
// targetBinCount, due to zoom condition instantly being fulfilled (cause unknown)
e.Count = point.Count e.Count = point.Count
break break
} }
@@ -633,13 +845,34 @@ func (r *JobRepository) jobsDurationStatisticsHistogram(
return points, nil return points, nil
} }
// jobsMetricStatisticsHistogram generates a metric histogram using footprint data from completed jobs.
//
// Values are normalized to the metric's peak value and distributed into bins. The algorithm
// is based on SQL histogram generation techniques, extracting metric values from JSON footprint
// and computing bin assignments in SQL.
//
// Parameters:
// - metric: Metric name (e.g., "cpu_load", "mem_used")
// - filters: Job filters to apply
// - bins: Number of bins to generate
//
// Returns MetricHistoPoints with metric name, unit, footprint stat type, and binned data.
//
// Algorithm:
// 1. Determine peak value from cluster configuration (filtered cluster or max across all)
// 2. Generate SQL that extracts footprint value, normalizes to [0,1], multiplies by bin count
// 3. Pre-initialize bins with min/max ranges based on peak value
// 4. Query database for counts per bin
// 5. Match results to pre-initialized bins
//
// Special handling: Values exactly equal to peak are forced into the last bin by multiplying
// peak by 0.999999999 to avoid creating an extra bin.
func (r *JobRepository) jobsMetricStatisticsHistogram( func (r *JobRepository) jobsMetricStatisticsHistogram(
ctx context.Context, ctx context.Context,
metric string, metric string,
filters []*model.JobFilter, filters []*model.JobFilter,
bins *int, bins *int,
) (*model.MetricHistoPoints, error) { ) (*model.MetricHistoPoints, error) {
// Determine the metric's peak value for histogram normalization
// Peak value defines the upper bound for binning: values are distributed across // Peak value defines the upper bound for binning: values are distributed across
// bins from 0 to peak. First try to get peak from filtered cluster, otherwise // bins from 0 to peak. First try to get peak from filtered cluster, otherwise
// scan all clusters to find the maximum peak value. // scan all clusters to find the maximum peak value.
@@ -679,18 +912,14 @@ func (r *JobRepository) jobsMetricStatisticsHistogram(
} }
} }
// Construct SQL histogram bins using normalized values // Construct SQL histogram bins using normalized values.
// Algorithm based on: https://jereze.com/code/sql-histogram/ (modified) // Algorithm based on: https://jereze.com/code/sql-histogram/ (modified)
start := time.Now() start := time.Now()
// Calculate bin number for each job's metric value: // Bin calculation formula:
// 1. Extract metric value from JSON footprint // bin_number = CAST( (value / peak) * num_bins AS INTEGER ) + 1
// 2. Normalize to [0,1] by dividing by peak // Special case: value == peak would create bin N+1, so we test for equality
// 3. Multiply by number of bins to get bin number // and multiply peak by 0.999999999 to force it into bin N.
// 4. Cast to integer for bin assignment
//
// Special case: Values exactly equal to peak would fall into bin N+1,
// so we multiply peak by 0.999999999 to force it into the last bin (bin N)
binQuery := fmt.Sprintf(`CAST( binQuery := fmt.Sprintf(`CAST(
((case when json_extract(footprint, "$.%s") = %f then %f*0.999999999 else json_extract(footprint, "$.%s") end) / %f) ((case when json_extract(footprint, "$.%s") = %f then %f*0.999999999 else json_extract(footprint, "$.%s") end) / %f)
* %v as INTEGER )`, * %v as INTEGER )`,
@@ -699,24 +928,19 @@ func (r *JobRepository) jobsMetricStatisticsHistogram(
mainQuery := sq.Select( mainQuery := sq.Select(
fmt.Sprintf(`%s + 1 as bin`, binQuery), fmt.Sprintf(`%s + 1 as bin`, binQuery),
`count(*) as count`, `count(*) as count`,
// For Debug: // fmt.Sprintf(`CAST((%f / %d) as INTEGER ) * %s as min`, peak, *bins, binQuery),
// For Debug: // fmt.Sprintf(`CAST((%f / %d) as INTEGER ) * (%s + 1) as max`, peak, *bins, binQuery),
).From("job").Where( ).From("job").Where(
"JSON_VALID(footprint)", "JSON_VALID(footprint)",
).Where(fmt.Sprintf(`json_extract(footprint, "$.%s") is not null and json_extract(footprint, "$.%s") <= %f`, (metric + "_" + footprintStat), (metric + "_" + footprintStat), peak)) ).Where(fmt.Sprintf(`json_extract(footprint, "$.%s") is not null and json_extract(footprint, "$.%s") <= %f`, (metric + "_" + footprintStat), (metric + "_" + footprintStat), peak))
// Only accessible Jobs...
mainQuery, qerr := SecurityCheck(ctx, mainQuery) mainQuery, qerr := SecurityCheck(ctx, mainQuery)
if qerr != nil { if qerr != nil {
return nil, qerr return nil, qerr
} }
// Filters...
for _, f := range filters { for _, f := range filters {
mainQuery = BuildWhereClause(f, mainQuery) mainQuery = BuildWhereClause(f, mainQuery)
} }
// Finalize query with Grouping and Ordering
mainQuery = mainQuery.GroupBy("bin").OrderBy("bin") mainQuery = mainQuery.GroupBy("bin").OrderBy("bin")
rows, err := mainQuery.RunWith(r.DB).Query() rows, err := mainQuery.RunWith(r.DB).Query()
@@ -725,8 +949,7 @@ func (r *JobRepository) jobsMetricStatisticsHistogram(
return nil, err return nil, err
} }
// Initialize histogram bins with calculated min/max ranges // Pre-initialize bins with calculated min/max ranges.
// Each bin represents a range of metric values
// Example: peak=1000, bins=10 -> bin 1=[0,100), bin 2=[100,200), ..., bin 10=[900,1000] // Example: peak=1000, bins=10 -> bin 1=[0,100), bin 2=[100,200), ..., bin 10=[900,1000]
points := make([]*model.MetricHistoPoint, 0) points := make([]*model.MetricHistoPoint, 0)
binStep := int(peak) / *bins binStep := int(peak) / *bins
@@ -737,32 +960,21 @@ func (r *JobRepository) jobsMetricStatisticsHistogram(
points = append(points, &epoint) points = append(points, &epoint)
} }
// Fill counts from query results // Match query results to pre-initialized bins.
// Query only returns bins that have jobs, so we match against pre-initialized bins
for rows.Next() { for rows.Next() {
rpoint := model.MetricHistoPoint{} rpoint := model.MetricHistoPoint{}
if err := rows.Scan(&rpoint.Bin, &rpoint.Count); err != nil { // Required for Debug: &rpoint.Min, &rpoint.Max if err := rows.Scan(&rpoint.Bin, &rpoint.Count); err != nil {
cclog.Warnf("Error while scanning rows for %s", metric) cclog.Warnf("Error while scanning rows for %s", metric)
return nil, err // FIXME: Totally bricks cc-backend if returned and if all metrics requested? return nil, err
} }
// Match query result to pre-initialized bin and update count
for _, e := range points { for _, e := range points {
if e.Bin != nil && rpoint.Bin != nil { if e.Bin != nil && rpoint.Bin != nil && *e.Bin == *rpoint.Bin {
if *e.Bin == *rpoint.Bin {
e.Count = rpoint.Count e.Count = rpoint.Count
// Only Required For Debug: Check DB returned Min/Max against Backend Init above
// if rpoint.Min != nil {
// cclog.Warnf(">>>> Bin %d Min Set For %s to %d (Init'd with: %d)", *e.Bin, metric, *rpoint.Min, *e.Min)
// }
// if rpoint.Max != nil {
// cclog.Warnf(">>>> Bin %d Max Set For %s to %d (Init'd with: %d)", *e.Bin, metric, *rpoint.Max, *e.Max)
// }
break break
} }
} }
} }
}
result := model.MetricHistoPoints{Metric: metric, Unit: unit, Stat: &footprintStat, Data: points} result := model.MetricHistoPoints{Metric: metric, Unit: unit, Stat: &footprintStat, Data: points}
@@ -770,6 +982,28 @@ func (r *JobRepository) jobsMetricStatisticsHistogram(
return &result, nil return &result, nil
} }
// runningJobsMetricStatisticsHistogram generates metric histograms for running jobs using live data.
//
// Unlike completed jobs which use footprint data from the database, running jobs require
// fetching current metric averages from the metric backend (via metricdispatch).
//
// Parameters:
// - metrics: List of metric names
// - filters: Job filters (should filter to running jobs only)
// - bins: Number of histogram bins
//
// Returns slice of MetricHistoPoints, one per metric.
//
// Limitations:
// - Maximum 500 jobs (returns nil if more jobs match)
// - Requires metric backend availability
// - Bins based on metric peak values from cluster configuration
//
// Algorithm:
// 1. Query first 501 jobs to check count limit
// 2. Load metric averages for all jobs via metricdispatch
// 3. For each metric, create bins based on peak value
// 4. Iterate averages and count jobs per bin
func (r *JobRepository) runningJobsMetricStatisticsHistogram( func (r *JobRepository) runningJobsMetricStatisticsHistogram(
ctx context.Context, ctx context.Context,
metrics []string, metrics []string,

View File

@@ -3,6 +3,34 @@
// Use of this source code is governed by a MIT-style // Use of this source code is governed by a MIT-style
// license that can be found in the LICENSE file. // license that can be found in the LICENSE file.
// Package repository provides data access and persistence layer for ClusterCockpit.
//
// This file implements tag management functionality for job categorization and classification.
// Tags support both manual assignment (via REST/GraphQL APIs) and automatic detection
// (via tagger plugins). The implementation includes role-based access control through
// tag scopes and maintains bidirectional consistency between the SQL database and
// the file-based job archive.
//
// Database Schema:
//
// CREATE TABLE tag (
// id INTEGER PRIMARY KEY AUTOINCREMENT,
// tag_type VARCHAR(255) NOT NULL,
// tag_name VARCHAR(255) NOT NULL,
// tag_scope VARCHAR(255) NOT NULL DEFAULT "global",
// CONSTRAINT tag_unique UNIQUE (tag_type, tag_name, tag_scope)
// );
//
// CREATE TABLE jobtag (
// job_id INTEGER,
// tag_id INTEGER,
// PRIMARY KEY (job_id, tag_id),
// FOREIGN KEY (job_id) REFERENCES job(id) ON DELETE CASCADE,
// FOREIGN KEY (tag_id) REFERENCES tag(id) ON DELETE CASCADE
// );
//
// The jobtag junction table enables many-to-many relationships between jobs and tags.
// CASCADE deletion ensures referential integrity when jobs or tags are removed.
package repository package repository
import ( import (
@@ -73,7 +101,7 @@ func (r *JobRepository) AddTagDirect(job int64, tag int64) ([]*schema.Tag, error
func (r *JobRepository) RemoveTag(user *schema.User, job, tag int64) ([]*schema.Tag, error) { func (r *JobRepository) RemoveTag(user *schema.User, job, 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 while finding job %d for user %s during tag removal: %v", job, user.Username, err)
return nil, err return nil, err
} }
@@ -93,7 +121,7 @@ func (r *JobRepository) RemoveTag(user *schema.User, job, tag int64) ([]*schema.
archiveTags, err := r.getArchiveTags(&job) archiveTags, err := r.getArchiveTags(&job)
if err != nil { if err != nil {
cclog.Warn("Error while getting tags for job") cclog.Warnf("Error while getting archive tags for job %d in RemoveTag: %v", job, err)
return nil, err return nil, err
} }
@@ -104,7 +132,7 @@ func (r *JobRepository) RemoveTag(user *schema.User, job, tag int64) ([]*schema.
// Requires user authentication for security checks. Used by REST API. // Requires user authentication for security checks. Used by REST API.
func (r *JobRepository) RemoveJobTagByRequest(user *schema.User, job int64, tagType string, tagName string, tagScope string) ([]*schema.Tag, error) { func (r *JobRepository) RemoveJobTagByRequest(user *schema.User, job int64, tagType string, tagName string, tagScope string) ([]*schema.Tag, error) {
// Get Tag ID to delete // Get Tag ID to delete
tagID, exists := r.TagId(tagType, tagName, tagScope) tagID, exists := r.TagID(tagType, tagName, tagScope)
if !exists { if !exists {
cclog.Warnf("Tag does not exist (name, type, scope): %s, %s, %s", tagName, tagType, tagScope) cclog.Warnf("Tag does not exist (name, type, scope): %s, %s, %s", tagName, tagType, tagScope)
return nil, fmt.Errorf("tag does not exist (name, type, scope): %s, %s, %s", tagName, tagType, tagScope) return nil, fmt.Errorf("tag does not exist (name, type, scope): %s, %s, %s", tagName, tagType, tagScope)
@@ -113,7 +141,7 @@ func (r *JobRepository) RemoveJobTagByRequest(user *schema.User, job int64, tagT
// Get Job // Get Job
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 while finding job %d for user %s during tag removal by request: %v", job, user.Username, err)
return nil, err return nil, err
} }
@@ -128,19 +156,30 @@ func (r *JobRepository) RemoveJobTagByRequest(user *schema.User, job int64, tagT
tags, err := r.GetTags(user, &job) tags, err := r.GetTags(user, &job)
if err != nil { if err != nil {
cclog.Warn("Error while getting tags for job") cclog.Warnf("Error while getting tags for job %d in RemoveJobTagByRequest: %v", job, err)
return nil, err return nil, err
} }
archiveTags, err := r.getArchiveTags(&job) archiveTags, err := r.getArchiveTags(&job)
if err != nil { if err != nil {
cclog.Warn("Error while getting tags for job") cclog.Warnf("Error while getting archive tags for job %d in RemoveJobTagByRequest: %v", job, err)
return nil, err return nil, err
} }
return tags, archive.UpdateTags(j, archiveTags) return tags, archive.UpdateTags(j, archiveTags)
} }
// removeTagFromArchiveJobs updates the job archive for all affected jobs after a tag deletion.
//
// This function is called asynchronously (via goroutine) after removing a tag from the database
// to synchronize the file-based job archive with the database state. Errors are logged but not
// returned since this runs in the background.
//
// Parameters:
// - jobIds: Database IDs of all jobs that had the deleted tag
//
// Implementation note: Each job is processed individually to handle partial failures gracefully.
// If one job fails to update, others will still be processed.
func (r *JobRepository) removeTagFromArchiveJobs(jobIds []int64) { func (r *JobRepository) removeTagFromArchiveJobs(jobIds []int64) {
for _, j := range jobIds { for _, j := range jobIds {
tags, err := r.getArchiveTags(&j) tags, err := r.getArchiveTags(&j)
@@ -163,18 +202,18 @@ func (r *JobRepository) removeTagFromArchiveJobs(jobIds []int64) {
// Used by REST API. Does not update tagged jobs in Job archive. // Used by REST API. Does not update tagged jobs in Job archive.
func (r *JobRepository) RemoveTagByRequest(tagType string, tagName string, tagScope string) error { func (r *JobRepository) RemoveTagByRequest(tagType string, tagName string, tagScope string) error {
// Get Tag ID to delete // Get Tag ID to delete
tagID, exists := r.TagId(tagType, tagName, tagScope) tagID, exists := r.TagID(tagType, tagName, tagScope)
if !exists { if !exists {
cclog.Warnf("Tag does not exist (name, type, scope): %s, %s, %s", tagName, tagType, tagScope) cclog.Warnf("Tag does not exist (name, type, scope): %s, %s, %s", tagName, tagType, tagScope)
return fmt.Errorf("tag does not exist (name, type, scope): %s, %s, %s", tagName, tagType, tagScope) return fmt.Errorf("tag does not exist (name, type, scope): %s, %s, %s", tagName, tagType, tagScope)
} }
return r.RemoveTagById(tagID) return r.RemoveTagByID(tagID)
} }
// Removes a tag from db by tag id // Removes a tag from db by tag id
// Used by GraphQL API. // Used by GraphQL API.
func (r *JobRepository) RemoveTagById(tagID int64) error { func (r *JobRepository) RemoveTagByID(tagID int64) error {
jobIds, err := r.FindJobIdsByTag(tagID) jobIds, err := r.FindJobIdsByTag(tagID)
if err != nil { if err != nil {
return err return err
@@ -213,7 +252,7 @@ func (r *JobRepository) RemoveTagById(tagID int64) error {
// Example: // Example:
// //
// tagID, err := repo.CreateTag("performance", "high-memory", "global") // tagID, err := repo.CreateTag("performance", "high-memory", "global")
func (r *JobRepository) CreateTag(tagType string, tagName string, tagScope string) (tagId int64, err error) { func (r *JobRepository) CreateTag(tagType string, tagName string, tagScope string) (tagID int64, err error) {
// Default to "Global" scope if none defined // Default to "Global" scope if none defined
if tagScope == "" { if tagScope == "" {
tagScope = "global" tagScope = "global"
@@ -300,13 +339,13 @@ func (r *JobRepository) CountTags(user *schema.User) (tags []schema.Tag, counts
for rows.Next() { for rows.Next() {
var tagType string var tagType string
var tagName string var tagName string
var tagId int var tagID int
var count int var count int
if err = rows.Scan(&tagType, &tagName, &tagId, &count); err != nil { if err = rows.Scan(&tagType, &tagName, &tagID, &count); err != nil {
return nil, nil, err return nil, nil, err
} }
// Use tagId as second Map-Key component to differentiate tags with identical names // Use tagId as second Map-Key component to differentiate tags with identical names
counts[fmt.Sprint(tagType, tagName, tagId)] = count counts[fmt.Sprint(tagType, tagName, tagID)] = count
} }
err = rows.Err() err = rows.Err()
@@ -314,18 +353,44 @@ func (r *JobRepository) CountTags(user *schema.User) (tags []schema.Tag, counts
} }
var ( var (
// ErrTagNotFound is returned when a tag ID or tag identifier (type, name, scope) does not exist in the database.
ErrTagNotFound = errors.New("the tag does not exist") ErrTagNotFound = errors.New("the tag does not exist")
// ErrJobNotOwned is returned when a user attempts to tag a job they do not have permission to access.
ErrJobNotOwned = errors.New("user is not owner of job") ErrJobNotOwned = errors.New("user is not owner of job")
// ErrTagNoAccess is returned when a user attempts to use a tag they cannot access due to scope restrictions.
ErrTagNoAccess = errors.New("user not permitted to use that tag") ErrTagNoAccess = errors.New("user not permitted to use that tag")
// ErrTagPrivateScope is returned when a user attempts to access another user's private tag.
ErrTagPrivateScope = errors.New("tag is private to another user") ErrTagPrivateScope = errors.New("tag is private to another user")
// ErrTagAdminScope is returned when a non-admin user attempts to use an admin-scoped tag.
ErrTagAdminScope = errors.New("tag requires admin privileges") ErrTagAdminScope = errors.New("tag requires admin privileges")
// ErrTagsIncompatScopes is returned when attempting to combine admin and non-admin scoped tags in a single operation.
ErrTagsIncompatScopes = errors.New("combining admin and non-admin scoped tags not allowed") 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. // 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) { // This function performs three operations atomically:
q := sq.Insert("jobtag").Columns("job_id", "tag_id").Values(jobId, tagId) // 1. Inserts the job-tag association into the jobtag junction table
// 2. Retrieves the updated tag list for the job (using the provided getTags callback)
// 3. Updates the job archive with the new tags to maintain database-archive consistency
//
// Parameters:
// - jobId: Database ID of the job
// - tagId: Database ID of the tag to associate
// - job: Full job object needed for archive update
// - getTags: Callback function to retrieve updated tags (allows different security contexts)
//
// Returns the complete updated tag list for the job or an error.
//
// Note: This function does NOT validate tag scope permissions - callers must perform
// authorization checks before invoking this helper.
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 { if _, err := q.RunWith(r.stmtCache).Exec(); err != nil {
s, _, _ := q.ToSql() s, _, _ := q.ToSql()
@@ -335,13 +400,13 @@ func (r *JobRepository) addJobTag(jobId int64, tagId int64, job *schema.Job, get
tags, err := getTags() tags, err := getTags()
if err != nil { if err != nil {
cclog.Warnf("Error getting tags for job %d: %v", jobId, err) cclog.Warnf("Error getting tags for job %d: %v", jobID, err)
return nil, err return nil, err
} }
archiveTags, err := r.getArchiveTags(&jobId) archiveTags, err := r.getArchiveTags(&jobID)
if err != nil { if err != nil {
cclog.Warnf("Error getting archive tags for job %d: %v", jobId, err) cclog.Warnf("Error getting archive tags for job %d: %v", jobID, err)
return nil, err return nil, err
} }
@@ -350,7 +415,7 @@ func (r *JobRepository) addJobTag(jobId int64, tagId int64, job *schema.Job, get
// 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) {
// Default to "Global" scope if none defined // Default to "Global" scope if none defined
if tagScope == "" { if tagScope == "" {
tagScope = "global" tagScope = "global"
@@ -364,44 +429,43 @@ func (r *JobRepository) AddTagOrCreate(user *schema.User, jobId int64, tagType s
return 0, fmt.Errorf("cannot write tag scope with current authorization") return 0, fmt.Errorf("cannot write tag scope with current authorization")
} }
tagId, exists := r.TagId(tagType, tagName, tagScope) tagID, exists := r.TagID(tagType, tagName, tagScope)
if !exists { if !exists {
tagId, err = r.CreateTag(tagType, tagName, tagScope) tagID, err = r.CreateTag(tagType, tagName, tagScope)
if err != nil { if err != nil {
return 0, err return 0, err
} }
} }
if _, err := r.AddTag(user, jobId, tagId); err != nil { if _, err := r.AddTag(user, jobID, tagID); err != nil {
return 0, err return 0, err
} }
return tagId, nil return tagID, nil
} }
// used in auto tagger plugins func (r *JobRepository) AddTagOrCreateDirect(jobID int64, tagType string, tagName string) (tagID int64, err error) {
func (r *JobRepository) AddTagOrCreateDirect(jobId int64, tagType string, tagName string) (tagId int64, err error) {
tagScope := "global" tagScope := "global"
tagId, exists := r.TagId(tagType, tagName, tagScope) tagID, exists := r.TagID(tagType, tagName, tagScope)
if !exists { if !exists {
tagId, err = r.CreateTag(tagType, tagName, tagScope) tagID, err = r.CreateTag(tagType, tagName, tagScope)
if err != nil { if err != nil {
return 0, err return 0, err
} }
} }
if _, err := r.AddTagDirect(jobId, tagId); err != nil { if _, err := r.AddTagDirect(jobID, tagID); err != nil {
return 0, err return 0, err
} }
return tagId, nil return tagID, nil
} }
func (r *JobRepository) HasTag(jobId int64, tagType string, tagName string) bool { func (r *JobRepository) HasTag(jobID int64, tagType string, tagName string) bool {
var id int64 var id int64
q := sq.Select("id").From("tag").Join("jobtag ON jobtag.tag_id = tag.id"). q := sq.Select("id").From("tag").Join("jobtag ON jobtag.tag_id = tag.id").
Where("jobtag.job_id = ?", jobId).Where("tag.tag_type = ?", tagType). Where("jobtag.job_id = ?", jobID).Where("tag.tag_type = ?", tagType).
Where("tag.tag_name = ?", tagName) Where("tag.tag_name = ?", tagName)
err := q.RunWith(r.stmtCache).QueryRow().Scan(&id) err := q.RunWith(r.stmtCache).QueryRow().Scan(&id)
if err != nil { if err != nil {
@@ -411,21 +475,21 @@ func (r *JobRepository) HasTag(jobId int64, tagType string, tagName string) bool
} }
} }
// TagId returns the database id of the tag with the specified type and name. // TagID returns the database id of the tag with the specified type and name.
func (r *JobRepository) TagId(tagType string, tagName string, tagScope string) (tagId int64, exists bool) { func (r *JobRepository) TagID(tagType string, tagName string, tagScope string) (tagID int64, exists bool) {
exists = true exists = true
if err := sq.Select("id").From("tag"). if err := sq.Select("id").From("tag").
Where("tag.tag_type = ?", tagType).Where("tag.tag_name = ?", tagName).Where("tag.tag_scope = ?", tagScope). Where("tag.tag_type = ?", tagType).Where("tag.tag_name = ?", tagName).Where("tag.tag_scope = ?", tagScope).
RunWith(r.stmtCache).QueryRow().Scan(&tagId); err != nil { RunWith(r.stmtCache).QueryRow().Scan(&tagID); err != nil {
exists = false exists = false
} }
return return
} }
// TagInfo returns the database infos of the tag with the specified id. // TagInfo returns the database infos of the tag with the specified id.
func (r *JobRepository) TagInfo(tagId int64) (tagType string, tagName string, tagScope string, exists bool) { func (r *JobRepository) TagInfo(tagID int64) (tagType string, tagName string, tagScope string, exists bool) {
exists = true exists = true
if err := sq.Select("tag.tag_type", "tag.tag_name", "tag.tag_scope").From("tag").Where("tag.id = ?", tagId). if err := sq.Select("tag.tag_type", "tag.tag_name", "tag.tag_scope").From("tag").Where("tag.id = ?", tagID).
RunWith(r.stmtCache).QueryRow().Scan(&tagType, &tagName, &tagScope); err != nil { RunWith(r.stmtCache).QueryRow().Scan(&tagType, &tagName, &tagScope); err != nil {
exists = false exists = false
} }
@@ -450,7 +514,7 @@ func (r *JobRepository) GetTags(user *schema.User, job *int64) ([]*schema.Tag, e
for rows.Next() { for rows.Next() {
tag := &schema.Tag{} tag := &schema.Tag{}
if err := rows.Scan(&tag.ID, &tag.Type, &tag.Name, &tag.Scope); err != nil { if err := rows.Scan(&tag.ID, &tag.Type, &tag.Name, &tag.Scope); err != nil {
cclog.Warn("Error while scanning rows") cclog.Warnf("Error while scanning tag rows in GetTags: %v", err)
return nil, err return nil, err
} }
// Handle Scope Filtering: Tag Scope is Global, Private (== Username) or User is auth'd to view Admin Tags // Handle Scope Filtering: Tag Scope is Global, Private (== Username) or User is auth'd to view Admin Tags
@@ -483,7 +547,7 @@ func (r *JobRepository) GetTagsDirect(job *int64) ([]*schema.Tag, error) {
for rows.Next() { for rows.Next() {
tag := &schema.Tag{} tag := &schema.Tag{}
if err := rows.Scan(&tag.ID, &tag.Type, &tag.Name, &tag.Scope); err != nil { if err := rows.Scan(&tag.ID, &tag.Type, &tag.Name, &tag.Scope); err != nil {
cclog.Warn("Error while scanning rows") cclog.Warnf("Error while scanning tag rows in GetTagsDirect: %v", err)
return nil, err return nil, err
} }
tags = append(tags, tag) tags = append(tags, tag)
@@ -492,7 +556,18 @@ func (r *JobRepository) GetTagsDirect(job *int64) ([]*schema.Tag, error) {
return tags, nil return tags, nil
} }
// GetArchiveTags returns a list of all tags *regardless of scope* for archiving if job is nil or of the tags that the job with that database ID has. // getArchiveTags returns all tags for a job WITHOUT applying scope-based filtering.
//
// This internal function is used exclusively for job archive synchronization where we need
// to store all tags regardless of the current user's permissions. Unlike GetTags() which
// filters by scope, this returns the complete unfiltered tag list.
//
// Parameters:
// - job: Pointer to job database ID, or nil to return all tags in the system
//
// Returns all tags without scope filtering, used only for archive operations.
//
// WARNING: Do NOT expose this function to user-facing APIs as it bypasses authorization.
func (r *JobRepository) getArchiveTags(job *int64) ([]*schema.Tag, error) { func (r *JobRepository) getArchiveTags(job *int64) ([]*schema.Tag, error) {
q := sq.Select("id", "tag_type", "tag_name", "tag_scope").From("tag") q := sq.Select("id", "tag_type", "tag_name", "tag_scope").From("tag")
if job != nil { if job != nil {
@@ -510,7 +585,7 @@ func (r *JobRepository) getArchiveTags(job *int64) ([]*schema.Tag, error) {
for rows.Next() { for rows.Next() {
tag := &schema.Tag{} tag := &schema.Tag{}
if err := rows.Scan(&tag.ID, &tag.Type, &tag.Name, &tag.Scope); err != nil { if err := rows.Scan(&tag.ID, &tag.Type, &tag.Name, &tag.Scope); err != nil {
cclog.Warn("Error while scanning rows") cclog.Warnf("Error while scanning tag rows in getArchiveTags: %v", err)
return nil, err return nil, err
} }
tags = append(tags, tag) tags = append(tags, tag)
@@ -519,18 +594,18 @@ func (r *JobRepository) getArchiveTags(job *int64) ([]*schema.Tag, error) {
return tags, nil return tags, nil
} }
func (r *JobRepository) ImportTag(jobId int64, tagType string, tagName string, tagScope string) (err error) { func (r *JobRepository) ImportTag(jobID int64, tagType string, tagName string, tagScope string) (err error) {
// Import has no scope ctx, only import from metafile to DB (No recursive archive update required), only returns err // Import has no scope ctx, only import from metafile to DB (No recursive archive update required), only returns err
tagId, exists := r.TagId(tagType, tagName, tagScope) tagID, exists := r.TagID(tagType, tagName, tagScope)
if !exists { if !exists {
tagId, err = r.CreateTag(tagType, tagName, tagScope) tagID, err = r.CreateTag(tagType, tagName, tagScope)
if err != nil { if err != nil {
return err return err
} }
} }
q := sq.Insert("jobtag").Columns("job_id", "tag_id").Values(jobId, tagId) q := sq.Insert("jobtag").Columns("job_id", "tag_id").Values(jobID, tagID)
if _, err := q.RunWith(r.stmtCache).Exec(); err != nil { if _, err := q.RunWith(r.stmtCache).Exec(); err != nil {
s, _, _ := q.ToSql() s, _, _ := q.ToSql()
@@ -541,6 +616,28 @@ func (r *JobRepository) ImportTag(jobId int64, tagType string, tagName string, t
return nil return nil
} }
// checkScopeAuth validates whether a user is authorized to perform an operation on a tag with the given scope.
//
// This function implements the tag scope authorization matrix:
//
// Scope | Read Access | Write Access
// -------------|----------------------------------|----------------------------------
// "global" | All users | Admin, Support, API-only
// "admin" | Admin, Support | Admin, API-only
// <username> | Owner only | Owner only (private tags)
//
// Parameters:
// - user: User attempting the operation (must not be nil)
// - operation: Either "read" or "write"
// - scope: Tag scope value ("global", "admin", or username for private tags)
//
// Returns:
// - pass: true if authorized, false if denied
// - err: error only if operation is invalid or user is nil
//
// Special cases:
// - API-only users (single role: RoleApi) can write to admin and global scopes for automation
// - Private tags use the username as scope, granting exclusive access to that user
func (r *JobRepository) checkScopeAuth(user *schema.User, operation string, scope string) (pass bool, err error) { func (r *JobRepository) checkScopeAuth(user *schema.User, operation string, scope string) (pass bool, err error) {
if user != nil { if user != nil {
switch { switch {

View File

@@ -108,7 +108,7 @@ func initClusterConfig() error {
} }
availability.SubClusters = append(availability.SubClusters, sc.Name) availability.SubClusters = append(availability.SubClusters, sc.Name)
sc.MetricConfig = append(sc.MetricConfig, *newMetric) sc.MetricConfig = append(sc.MetricConfig, newMetric)
if newMetric.Footprint != "" { if newMetric.Footprint != "" {
sc.Footprint = append(sc.Footprint, newMetric.Name) sc.Footprint = append(sc.Footprint, newMetric.Name)
@@ -282,7 +282,7 @@ func GetSubClusterByNode(cluster, hostname string) (string, error) {
return "", fmt.Errorf("ARCHIVE/CLUSTERCONFIG > no subcluster found for cluster %v and host %v", cluster, hostname) return "", fmt.Errorf("ARCHIVE/CLUSTERCONFIG > no subcluster found for cluster %v and host %v", cluster, hostname)
} }
func MetricIndex(mc []schema.MetricConfig, name string) (int, error) { func MetricIndex(mc []*schema.MetricConfig, name string) (int, error) {
for i, m := range mc { for i, m := range mc {
if m.Name == name { if m.Name == name {
return i, nil return i, nil

View File

@@ -3,6 +3,70 @@
// 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 provides nodelist parsing functionality for HPC cluster node specifications.
//
// # Overview
//
// The nodelist package implements parsing and querying of compact node list representations
// commonly used in HPC job schedulers and cluster management systems. It converts compressed
// node specifications (e.g., "node[01-10]") into queryable structures that can efficiently
// test node membership and expand to full node lists.
//
// # Node List Format
//
// Node lists use a compact syntax with the following rules:
//
// 1. Comma-separated terms represent alternative node patterns (OR logic)
// 2. Each term consists of a string prefix followed by optional numeric ranges
// 3. Numeric ranges are specified in square brackets with zero-padded start-end format
// 4. Multiple ranges within brackets are comma-separated
// 5. Range digits must be zero-padded and of equal length (e.g., "01-99" not "1-99")
//
// # Examples
//
// "node01" // Single node
// "node01,node02" // Multiple individual nodes
// "node[01-10]" // Range: node01 through node10 (zero-padded)
// "node[01-10,20-30]" // Multiple ranges: node01-10 and node20-30
// "cn-00[10-20],cn-00[50-60]" // Different prefixes with ranges
// "login,compute[001-100]" // Mixed individual and range terms
//
// # Usage
//
// Parse a node list specification:
//
// nl, err := ParseNodeList("node[01-10],login")
// if err != nil {
// log.Fatal(err)
// }
//
// Check if a node name matches the list:
//
// if nl.Contains("node05") {
// // node05 is in the list
// }
//
// Expand to full list of node names:
//
// nodes := nl.PrintList() // ["node01", "node02", ..., "node10", "login"]
//
// Count total nodes in the list:
//
// count := nl.NodeCount() // 11 (10 from range + 1 individual)
//
// # Integration
//
// This package is used by:
// - clusterConfig.go: Parses SubCluster.Nodes field from cluster configuration
// - schema.resolvers.go: GraphQL resolver for computing numberOfNodes in subclusters
// - Job archive: Validates node assignments against configured cluster topology
//
// # Constraints
//
// - Only zero-padded numeric ranges are supported
// - Range start and end must have identical digit counts
// - No whitespace allowed in node list specifications
// - Ranges must be specified as start-end (not individual numbers)
package archive package archive
import ( import (
@@ -13,12 +77,36 @@ import (
cclog "github.com/ClusterCockpit/cc-lib/v2/ccLogger" cclog "github.com/ClusterCockpit/cc-lib/v2/ccLogger"
) )
// NodeList represents a parsed node list specification as a collection of node pattern terms.
// Each term is a sequence of expressions that must match consecutively for a node name to match.
// Terms are evaluated with OR logic - a node matches if ANY term matches completely.
//
// Internal structure:
// - Outer slice: OR terms (comma-separated in input)
// - Inner slice: AND expressions (must all match sequentially)
// - Each expression implements: consume (pattern matching), limits (range info), prefix (string part)
//
// Example: "node[01-10],login" becomes:
// - Term 1: [NLExprString("node"), NLExprIntRanges(01-10)]
// - Term 2: [NLExprString("login")]
type NodeList [][]interface { type NodeList [][]interface {
consume(input string) (next string, ok bool) consume(input string) (next string, ok bool)
limits() []map[string]int limits() []map[string]int
prefix() string prefix() string
} }
// Contains tests whether the given node name matches any pattern in the NodeList.
// Returns true if the name matches at least one term completely, false otherwise.
//
// Matching logic:
// - Evaluates each term sequentially (OR logic across terms)
// - Within a term, all expressions must match in order (AND logic)
// - A match is complete only if the entire input is consumed (str == "")
//
// Examples:
// - NodeList("node[01-10]").Contains("node05") → true
// - NodeList("node[01-10]").Contains("node11") → false
// - NodeList("node[01-10]").Contains("node5") → false (missing zero-padding)
func (nl *NodeList) Contains(name string) bool { func (nl *NodeList) Contains(name string) bool {
var ok bool var ok bool
for _, term := range *nl { for _, term := range *nl {
@@ -38,14 +126,22 @@ func (nl *NodeList) Contains(name string) bool {
return false return false
} }
// PrintList expands the NodeList into a full slice of individual node names.
// This performs the inverse operation of ParseNodeList, expanding all ranges
// into their constituent node names with proper zero-padding.
//
// Returns a slice of node names in the order they appear in the NodeList.
// For range terms, nodes are expanded in ascending numeric order.
//
// Example:
// - ParseNodeList("node[01-03],login").PrintList() → ["node01", "node02", "node03", "login"]
func (nl *NodeList) PrintList() []string { func (nl *NodeList) PrintList() []string {
var out []string var out []string
for _, term := range *nl { for _, term := range *nl {
// Get String-Part first
prefix := term[0].prefix() prefix := term[0].prefix()
if len(term) == 1 { // If only String-Part in Term: Single Node Name -> Use as provided if len(term) == 1 {
out = append(out, prefix) out = append(out, prefix)
} else { // Else: Numeric start-end definition with x digits zeroPadded } else {
limitArr := term[1].limits() limitArr := term[1].limits()
for _, inner := range limitArr { for _, inner := range limitArr {
for i := inner["start"]; i < inner["end"]+1; i++ { for i := inner["start"]; i < inner["end"]+1; i++ {
@@ -61,12 +157,22 @@ func (nl *NodeList) PrintList() []string {
return out return out
} }
// NodeCount returns the total number of individual nodes represented by the NodeList.
// This efficiently counts nodes without expanding the full list, making it suitable
// for large node ranges.
//
// Calculation:
// - Individual node terms contribute 1
// - Range terms contribute (end - start + 1) for each range
//
// Example:
// - ParseNodeList("node[01-10],login").NodeCount() → 11 (10 from range + 1 individual)
func (nl *NodeList) NodeCount() int { func (nl *NodeList) NodeCount() int {
out := 0 out := 0
for _, term := range *nl { for _, term := range *nl {
if len(term) == 1 { // If only String-Part in Term: Single Node Name -> add one if len(term) == 1 {
out += 1 out += 1
} else { // Else: Numeric start-end definition -> add difference + 1 } else {
limitArr := term[1].limits() limitArr := term[1].limits()
for _, inner := range limitArr { for _, inner := range limitArr {
out += (inner["end"] - inner["start"]) + 1 out += (inner["end"] - inner["start"]) + 1
@@ -76,6 +182,8 @@ func (nl *NodeList) NodeCount() int {
return out return out
} }
// NLExprString represents a literal string prefix in a node name pattern.
// It matches by checking if the input starts with this exact string.
type NLExprString string type NLExprString string
func (nle NLExprString) consume(input string) (next string, ok bool) { func (nle NLExprString) consume(input string) (next string, ok bool) {
@@ -96,6 +204,8 @@ func (nle NLExprString) prefix() string {
return string(nle) return string(nle)
} }
// NLExprIntRanges represents multiple alternative integer ranges (comma-separated within brackets).
// A node name matches if it matches ANY of the contained ranges (OR logic).
type NLExprIntRanges []NLExprIntRange type NLExprIntRanges []NLExprIntRange
func (nles NLExprIntRanges) consume(input string) (next string, ok bool) { func (nles NLExprIntRanges) consume(input string) (next string, ok bool) {
@@ -122,6 +232,11 @@ func (nles NLExprIntRanges) prefix() string {
return s return s
} }
// NLExprIntRange represents a single zero-padded integer range (e.g., "01-99").
// Fields:
// - start, end: Numeric range boundaries (inclusive)
// - zeroPadded: Must be true (non-padded ranges not supported)
// - digits: Required digit count for zero-padding
type NLExprIntRange struct { type NLExprIntRange struct {
start, end int64 start, end int64
zeroPadded bool zeroPadded bool
@@ -176,6 +291,28 @@ func (nles NLExprIntRange) prefix() string {
return s return s
} }
// ParseNodeList parses a compact node list specification into a queryable NodeList structure.
//
// Input format rules:
// - Comma-separated terms (OR logic): "node01,node02" matches either node
// - Range syntax: "node[01-10]" expands to node01 through node10
// - Multiple ranges: "node[01-05,10-15]" creates two ranges
// - Zero-padding required: digits in ranges must be zero-padded and equal length
// - Mixed formats: "login,compute[001-100]" combines individual and range terms
//
// Validation:
// - Returns error if brackets are unclosed
// - Returns error if ranges lack '-' separator
// - Returns error if range digits have unequal length
// - Returns error if range numbers fail to parse
// - Returns error on invalid characters
//
// Examples:
// - "node[01-10]" → NodeList with one term (10 nodes)
// - "node01,node02" → NodeList with two terms (2 nodes)
// - "cn[01-05,10-15]" → NodeList with ranges 01-05 and 10-15 (11 nodes total)
// - "a[1-9]" → Error (not zero-padded)
// - "a[01-9]" → Error (unequal digit counts)
func ParseNodeList(raw string) (NodeList, error) { func ParseNodeList(raw string) (NodeList, error) {
isLetter := func(r byte) bool { return ('a' <= r && r <= 'z') || ('A' <= r && r <= 'Z') } isLetter := func(r byte) bool { return ('a' <= r && r <= 'z') || ('A' <= r && r <= 'Z') }
isDigit := func(r byte) bool { return '0' <= r && r <= '9' } isDigit := func(r byte) bool { return '0' <= r && r <= '9' }
@@ -232,12 +369,12 @@ func ParseNodeList(raw string) (NodeList, error) {
nles := NLExprIntRanges{} nles := NLExprIntRanges{}
for _, part := range parts { for _, part := range parts {
minus := strings.Index(part, "-") before, after, ok := strings.Cut(part, "-")
if minus == -1 { if !ok {
return nil, fmt.Errorf("ARCHIVE/NODELIST > no '-' found inside '[...]'") return nil, fmt.Errorf("ARCHIVE/NODELIST > no '-' found inside '[...]'")
} }
s1, s2 := part[0:minus], part[minus+1:] s1, s2 := before, after
if len(s1) != len(s2) || len(s1) == 0 { if len(s1) != len(s2) || len(s1) == 0 {
return nil, fmt.Errorf("ARCHIVE/NODELIST > %v and %v are not of equal length or of length zero", s1, s2) return nil, fmt.Errorf("ARCHIVE/NODELIST > %v and %v are not of equal length or of length zero", s1, s2)
} }