Merge pull request #148 from ClusterCockpit/hotfix

Hotfix
This commit is contained in:
Jan Eitzinger 2023-06-16 14:37:10 +02:00 committed by GitHub
commit 07f8950838
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
20 changed files with 228 additions and 91 deletions

2
.gitignore vendored
View File

@ -14,3 +14,5 @@
/archive-manager /archive-manager
var/job.db-shm var/job.db-shm
var/job.db-wal var/job.db-wal
dist/

70
.goreleaser.yaml Normal file
View File

@ -0,0 +1,70 @@
# This is an example .goreleaser.yml file with some sensible defaults.
before:
hooks:
- go mod tidy
builds:
- env:
- CGO_ENABLED=0
goos:
- linux
- darwin
goarch:
- amd64
- arm64
goamd64:
- v2
- v3
goarm:
- "7"
id: "cc-backend"
main: ./cmd/cc-backend
tags:
- static_build
archives:
- format: tar.gz
# this name template makes the OS and Arch compatible with the results of uname.
name_template: >-
{{ .ProjectName }}_
{{- title .Os }}_
{{- if eq .Arch "amd64" }}x86_64
{{- else }}{{ .Arch }}{{ end }}
{{- if .Arm }}v{{ .Arm }}{{ end }}
checksum:
name_template: 'checksums.txt'
snapshot:
name_template: "{{ incpatch .Version }}-next"
changelog:
sort: asc
filters:
exclude:
- "^test:"
- "^chore"
- "merge conflict"
- Merge pull request
- Merge remote-tracking branch
- Merge branch
groups:
- title: "Dependency updates"
regexp: '^.*?(feat|fix)\(deps\)!?:.+$'
order: 300
- title: "New Features"
regexp: '^.*?feat(\([[:word:]]+\))??!?:.+$'
order: 100
- title: "Security updates"
regexp: '^.*?sec(\([[:word:]]+\))??!?:.+$'
order: 150
- title: "Bug fixes"
regexp: '^.*?fix(\([[:word:]]+\))??!?:.+$'
order: 200
- title: "Documentation updates"
regexp: ^.*?doc(\([[:word:]]+\))??!?:.+$
order: 400
- title: Other work
order: 9999
release:
draft: true
footer: |
Supports job archive version 1 and database version 4.
# vim: set ts=2 sw=2 tw=0 fo=cnqoj

View File

@ -5,7 +5,7 @@ FRONTEND = ./web/frontend
VERSION = 1.0.0 VERSION = 1.0.0
GIT_HASH := $(shell git rev-parse --short HEAD || echo 'development') GIT_HASH := $(shell git rev-parse --short HEAD || echo 'development')
CURRENT_TIME = $(shell date +"%Y-%m-%d:T%H:%M:%S") CURRENT_TIME = $(shell date +"%Y-%m-%d:T%H:%M:%S")
LD_FLAGS = '-s -X main.buildTime=${CURRENT_TIME} -X main.version=${VERSION} -X main.hash=${GIT_HASH}' LD_FLAGS = '-s -X main.date=${CURRENT_TIME} -X main.version=${VERSION} -X main.commit=${GIT_HASH}'
EXECUTABLES = go npm EXECUTABLES = go npm
K := $(foreach exec,$(EXECUTABLES),\ K := $(foreach exec,$(EXECUTABLES),\

View File

@ -8,7 +8,7 @@ implementation of ClusterCockpit.
**Breaking changes** **Breaking changes**
The aggregate job statistic core hours is now computed using the job table The aggregate job statistic core hours is now computed using the job table
column `num_hwthreads`. In a the future release this column will be renamed to column `num_hwthreads`. In a future release this column will be renamed to
`num_cores`. For correct display of core hours `num_hwthreads` must be correctly `num_cores`. For correct display of core hours `num_hwthreads` must be correctly
filled on job start. If your existing jobs do not provide the correct value in filled on job start. If your existing jobs do not provide the correct value in
this column then you can set this with one SQL INSERT statement. This only applies this column then you can set this with one SQL INSERT statement. This only applies
@ -16,7 +16,7 @@ if you have exclusive jobs, only. Please be aware that we treat this column as
it is the number of cores. In case you have SMT enabled and `num_hwthreads` it is the number of cores. In case you have SMT enabled and `num_hwthreads`
is not the number of cores the core hours will be too high by a factor! is not the number of cores the core hours will be too high by a factor!
**Features** **Notable changes**
* Supports user roles admin, support, manager, user, and api. * Supports user roles admin, support, manager, user, and api.
* Unified search bar supports job id, job name, project id, user name, and name * Unified search bar supports job id, job name, project id, user name, and name
* Performance improvements for sqlite db backend * Performance improvements for sqlite db backend
@ -24,4 +24,3 @@ is not the number of cores the core hours will be too high by a factor!
* Better support for shared jobs * Better support for shared jobs
* More flexible metric list configuration * More flexible metric list configuration
* Versioning and migration for database and job archive * Versioning and migration for database and job archive

View File

@ -59,8 +59,8 @@ const logoString = `
` `
var ( var (
buildTime string date string
hash string commit string
version string version string
) )
@ -86,8 +86,8 @@ func main() {
if flagVersion { if flagVersion {
fmt.Print(logoString) fmt.Print(logoString)
fmt.Printf("Version:\t%s\n", version) fmt.Printf("Version:\t%s\n", version)
fmt.Printf("Git hash:\t%s\n", hash) fmt.Printf("Git hash:\t%s\n", commit)
fmt.Printf("Build time:\t%s\n", buildTime) fmt.Printf("Build time:\t%s\n", date)
fmt.Printf("SQL db version:\t%d\n", repository.Version) fmt.Printf("SQL db version:\t%d\n", repository.Version)
fmt.Printf("Job archive version:\t%d\n", archive.Version) fmt.Printf("Job archive version:\t%d\n", archive.Version)
os.Exit(0) os.Exit(0)
@ -245,7 +245,7 @@ func main() {
} }
r := mux.NewRouter() r := mux.NewRouter()
buildInfo := web.Build{Version: version, Hash: hash, Buildtime: buildTime} buildInfo := web.Build{Version: version, Hash: commit, Buildtime: date}
r.HandleFunc("/login", func(rw http.ResponseWriter, r *http.Request) { r.HandleFunc("/login", func(rw http.ResponseWriter, r *http.Request) {
rw.Header().Add("Content-Type", "text/html; charset=utf-8") rw.Header().Add("Content-Type", "text/html; charset=utf-8")
@ -320,7 +320,7 @@ func main() {
}) })
// Mount all /monitoring/... and /api/... routes. // Mount all /monitoring/... and /api/... routes.
routerConfig.SetupRoutes(secured, version, hash, buildTime) routerConfig.SetupRoutes(secured, version, commit, date)
api.MountRoutes(secured) api.MountRoutes(secured)
if config.Keys.EmbedStaticFiles { if config.Keys.EmbedStaticFiles {

7
go.mod
View File

@ -6,6 +6,7 @@ require (
github.com/99designs/gqlgen v0.17.24 github.com/99designs/gqlgen v0.17.24
github.com/ClusterCockpit/cc-units v0.4.0 github.com/ClusterCockpit/cc-units v0.4.0
github.com/Masterminds/squirrel v1.5.3 github.com/Masterminds/squirrel v1.5.3
github.com/go-co-op/gocron v1.25.0
github.com/go-ldap/ldap/v3 v3.4.4 github.com/go-ldap/ldap/v3 v3.4.4
github.com/go-sql-driver/mysql v1.7.0 github.com/go-sql-driver/mysql v1.7.0
github.com/golang-jwt/jwt/v4 v4.5.0 github.com/golang-jwt/jwt/v4 v4.5.0
@ -25,6 +26,7 @@ require (
github.com/swaggo/swag v1.8.10 github.com/swaggo/swag v1.8.10
github.com/vektah/gqlparser/v2 v2.5.1 github.com/vektah/gqlparser/v2 v2.5.1
golang.org/x/crypto v0.6.0 golang.org/x/crypto v0.6.0
golang.org/x/exp v0.0.0-20230510235704-dd950f8aeaea
) )
require ( require (
@ -40,7 +42,6 @@ require (
github.com/felixge/httpsnoop v1.0.3 // indirect github.com/felixge/httpsnoop v1.0.3 // indirect
github.com/ghodss/yaml v1.0.0 // indirect github.com/ghodss/yaml v1.0.0 // indirect
github.com/go-asn1-ber/asn1-ber v1.5.4 // indirect github.com/go-asn1-ber/asn1-ber v1.5.4 // indirect
github.com/go-co-op/gocron v1.25.0 // indirect
github.com/go-openapi/jsonpointer v0.19.6 // indirect github.com/go-openapi/jsonpointer v0.19.6 // indirect
github.com/go-openapi/jsonreference v0.20.2 // indirect github.com/go-openapi/jsonreference v0.20.2 // indirect
github.com/go-openapi/spec v0.20.8 // indirect github.com/go-openapi/spec v0.20.8 // indirect
@ -56,7 +57,6 @@ require (
github.com/josharian/intern v1.0.0 // indirect github.com/josharian/intern v1.0.0 // indirect
github.com/jpillora/backoff v1.0.0 // indirect github.com/jpillora/backoff v1.0.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect github.com/json-iterator/go v1.1.12 // indirect
github.com/kr/pretty v0.3.0 // indirect
github.com/lann/builder v0.0.0-20180802200727-47ae307949d0 // indirect github.com/lann/builder v0.0.0-20180802200727-47ae307949d0 // indirect
github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0 // indirect github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0 // indirect
github.com/mailru/easyjson v0.7.7 // indirect github.com/mailru/easyjson v0.7.7 // indirect
@ -70,14 +70,11 @@ require (
github.com/prometheus/client_model v0.3.0 // indirect github.com/prometheus/client_model v0.3.0 // indirect
github.com/prometheus/procfs v0.9.0 // indirect github.com/prometheus/procfs v0.9.0 // indirect
github.com/robfig/cron/v3 v3.0.1 // indirect github.com/robfig/cron/v3 v3.0.1 // indirect
github.com/rogpeppe/go-internal v1.8.1 // indirect
github.com/russross/blackfriday/v2 v2.1.0 // indirect github.com/russross/blackfriday/v2 v2.1.0 // indirect
github.com/stretchr/testify v1.8.2 // indirect
github.com/swaggo/files v1.0.0 // indirect github.com/swaggo/files v1.0.0 // indirect
github.com/urfave/cli/v2 v2.24.4 // indirect github.com/urfave/cli/v2 v2.24.4 // indirect
github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 // indirect github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 // indirect
go.uber.org/atomic v1.10.0 // indirect go.uber.org/atomic v1.10.0 // indirect
golang.org/x/exp v0.0.0-20230510235704-dd950f8aeaea // indirect
golang.org/x/mod v0.8.0 // indirect golang.org/x/mod v0.8.0 // indirect
golang.org/x/net v0.7.0 // indirect golang.org/x/net v0.7.0 // indirect
golang.org/x/oauth2 v0.5.0 // indirect golang.org/x/oauth2 v0.5.0 // indirect

5
go.sum
View File

@ -828,7 +828,6 @@ github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORN
github.com/kr/pretty v0.2.0/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= github.com/kr/pretty v0.2.0/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0= github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/pty v1.1.5/go.mod h1:9r2w37qlBe7rQ6e1fg1S/9xpWHSnaqNdHD3WcMdbPDA= github.com/kr/pty v1.1.5/go.mod h1:9r2w37qlBe7rQ6e1fg1S/9xpWHSnaqNdHD3WcMdbPDA=
github.com/kr/pty v1.1.8/go.mod h1:O1sed60cT9XZ5uDucP5qwvh+TE3NnUj51EiZO/lmSfw= github.com/kr/pty v1.1.8/go.mod h1:O1sed60cT9XZ5uDucP5qwvh+TE3NnUj51EiZO/lmSfw=
@ -1010,7 +1009,6 @@ github.com/pierrec/lz4 v2.0.5+incompatible/go.mod h1:pdkljMzZIN41W+lC3N2tnIh5sFi
github.com/pierrec/lz4/v4 v4.1.8/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4= github.com/pierrec/lz4/v4 v4.1.8/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4=
github.com/pkg/browser v0.0.0-20210706143420-7d21f8c997e2/go.mod h1:HKlIX3XHQyzLZPlr7++PzdhaXEj94dEiJgZDTsxEqUI= github.com/pkg/browser v0.0.0-20210706143420-7d21f8c997e2/go.mod h1:HKlIX3XHQyzLZPlr7++PzdhaXEj94dEiJgZDTsxEqUI=
github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8/go.mod h1:HKlIX3XHQyzLZPlr7++PzdhaXEj94dEiJgZDTsxEqUI= github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8/go.mod h1:HKlIX3XHQyzLZPlr7++PzdhaXEj94dEiJgZDTsxEqUI=
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/errors v0.8.1-0.20171018195549-f15c970de5b7/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.8.1-0.20171018195549-f15c970de5b7/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
@ -1073,9 +1071,7 @@ github.com/rogpeppe/fastuuid v1.2.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6L
github.com/rogpeppe/go-internal v1.1.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= github.com/rogpeppe/go-internal v1.1.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
github.com/rogpeppe/go-internal v1.2.2/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= github.com/rogpeppe/go-internal v1.2.2/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc=
github.com/rogpeppe/go-internal v1.8.1 h1:geMPLpDpQOgVyCg5z5GoRwLHepNdb71NXb67XFkP+Eg= github.com/rogpeppe/go-internal v1.8.1 h1:geMPLpDpQOgVyCg5z5GoRwLHepNdb71NXb67XFkP+Eg=
github.com/rogpeppe/go-internal v1.8.1/go.mod h1:JeRgkft04UBgHMgCIwADu4Pn6Mtm5d4nPKWu0nJ5d+o=
github.com/rs/xid v1.2.1/go.mod h1:+uKXf+4Djp6Md1KODXJxgGQPKngRmWyn10oCKFzNHOQ= github.com/rs/xid v1.2.1/go.mod h1:+uKXf+4Djp6Md1KODXJxgGQPKngRmWyn10oCKFzNHOQ=
github.com/rs/zerolog v1.13.0/go.mod h1:YbFCdg8HfsridGWAh22vktObvhZbQsZXe4/zB0OKkWU= github.com/rs/zerolog v1.13.0/go.mod h1:YbFCdg8HfsridGWAh22vktObvhZbQsZXe4/zB0OKkWU=
github.com/rs/zerolog v1.15.0/go.mod h1:xYTKnLHcpfU2225ny5qZjxnj9NvkumZYjJHlAThCjNc= github.com/rs/zerolog v1.15.0/go.mod h1:xYTKnLHcpfU2225ny5qZjxnj9NvkumZYjJHlAThCjNc=
@ -1155,7 +1151,6 @@ github.com/stretchr/testify v1.7.2/go.mod h1:R6va5+xMeoiuVRoj+gSkQ7d3FALtqAAGI1F
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.2 h1:+h33VjcLVPDHtOdpUCuF+7gSuG3yGIftsP1YvFihtJ8= github.com/stretchr/testify v1.8.2 h1:+h33VjcLVPDHtOdpUCuF+7gSuG3yGIftsP1YvFihtJ8=
github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/subosito/gotenv v1.2.0/go.mod h1:N0PQaV/YGNqwC0u51sEeR/aUtSLEXKX9iv69rRypqCw= github.com/subosito/gotenv v1.2.0/go.mod h1:N0PQaV/YGNqwC0u51sEeR/aUtSLEXKX9iv69rRypqCw=
github.com/swaggo/files v1.0.0 h1:1gGXVIeUFCS/dta17rnP0iOpr6CXFwKD7EO5ID233e4= github.com/swaggo/files v1.0.0 h1:1gGXVIeUFCS/dta17rnP0iOpr6CXFwKD7EO5ID233e4=
github.com/swaggo/files v1.0.0/go.mod h1:N59U6URJLyU1PQgFqPM7wXLMhJx7QAolnvfQkqO13kc= github.com/swaggo/files v1.0.0/go.mod h1:N59U6URJLyU1PQgFqPM7wXLMhJx7QAolnvfQkqO13kc=

View File

@ -6,6 +6,7 @@ package auth
import ( import (
"errors" "errors"
"fmt"
"net/http" "net/http"
"os" "os"
"strings" "strings"
@ -85,8 +86,8 @@ func (la *LdapAuthenticator) Login(
userDn := strings.Replace(la.config.UserBind, "{username}", user.Username, -1) userDn := strings.Replace(la.config.UserBind, "{username}", user.Username, -1)
if err := l.Bind(userDn, r.FormValue("password")); err != nil { if err := l.Bind(userDn, r.FormValue("password")); err != nil {
log.Error("Error while binding to ldap connection") log.Errorf("AUTH/LOCAL > Authentication for user %s failed: %v", user.Username, err)
return nil, err return nil, fmt.Errorf("AUTH/LDAP > Authentication failed")
} }
return user, nil return user, nil

View File

@ -8,6 +8,7 @@ import (
"fmt" "fmt"
"net/http" "net/http"
"github.com/ClusterCockpit/cc-backend/pkg/log"
"golang.org/x/crypto/bcrypt" "golang.org/x/crypto/bcrypt"
) )
@ -39,7 +40,8 @@ func (la *LocalAuthenticator) Login(
r *http.Request) (*User, error) { r *http.Request) (*User, error) {
if e := bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(r.FormValue("password"))); e != nil { if e := bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(r.FormValue("password"))); e != nil {
return nil, fmt.Errorf("AUTH/LOCAL > user '%s' provided the wrong password (%w)", user.Username, e) log.Errorf("AUTH/LOCAL > Authentication for user %s failed!", user.Username)
return nil, fmt.Errorf("AUTH/LOCAL > Authentication failed")
} }
return user, nil return user, nil

View File

@ -4,6 +4,7 @@
import Histogram from './plots/Histogram.svelte' import Histogram from './plots/Histogram.svelte'
import { Row, Col, Spinner, Card, CardHeader, CardTitle, CardBody, Table, Progress, Icon } from 'sveltestrap' import { Row, Col, Spinner, Card, CardHeader, CardTitle, CardBody, Table, Progress, Icon } from 'sveltestrap'
import { init } from './utils.js' import { init } from './utils.js'
import { scaleNumbers } from './units.js'
import { queryStore, gql, getContextClient } from '@urql/svelte' import { queryStore, gql, getContextClient } from '@urql/svelte'
const { query: initq } = init() const { query: initq } = init()
@ -50,15 +51,17 @@
? sum + (node.metrics.find(m => m.name == metric)?.metric.series.reduce((sum, series) => sum + series.data[series.data.length - 1], 0) || 0) ? sum + (node.metrics.find(m => m.name == metric)?.metric.series.reduce((sum, series) => sum + series.data[series.data.length - 1], 0) || 0)
: sum, 0) : sum, 0)
let allocatedNodes = {}, flopRate = {}, flopRateUnit = {}, memBwRate = {}, memBwRateUnit = {} let allocatedNodes = {}, flopRate = {}, flopRateUnitPrefix = {}, flopRateUnitBase = {}, memBwRate = {}, memBwRateUnitPrefix = {}, memBwRateUnitBase = {}
$: if ($initq.data && $mainQuery.data) { $: if ($initq.data && $mainQuery.data) {
let subClusters = $initq.data.clusters.find(c => c.name == cluster).subClusters let subClusters = $initq.data.clusters.find(c => c.name == cluster).subClusters
for (let subCluster of subClusters) { for (let subCluster of subClusters) {
allocatedNodes[subCluster.name] = $mainQuery.data.allocatedNodes.find(({ name }) => name == subCluster.name)?.count || 0 allocatedNodes[subCluster.name] = $mainQuery.data.allocatedNodes.find(({ name }) => name == subCluster.name)?.count || 0
flopRate[subCluster.name] = Math.floor(sumUp($mainQuery.data.nodeMetrics, subCluster.name, 'flops_any') * 100) / 100 flopRate[subCluster.name] = Math.floor(sumUp($mainQuery.data.nodeMetrics, subCluster.name, 'flops_any') * 100) / 100
flopRateUnit[subCluster.name] = subCluster.flopRateSimd.unit.prefix + subCluster.flopRateSimd.unit.base flopRateUnitPrefix[subCluster.name] = subCluster.flopRateSimd.unit.prefix
flopRateUnitBase[subCluster.name] = subCluster.flopRateSimd.unit.base
memBwRate[subCluster.name] = Math.floor(sumUp($mainQuery.data.nodeMetrics, subCluster.name, 'mem_bw') * 100) / 100 memBwRate[subCluster.name] = Math.floor(sumUp($mainQuery.data.nodeMetrics, subCluster.name, 'mem_bw') * 100) / 100
memBwRateUnit[subCluster.name] = subCluster.memoryBandwidth.unit.prefix + subCluster.memoryBandwidth.unit.base memBwRateUnitPrefix[subCluster.name] = subCluster.memoryBandwidth.unit.prefix
memBwRateUnitBase[subCluster.name] = subCluster.memoryBandwidth.unit.base
} }
} }
@ -111,17 +114,27 @@
<tr class="py-2"> <tr class="py-2">
<th scope="col">Allocated Nodes</th> <th scope="col">Allocated Nodes</th>
<td style="min-width: 100px;"><div class="col"><Progress value={allocatedNodes[subCluster.name]} max={subCluster.numberOfNodes}/></div></td> <td style="min-width: 100px;"><div class="col"><Progress value={allocatedNodes[subCluster.name]} max={subCluster.numberOfNodes}/></div></td>
<td>({allocatedNodes[subCluster.name]} Nodes / {subCluster.numberOfNodes} Total Nodes)</td> <td>{allocatedNodes[subCluster.name]} / {subCluster.numberOfNodes} Nodes</td>
</tr> </tr>
<tr class="py-2"> <tr class="py-2">
<th scope="col">Flop Rate (Any) <Icon name="info-circle" class="p-1" style="cursor: help;" title="Flops[Any] = (Flops[Double] x 2) + Flops[Single]"/></th> <th scope="col">Flop Rate (Any) <Icon name="info-circle" class="p-1" style="cursor: help;" title="Flops[Any] = (Flops[Double] x 2) + Flops[Single]"/></th>
<td style="min-width: 100px;"><div class="col"><Progress value={flopRate[subCluster.name]} max={subCluster.flopRateSimd.value * subCluster.numberOfNodes}/></div></td> <td style="min-width: 100px;"><div class="col"><Progress value={flopRate[subCluster.name]} max={subCluster.flopRateSimd.value * subCluster.numberOfNodes}/></div></td>
<td>({flopRate[subCluster.name]} {flopRateUnit[subCluster.name]} / {(subCluster.flopRateSimd.value * subCluster.numberOfNodes)} {flopRateUnit[subCluster.name]} [Max])</td> <td>
{scaleNumbers(flopRate[subCluster.name],
(subCluster.flopRateSimd.value * subCluster.numberOfNodes),
flopRateUnitPrefix[subCluster.name])
}{flopRateUnitBase[subCluster.name]} [Max]
</td>
</tr> </tr>
<tr class="py-2"> <tr class="py-2">
<th scope="col">MemBw Rate</th> <th scope="col">MemBw Rate</th>
<td style="min-width: 100px;"><div class="col"><Progress value={memBwRate[subCluster.name]} max={subCluster.memoryBandwidth.value * subCluster.numberOfNodes}/></div></td> <td style="min-width: 100px;"><div class="col"><Progress value={memBwRate[subCluster.name]} max={subCluster.memoryBandwidth.value * subCluster.numberOfNodes}/></div></td>
<td>({memBwRate[subCluster.name]} {memBwRateUnit[subCluster.name]} / {(subCluster.memoryBandwidth.value * subCluster.numberOfNodes)} {memBwRateUnit[subCluster.name]} [Max])</td> <td>
{scaleNumbers(memBwRate[subCluster.name],
(subCluster.memoryBandwidth.value * subCluster.numberOfNodes),
memBwRateUnitPrefix[subCluster.name])
}{memBwRateUnitBase[subCluster.name]} [Max]
</td>
</tr> </tr>
</Table> </Table>
</CardBody> </CardBody>

View File

@ -21,6 +21,8 @@ Changes: remove dependency, text inputs, configurable value ranges, on:change ev
export let max; export let max;
export let firstSlider; export let firstSlider;
export let secondSlider; export let secondSlider;
export let inputFieldFrom = 0;
export let inputFieldTo = 0;
const dispatch = createEventDispatcher(); const dispatch = createEventDispatcher();
@ -33,7 +35,6 @@ Changes: remove dependency, text inputs, configurable value ranges, on:change ev
let leftHandle; let leftHandle;
let body; let body;
let slider; let slider;
let inputFieldFrom, inputFieldTo;
let timeoutId = null; let timeoutId = null;
function queueChangeEvent() { function queueChangeEvent() {
@ -45,10 +46,10 @@ Changes: remove dependency, text inputs, configurable value ranges, on:change ev
timeoutId = null; timeoutId = null;
// Show selection but avoid feedback loop // Show selection but avoid feedback loop
if (values[0] != null && inputFieldFrom.value != values[0].toString()) if (values[0] != null && inputFieldFrom != values[0].toString())
inputFieldFrom.value = values[0].toString(); inputFieldFrom = values[0].toString();
if (values[1] != null && inputFieldTo.value != values[1].toString()) if (values[1] != null && inputFieldTo != values[1].toString())
inputFieldTo.value = values[1].toString(); inputFieldTo = values[1].toString();
dispatch('change', values); dispatch('change', values);
}, 250); }, 250);
@ -176,7 +177,7 @@ Changes: remove dependency, text inputs, configurable value ranges, on:change ev
const leftHandleLeft = leftHandle.getBoundingClientRect().left; const leftHandleLeft = leftHandle.getBoundingClientRect().left;
const pxStart = clamp((leftHandleLeft + event.detail.dx) - left, 0, parentWidth - width); const pxStart = clamp((leftHandleLeft + evt.detail.dx) - left, 0, parentWidth - width);
const pxEnd = clamp(pxStart + width, width, parentWidth); const pxEnd = clamp(pxStart + width, width, parentWidth);
const pStart = pxStart / parentWidth; const pStart = pxStart / parentWidth;
@ -190,12 +191,12 @@ Changes: remove dependency, text inputs, configurable value ranges, on:change ev
<div class="double-range-container"> <div class="double-range-container">
<div class="header"> <div class="header">
<input class="form-control" type="text" placeholder="from..." bind:this={inputFieldFrom} <input class="form-control" type="text" placeholder="from..." bind:value={inputFieldFrom}
on:input={(e) => inputChanged(0, e)} /> on:input={(e) => inputChanged(0, e)} />
<span>Full Range: <b> {min} </b> - <b> {max} </b></span> <span>Full Range: <b> {min} </b> - <b> {max} </b></span>
<input class="form-control" type="text" placeholder="to..." bind:this={inputFieldTo} <input class="form-control" type="text" placeholder="to..." bind:value={inputFieldTo}
on:input={(e) => inputChanged(1, e)} /> on:input={(e) => inputChanged(1, e)} />
</div> </div>
<div class="slider" bind:this={slider}> <div class="slider" bind:this={slider}>

View File

@ -60,7 +60,10 @@
isTagsOpen = false, isTagsOpen = false,
isDurationOpen = false, isDurationOpen = false,
isResourcesOpen = false, isResourcesOpen = false,
isStatsOpen = false isStatsOpen = false,
isNodesModified = false,
isHwthreadsModified = false,
isAccsModified = false
// Can be called from the outside to trigger a 'update' event from this component. // Can be called from the outside to trigger a 'update' event from this component.
export function update(additionalFilters = null) { export function update(additionalFilters = null) {
@ -181,7 +184,7 @@
<Icon name="tags"/> Tags <Icon name="tags"/> Tags
</DropdownItem> </DropdownItem>
<DropdownItem on:click={() => (isResourcesOpen = true)}> <DropdownItem on:click={() => (isResourcesOpen = true)}>
<Icon name="hdd-stack"/> Nodes/Accelerators <Icon name="hdd-stack"/> Resources
</DropdownItem> </DropdownItem>
<DropdownItem on:click={() => (isStatsOpen = true)}> <DropdownItem on:click={() => (isStatsOpen = true)}>
<Icon name="bar-chart" on:click={() => (isStatsOpen = true)}/> Statistics <Icon name="bar-chart" on:click={() => (isStatsOpen = true)}/> Statistics
@ -268,9 +271,15 @@
</Info> </Info>
{/if} {/if}
{#if filters.numNodes.from != null || filters.numNodes.to != null} {#if filters.numNodes.from != null || filters.numNodes.to != null ||
filters.numHWThreads.from != null || filters.numHWThreads.to != null ||
filters.numAccelerators.from != null || filters.numAccelerators.to != null }
<Info icon="hdd-stack" on:click={() => (isResourcesOpen = true)}> <Info icon="hdd-stack" on:click={() => (isResourcesOpen = true)}>
Nodes: {filters.numNodes.from} - {filters.numNodes.to} {#if isNodesModified } Nodes: {filters.numNodes.from} - {filters.numNodes.to} {/if}
{#if isNodesModified && isHwthreadsModified }, {/if}
{#if isHwthreadsModified } HWThreads: {filters.numHWThreads.from} - {filters.numHWThreads.to} {/if}
{#if (isNodesModified || isHwthreadsModified) && isAccsModified }, {/if}
{#if isAccsModified } Accelerators: {filters.numAccelerators.from} - {filters.numAccelerators.to} {/if}
</Info> </Info>
{/if} {/if}
@ -316,6 +325,9 @@
bind:numNodes={filters.numNodes} bind:numNodes={filters.numNodes}
bind:numHWThreads={filters.numHWThreads} bind:numHWThreads={filters.numHWThreads}
bind:numAccelerators={filters.numAccelerators} bind:numAccelerators={filters.numAccelerators}
bind:isNodesModified={isNodesModified}
bind:isHwthreadsModified={isHwthreadsModified}
bind:isAccsModified={isAccsModified}
on:update={() => update()} /> on:update={() => update()} />
<Statistics cluster={filters.cluster} <Statistics cluster={filters.cluster}

View File

@ -9,20 +9,23 @@
dispatch = createEventDispatcher() dispatch = createEventDispatcher()
export let cluster = null export let cluster = null
export let isModified = false
export let isOpen = false export let isOpen = false
export let numNodes = { from: null, to: null } export let numNodes = { from: null, to: null }
export let numHWThreads = { from: null, to: null } export let numHWThreads = { from: null, to: null }
export let numAccelerators = { from: null, to: null } export let numAccelerators = { from: null, to: null }
export let isNodesModified = false
export let isHwthreadsModified = false
export let isAccsModified = false
let pendingNumNodes = numNodes, pendingNumHWThreads = numHWThreads, pendingNumAccelerators = numAccelerators let pendingNumNodes = numNodes, pendingNumHWThreads = numHWThreads, pendingNumAccelerators = numAccelerators
$: isModified = pendingNumNodes.from != numNodes.from || pendingNumNodes.to != numNodes.to
|| pendingNumHWThreads.from != numHWThreads.from || pendingNumHWThreads.to != numHWThreads.to
|| pendingNumAccelerators.from != numAccelerators.from || pendingNumAccelerators.to != numAccelerators.to
const findMaxNumAccels = clusters => clusters.reduce((max, cluster) => Math.max(max, const findMaxNumAccels = clusters => clusters.reduce((max, cluster) => Math.max(max,
cluster.subClusters.reduce((max, sc) => Math.max(max, sc.topology.accelerators?.length || 0), 0)), 0) cluster.subClusters.reduce((max, sc) => Math.max(max, sc.topology.accelerators?.length || 0), 0)), 0)
// Limited to Single-Node Thread Count
const findMaxNumHWTreadsPerNode = clusters => clusters.reduce((max, cluster) => Math.max(max,
cluster.subClusters.reduce((max, sc) => Math.max(max, (sc.threadsPerCore * sc.coresPerSocket * sc.socketsPerNode) || 0), 0)), 0)
// console.log(header) // console.log(header)
let minNumNodes = 1, maxNumNodes = 0, minNumHWThreads = 1, maxNumHWThreads = 0, minNumAccelerators = 0, maxNumAccelerators = 0 let minNumNodes = 1, maxNumNodes = 0, minNumHWThreads = 1, maxNumHWThreads = 0, minNumAccelerators = 0, maxNumAccelerators = 0
$: { $: {
@ -33,11 +36,13 @@
minNumNodes = filterRanges.numNodes.from minNumNodes = filterRanges.numNodes.from
maxNumNodes = filterRanges.numNodes.to maxNumNodes = filterRanges.numNodes.to
maxNumAccelerators = findMaxNumAccels([{ subClusters }]) maxNumAccelerators = findMaxNumAccels([{ subClusters }])
maxNumHWThreads = findMaxNumHWTreadsPerNode([{ subClusters }])
} else if (clusters.length > 0) { } else if (clusters.length > 0) {
const { filterRanges } = header.clusters[0] const { filterRanges } = header.clusters[0]
minNumNodes = filterRanges.numNodes.from minNumNodes = filterRanges.numNodes.from
maxNumNodes = filterRanges.numNodes.to maxNumNodes = filterRanges.numNodes.to
maxNumAccelerators = findMaxNumAccels(clusters) maxNumAccelerators = findMaxNumAccels(clusters)
maxNumHWThreads = findMaxNumHWTreadsPerNode(clusters)
for (let cluster of header.clusters) { for (let cluster of header.clusters) {
const { filterRanges } = cluster const { filterRanges } = cluster
minNumNodes = Math.min(minNumNodes, filterRanges.numNodes.from) minNumNodes = Math.min(minNumNodes, filterRanges.numNodes.from)
@ -52,27 +57,53 @@
pendingNumNodes = { from: 0, to: maxNumNodes } pendingNumNodes = { from: 0, to: maxNumNodes }
} }
} }
$: {
if (isOpen && $initialized && ((pendingNumHWThreads.from == null && pendingNumHWThreads.to == null) || (isHwthreadsModified == false))) {
pendingNumHWThreads = { from: 0, to: maxNumHWThreads }
}
}
$: if ( maxNumAccelerators != null && maxNumAccelerators > 1 ) {
if (isOpen && $initialized && pendingNumAccelerators.from == null && pendingNumAccelerators.to == null) {
pendingNumAccelerators = { from: 0, to: maxNumAccelerators }
}
}
</script> </script>
<Modal isOpen={isOpen} toggle={() => (isOpen = !isOpen)}> <Modal isOpen={isOpen} toggle={() => (isOpen = !isOpen)}>
<ModalHeader> <ModalHeader>
Select Number of Nodes, HWThreads and Accelerators Select number of utilized Resources
</ModalHeader> </ModalHeader>
<ModalBody> <ModalBody>
<h4>Number of Nodes</h4> <h6>Number of Nodes</h6>
<DoubleRangeSlider <DoubleRangeSlider
on:change={({ detail }) => (pendingNumNodes = { from: detail[0], to: detail[1] })} on:change={({ detail }) => {
pendingNumNodes = { from: detail[0], to: detail[1] }
isNodesModified = true
}}
min={minNumNodes} max={maxNumNodes} min={minNumNodes} max={maxNumNodes}
firstSlider={pendingNumNodes.from} secondSlider={pendingNumNodes.to} /> firstSlider={pendingNumNodes.from} secondSlider={pendingNumNodes.to}
<!-- <DoubleRangeSlider inputFieldFrom={pendingNumNodes.from} inputFieldTo={pendingNumNodes.to}/>
on:change={({ detail }) => (pendingNumHWThreads = { from: detail[0], to: detail[1] })} <h6 style="margin-top: 1rem;">Number of HWThreads (Use for Single-Node Jobs)</h6>
min={minNumHWThreads} max={maxNumHWThreads}
firstSlider={pendingNumHWThreads.from} secondSlider={pendingNumHWThreads.to} /> -->
{#if maxNumAccelerators != null && maxNumAccelerators > 1}
<DoubleRangeSlider <DoubleRangeSlider
on:change={({ detail }) => (pendingNumAccelerators = { from: detail[0], to: detail[1] })} on:change={({ detail }) => {
pendingNumHWThreads = { from: detail[0], to: detail[1] }
isHwthreadsModified = true
}}
min={minNumHWThreads} max={maxNumHWThreads}
firstSlider={pendingNumHWThreads.from} secondSlider={pendingNumHWThreads.to}
inputFieldFrom={pendingNumHWThreads.from} inputFieldTo={pendingNumHWThreads.to}/>
{#if maxNumAccelerators != null && maxNumAccelerators > 1}
<h6 style="margin-top: 1rem;">Number of Accelerators</h6>
<DoubleRangeSlider
on:change={({ detail }) => {
pendingNumAccelerators = { from: detail[0], to: detail[1] }
isAccsModified = true
}}
min={minNumAccelerators} max={maxNumAccelerators} min={minNumAccelerators} max={maxNumAccelerators}
firstSlider={pendingNumAccelerators.from} secondSlider={pendingNumAccelerators.to} /> firstSlider={pendingNumAccelerators.from} secondSlider={pendingNumAccelerators.to}
inputFieldFrom={pendingNumAccelerators.from} inputFieldTo={pendingNumAccelerators.to}/>
{/if} {/if}
</ModalBody> </ModalBody>
<ModalFooter> <ModalFooter>
@ -80,6 +111,9 @@
disabled={pendingNumNodes.from == null || pendingNumNodes.to == null} disabled={pendingNumNodes.from == null || pendingNumNodes.to == null}
on:click={() => { on:click={() => {
isOpen = false isOpen = false
pendingNumNodes = isNodesModified ? pendingNumNodes : { from: null, to: null }
pendingNumHWThreads = isHwthreadsModified ? pendingNumHWThreads : { from: null, to: null }
pendingNumAccelerators = isAccsModified ? pendingNumAccelerators : { from: null, to: null }
numNodes ={ from: pendingNumNodes.from, to: pendingNumNodes.to } numNodes ={ from: pendingNumNodes.from, to: pendingNumNodes.to }
numHWThreads = { from: pendingNumHWThreads.from, to: pendingNumHWThreads.to } numHWThreads = { from: pendingNumHWThreads.from, to: pendingNumHWThreads.to }
numAccelerators = { from: pendingNumAccelerators.from, to: pendingNumAccelerators.to } numAccelerators = { from: pendingNumAccelerators.from, to: pendingNumAccelerators.to }
@ -95,6 +129,9 @@
numNodes = { from: pendingNumNodes.from, to: pendingNumNodes.to } numNodes = { from: pendingNumNodes.from, to: pendingNumNodes.to }
numHWThreads = { from: pendingNumHWThreads.from, to: pendingNumHWThreads.to } numHWThreads = { from: pendingNumHWThreads.from, to: pendingNumHWThreads.to }
numAccelerators = { from: pendingNumAccelerators.from, to: pendingNumAccelerators.to } numAccelerators = { from: pendingNumAccelerators.from, to: pendingNumAccelerators.to }
isNodesModified = false
isHwthreadsModified = false
isAccsModified = false
dispatch('update', { numNodes, numHWThreads, numAccelerators }) dispatch('update', { numNodes, numHWThreads, numAccelerators })
}}>Reset</Button> }}>Reset</Button>
<Button on:click={() => (isOpen = false)}>Close</Button> <Button on:click={() => (isOpen = false)}>Close</Button>

View File

@ -93,7 +93,8 @@
<DoubleRangeSlider <DoubleRangeSlider
on:change={({ detail }) => (stat.from = detail[0], stat.to = detail[1], stat.enabled = true)} on:change={({ detail }) => (stat.from = detail[0], stat.to = detail[1], stat.enabled = true)}
min={0} max={stat.peak} min={0} max={stat.peak}
firstSlider={stat.from} secondSlider={stat.to} /> firstSlider={stat.from} secondSlider={stat.to}
inputFieldFrom={stat.from} inputFieldTo={stat.to}/>
{/each} {/each}
</ModalBody> </ModalBody>
<ModalFooter> <ModalFooter>
@ -104,7 +105,8 @@
}}>Close & Apply</Button> }}>Close & Apply</Button>
<Button color="danger" on:click={() => { <Button color="danger" on:click={() => {
isOpen = false isOpen = false
statistics.forEach(stat => stat.enabled = false) resetRange($initialized, cluster)
statistics.forEach(stat => (stat.enabled = false))
stats = [] stats = []
dispatch('update', { stats }) dispatch('update', { stats })
}}>Reset</Button> }}>Reset</Button>

View File

@ -196,7 +196,7 @@
</style> </style>
<script context="module"> <script context="module">
import { formatNumber } from '../utils.js' import { formatNumber } from '../units.js'
export function binsFromFootprint(weights, values, numBins) { export function binsFromFootprint(weights, values, numBins) {
let min = 0, max = 0 let min = 0, max = 0

View File

@ -22,7 +22,7 @@
--> -->
<script> <script>
import uPlot from 'uplot' import uPlot from 'uplot'
import { formatNumber } from '../utils.js' import { formatNumber } from '../units.js'
import { getContext, onMount, onDestroy } from 'svelte' import { getContext, onMount, onDestroy } from 'svelte'
export let width export let width
@ -312,7 +312,9 @@
</script> </script>
<!--Add empty series warning card-->
<div bind:this={plotWrapper} class="cc-plot"></div> <div bind:this={plotWrapper} class="cc-plot"></div>
<style> <style>
.cc-plot { .cc-plot {
border-radius: 5px; border-radius: 5px;

View File

@ -46,16 +46,6 @@
} }
} }
const power = [1, 1e3, 1e6, 1e9, 1e12]
const suffix = ['', 'k', 'm', 'g']
function formatNumber(x) {
for (let i = 0; i < suffix.length; i++)
if (power[i] <= x && x < power[i+1])
return `${x / power[i]}${suffix[i]}`
return Math.abs(x) >= 1000 ? x.toExponential() : x.toString()
}
function axisStepFactor(i, size) { function axisStepFactor(i, size) {
if (size && size < 500) if (size && size < 500)
return 10 return 10
@ -307,6 +297,7 @@
<script> <script>
import { onMount, tick } from 'svelte' import { onMount, tick } from 'svelte'
import { formatNumber } from '../units.js'
export let flopsAny = null export let flopsAny = null
export let memBw = null export let memBw = null

View File

@ -3,7 +3,7 @@
</div> </div>
<script context="module"> <script context="module">
import { formatNumber } from '../utils.js' import { formatNumber } from '../units.js'
const axesColor = '#aaaaaa' const axesColor = '#aaaaaa'
const fontSize = 12 const fontSize = 12

29
web/frontend/src/units.js Normal file
View File

@ -0,0 +1,29 @@
/*
Collect Functions for Unit Handling and Scaling Here
*/
const power = [1, 1e3, 1e6, 1e9, 1e12, 1e15, 1e18, 1e21]
const prefix = ['', 'K', 'M', 'G', 'T', 'P', 'E']
export function formatNumber(x) {
for (let i = 0; i < prefix.length; i++)
if (power[i] <= x && x < power[i+1])
return `${Math.round((x / power[i]) * 100) / 100} ${prefix[i]}`
return Math.abs(x) >= 1000 ? x.toExponential() : x.toString()
}
export function scaleNumbers(x, y , p = '') {
const oldPower = power[prefix.indexOf(p)]
const rawXValue = x * oldPower
const rawYValue = y * oldPower
for (let i = 0; i < prefix.length; i++) {
if (power[i] <= rawYValue && rawYValue < power[i+1]) {
return `${Math.round((rawXValue / power[i]) * 100) / 100} / ${Math.round((rawYValue / power[i]) * 100) / 100} ${prefix[i]}`
}
}
return Math.abs(rawYValue) >= 1000 ? `${rawXValue.toExponential()} / ${rawYValue.toExponential()}` : `${rawYValue.toString()} / ${rawYValue.toString()}`
}

View File

@ -123,22 +123,6 @@ export function init(extraInitQuery = "") {
}; };
} }
export function formatNumber(x) {
let suffix = "";
if (x >= 1000000000) {
x /= 1000000;
suffix = "G";
} else if (x >= 1000000) {
x /= 1000000;
suffix = "M";
} else if (x >= 1000) {
x /= 1000;
suffix = "k";
}
return `${Math.round(x * 100) / 100} ${suffix}`;
}
// Use https://developer.mozilla.org/en-US/docs/Web/API/structuredClone instead? // Use https://developer.mozilla.org/en-US/docs/Web/API/structuredClone instead?
export function deepCopy(x) { export function deepCopy(x) {
return JSON.parse(JSON.stringify(x)); return JSON.parse(JSON.stringify(x));