diff --git a/.gitignore b/.gitignore index e757ce7..36b051f 100644 --- a/.gitignore +++ b/.gitignore @@ -3,7 +3,6 @@ /var/job-archive /var/*.db /var/machine-state -/var-backup /.env /config.json diff --git a/internal/api/rest.go b/internal/api/rest.go index ce57ebf..a8bb79d 100644 --- a/internal/api/rest.go +++ b/internal/api/rest.go @@ -178,7 +178,7 @@ func decode(r io.Reader, val interface{}) error { // @router /jobs/ [get] func (api *RestApi) getJobs(rw http.ResponseWriter, r *http.Request) { if user := auth.GetUser(r.Context()); user != nil && !user.HasRole(auth.RoleApi) { - handleError(fmt.Errorf("missing role: %v", auth.RoleApi), http.StatusForbidden, rw) + handleError(fmt.Errorf("missing role: %v", auth.GetRoleString(auth.RoleApi)), http.StatusForbidden, rw) return } @@ -318,7 +318,7 @@ func (api *RestApi) getJobs(rw http.ResponseWriter, r *http.Request) { // @router /jobs/tag_job/{id} [post] func (api *RestApi) tagJob(rw http.ResponseWriter, r *http.Request) { if user := auth.GetUser(r.Context()); user != nil && !user.HasRole(auth.RoleApi) { - handleError(fmt.Errorf("missing role: %v", auth.RoleApi), http.StatusForbidden, rw) + handleError(fmt.Errorf("missing role: %v", auth.GetRoleString(auth.RoleApi)), http.StatusForbidden, rw) return } @@ -383,7 +383,7 @@ func (api *RestApi) tagJob(rw http.ResponseWriter, r *http.Request) { // @router /jobs/start_job/ [post] func (api *RestApi) startJob(rw http.ResponseWriter, r *http.Request) { if user := auth.GetUser(r.Context()); user != nil && !user.HasRole(auth.RoleApi) { - handleError(fmt.Errorf("missing role: %v", auth.RoleApi), http.StatusForbidden, rw) + handleError(fmt.Errorf("missing role: %v", auth.GetRoleString(auth.RoleApi)), http.StatusForbidden, rw) return } @@ -464,7 +464,7 @@ func (api *RestApi) startJob(rw http.ResponseWriter, r *http.Request) { // @router /jobs/stop_job/{id} [post] func (api *RestApi) stopJobById(rw http.ResponseWriter, r *http.Request) { if user := auth.GetUser(r.Context()); user != nil && !user.HasRole(auth.RoleApi) { - handleError(fmt.Errorf("missing role: %v", auth.RoleApi), http.StatusForbidden, rw) + handleError(fmt.Errorf("missing role: %v", auth.GetRoleString(auth.RoleApi)), http.StatusForbidden, rw) return } @@ -517,7 +517,7 @@ func (api *RestApi) stopJobById(rw http.ResponseWriter, r *http.Request) { // @router /jobs/stop_job/ [post] func (api *RestApi) stopJobByRequest(rw http.ResponseWriter, r *http.Request) { if user := auth.GetUser(r.Context()); user != nil && !user.HasRole(auth.RoleApi) { - handleError(fmt.Errorf("missing role: %v", auth.RoleApi), http.StatusForbidden, rw) + handleError(fmt.Errorf("missing role: %v", auth.GetRoleString(auth.RoleApi)), http.StatusForbidden, rw) return } @@ -563,7 +563,7 @@ func (api *RestApi) stopJobByRequest(rw http.ResponseWriter, r *http.Request) { // @router /jobs/delete_job/{id} [delete] func (api *RestApi) deleteJobById(rw http.ResponseWriter, r *http.Request) { if user := auth.GetUser(r.Context()); user != nil && !user.HasRole(auth.RoleApi) { - handleError(fmt.Errorf("missing role: %v", auth.RoleApi), http.StatusForbidden, rw) + handleError(fmt.Errorf("missing role: %v", auth.GetRoleString(auth.RoleApi)), http.StatusForbidden, rw) return } @@ -611,7 +611,7 @@ func (api *RestApi) deleteJobById(rw http.ResponseWriter, r *http.Request) { // @router /jobs/delete_job/ [delete] func (api *RestApi) deleteJobByRequest(rw http.ResponseWriter, r *http.Request) { if user := auth.GetUser(r.Context()); user != nil && !user.HasRole(auth.RoleApi) { - handleError(fmt.Errorf("missing role: %v", auth.RoleApi), http.StatusForbidden, rw) + handleError(fmt.Errorf("missing role: %v", auth.GetRoleString(auth.RoleApi)), http.StatusForbidden, rw) return } @@ -667,7 +667,7 @@ func (api *RestApi) deleteJobByRequest(rw http.ResponseWriter, r *http.Request) // @router /jobs/delete_job_before/{ts} [delete] func (api *RestApi) deleteJobBefore(rw http.ResponseWriter, r *http.Request) { if user := auth.GetUser(r.Context()); user != nil && !user.HasRole(auth.RoleApi) { - handleError(fmt.Errorf("missing role: %v", auth.RoleApi), http.StatusForbidden, rw) + handleError(fmt.Errorf("missing role: %v", auth.GetRoleString(auth.RoleApi)), http.StatusForbidden, rw) return } @@ -819,15 +819,15 @@ func (api *RestApi) createUser(rw http.ResponseWriter, r *http.Request) { } username, password, role, name, email, project := r.FormValue("username"), r.FormValue("password"), r.FormValue("role"), r.FormValue("name"), r.FormValue("email"), r.FormValue("project") - if len(password) == 0 && role != auth.RoleApi { + if len(password) == 0 && role != auth.GetRoleString(auth.RoleApi) { http.Error(rw, "Only API users are allowed to have a blank password (login will be impossible)", http.StatusBadRequest) return } - if len(project) != 0 && role != auth.RoleManager { + if len(project) != 0 && role != auth.GetRoleString(auth.RoleManager) { http.Error(rw, "only managers require a project (can be changed later)", http.StatusBadRequest) return - } else if len(project) == 0 && role == auth.RoleManager { + } else if len(project) == 0 && role == auth.GetRoleString(auth.RoleManager) { http.Error(rw, "managers require a project to manage (can be changed later)", http.StatusBadRequest) return } diff --git a/internal/auth/auth.go b/internal/auth/auth.go index e080627..4db4737 100644 --- a/internal/auth/auth.go +++ b/internal/auth/auth.go @@ -12,6 +12,7 @@ import ( "fmt" "net/http" "os" + "strings" "time" "github.com/ClusterCockpit/cc-backend/pkg/log" @@ -19,36 +20,83 @@ import ( "github.com/jmoiron/sqlx" ) -const ( - RoleAdmin string = "admin" - RoleSupport string = "support" - RoleManager string = "manager" - RoleUser string = "user" - RoleApi string = "api" -) - -var validRoles = [5]string{RoleUser, RoleManager, RoleSupport, RoleAdmin, RoleApi} +type AuthSource int const ( - AuthViaLocalPassword int8 = 0 - AuthViaLDAP int8 = 1 - AuthViaToken int8 = 2 + AuthViaLocalPassword AuthSource = iota + AuthViaLDAP + AuthViaToken ) type User struct { - Username string `json:"username"` - Password string `json:"-"` - Name string `json:"name"` - Roles []string `json:"roles"` - AuthSource int8 `json:"via"` - Email string `json:"email"` - Projects []string `json:"projects"` + Username string `json:"username"` + Password string `json:"-"` + Name string `json:"name"` + Roles []string `json:"roles"` + AuthSource AuthSource `json:"via"` + Email string `json:"email"` + Projects []string `json:"projects"` Expiration time.Time } -func (u *User) HasRole(role string) bool { +type Role int + +const ( + RoleAnonymous Role = iota + RoleApi + RoleUser + RoleManager + RoleSupport + RoleAdmin + RoleError +) + +func GetRoleString(roleInt Role) string { + return [6]string{"anonymous", "api", "user", "manager", "support", "admin"}[roleInt] +} + +func getRoleEnum(roleStr string) Role { + switch strings.ToLower(roleStr) { + case "admin": + return RoleAdmin + case "support": + return RoleSupport + case "manager": + return RoleManager + case "user": + return RoleUser + case "api": + return RoleApi + case "anonymous": + return RoleAnonymous + default: + return RoleError + } +} + +func isValidRole(role string) bool { + if getRoleEnum(role) == RoleError { + log.Errorf("Unknown Role %s", role) + return false + } + return true +} + +func (u *User) HasValidRole(role string) (hasRole bool, isValid bool) { + if isValidRole(role) { + for _, r := range u.Roles { + if r == role { + return true, true + } + } + return false, true + } + return false, false +} + +func (u *User) HasRole(role Role) bool { for _, r := range u.Roles { - if r == role { + if r == GetRoleString(role) { return true } } @@ -56,10 +104,10 @@ func (u *User) HasRole(role string) bool { } // Role-Arrays are short: performance not impacted by nested loop -func (u *User) HasAnyRole(queryroles []string) bool { +func (u *User) HasAnyRole(queryroles []Role) bool { for _, ur := range u.Roles { for _, qr := range queryroles { - if ur == qr { + if ur == GetRoleString(qr) { return true } } @@ -68,12 +116,12 @@ func (u *User) HasAnyRole(queryroles []string) bool { } // Role-Arrays are short: performance not impacted by nested loop -func (u *User) HasAllRoles(queryroles []string) bool { +func (u *User) HasAllRoles(queryroles []Role) bool { target := len(queryroles) matches := 0 for _, ur := range u.Roles { for _, qr := range queryroles { - if ur == qr { + if ur == GetRoleString(qr) { matches += 1 break } @@ -88,11 +136,11 @@ func (u *User) HasAllRoles(queryroles []string) bool { } // Role-Arrays are short: performance not impacted by nested loop -func (u *User) HasNotRoles(queryroles []string) bool { +func (u *User) HasNotRoles(queryroles []Role) bool { matches := 0 for _, ur := range u.Roles { for _, qr := range queryroles { - if ur == qr { + if ur == GetRoleString(qr) { matches += 1 break } @@ -106,20 +154,47 @@ func (u *User) HasNotRoles(queryroles []string) bool { } } -// Find highest role, returns integer -func (u *User) GetAuthLevel() int { +// Called by API endpoint '/roles/' from frontend: Only required for admin config -> Check Admin Role +func GetValidRoles(user *User) ([]string, error) { + var vals []string + if user.HasRole(RoleAdmin) { + for i := RoleApi; i < RoleError; i++ { + vals = append(vals, GetRoleString(i)) + } + return vals, nil + } + + return vals, fmt.Errorf("%s: only admins are allowed to fetch a list of roles", user.Username) +} + +// Called by routerConfig web.page setup in backend: Only requires known user and/or not API user +func GetValidRolesMap(user *User) (map[string]Role, error) { + named := make(map[string]Role) + if user.HasNotRoles([]Role{RoleApi, RoleAnonymous}) { + for i := RoleApi; i < RoleError; i++ { + named[GetRoleString(i)] = i + } + return named, nil + } + return named, fmt.Errorf("Only known users are allowed to fetch a list of roles") +} + +// Find highest role +func (u *User) GetAuthLevel() Role { if u.HasRole(RoleAdmin) { - return 5 + return RoleAdmin } else if u.HasRole(RoleSupport) { - return 4 + return RoleSupport } else if u.HasRole(RoleManager) { - return 3 + return RoleManager } else if u.HasRole(RoleUser) { - return 2 + return RoleUser } else if u.HasRole(RoleApi) { - return 1 + return RoleApi + } else if u.HasRole(RoleAnonymous) { + return RoleAnonymous } else { - return 0 + return RoleError } } @@ -132,24 +207,6 @@ func (u *User) HasProject(project string) bool { return false } -func IsValidRole(role string) bool { - for _, r := range validRoles { - if r == role { - return true - } - } - return false -} - -func GetValidRoles(user *User) ([5]string, error) { - var vals [5]string - if !user.HasRole(RoleAdmin) { - return vals, fmt.Errorf("%s: only admins are allowed to fetch a list of roles", user.Username) - } else { - return validRoles, nil - } -} - func GetUser(ctx context.Context) *User { x := ctx.Value(ContextUserKey) if x == nil { diff --git a/internal/auth/jwt.go b/internal/auth/jwt.go index 64fe671..7a80c1c 100644 --- a/internal/auth/jwt.go +++ b/internal/auth/jwt.go @@ -146,12 +146,16 @@ func (ja *JWTAuthenticator) Login( if rawroles, ok := claims["roles"].([]interface{}); ok { for _, rr := range rawroles { if r, ok := rr.(string); ok { - roles = append(roles, r) + if isValidRole(r) { + roles = append(roles, r) + } } } } if rawrole, ok := claims["roles"].(string); ok { - roles = append(roles, rawrole) + if isValidRole(rawrole) { + roles = append(roles, rawrole) + } } if user == nil { diff --git a/internal/auth/ldap.go b/internal/auth/ldap.go index 455f393..672f10d 100644 --- a/internal/auth/ldap.go +++ b/internal/auth/ldap.go @@ -164,7 +164,7 @@ func (la *LdapAuthenticator) Sync() error { name := newnames[username] log.Debugf("sync: add %v (name: %v, roles: [user], ldap: true)", username, name) if _, err := la.auth.db.Exec(`INSERT INTO user (username, ldap, name, roles) VALUES (?, ?, ?, ?)`, - username, 1, name, "[\""+RoleUser+"\"]"); err != nil { + username, 1, name, "[\""+GetRoleString(RoleUser)+"\"]"); err != nil { log.Errorf("User '%s' new in LDAP: Insert into DB failed", username) return err } diff --git a/internal/auth/users.go b/internal/auth/users.go index 38c1e11..b69533b 100644 --- a/internal/auth/users.go +++ b/internal/auth/users.go @@ -10,6 +10,7 @@ import ( "encoding/json" "errors" "fmt" + "strings" "github.com/ClusterCockpit/cc-backend/internal/graph/model" "github.com/ClusterCockpit/cc-backend/pkg/log" @@ -133,23 +134,25 @@ func (auth *Authentication) ListUsers(specialsOnly bool) ([]*User, error) { func (auth *Authentication) AddRole( ctx context.Context, username string, - role string) error { + queryrole string) error { + newRole := strings.ToLower(queryrole) user, err := auth.GetUser(username) if err != nil { log.Warnf("Could not load user '%s'", username) return err } - if !IsValidRole(role) { - return fmt.Errorf("Invalid user role: %v", role) + exists, valid := user.HasValidRole(newRole) + + if !valid { + return fmt.Errorf("Supplied role is no valid option : %v", newRole) + } + if exists { + return fmt.Errorf("User %v already has role %v", username, newRole) } - if user.HasRole(role) { - return fmt.Errorf("user %#v already has role %#v", username, role) - } - - roles, _ := json.Marshal(append(user.Roles, role)) + roles, _ := json.Marshal(append(user.Roles, newRole)) if _, err := sq.Update("user").Set("roles", roles).Where("user.username = ?", username).RunWith(auth.db).Exec(); err != nil { log.Errorf("Error while adding new role for user '%s'", user.Username) return err @@ -157,41 +160,40 @@ func (auth *Authentication) AddRole( return nil } -func (auth *Authentication) RemoveRole(ctx context.Context, username string, role string) error { +func (auth *Authentication) RemoveRole(ctx context.Context, username string, queryrole string) error { + oldRole := strings.ToLower(queryrole) user, err := auth.GetUser(username) if err != nil { log.Warnf("Could not load user '%s'", username) return err } - if !IsValidRole(role) { - return fmt.Errorf("Invalid user role: %#v", role) + exists, valid := user.HasValidRole(oldRole) + + if !valid { + return fmt.Errorf("Supplied role is no valid option : %v", oldRole) + } + if !exists { + return fmt.Errorf("Role already deleted for user '%v': %v", username, oldRole) } - if role == RoleManager && len(user.Projects) != 0 { + if oldRole == GetRoleString(RoleManager) && len(user.Projects) != 0 { return fmt.Errorf("Cannot remove role 'manager' while user %s still has assigned project(s) : %v", username, user.Projects) } - var exists bool var newroles []string for _, r := range user.Roles { - if r != role { + if r != oldRole { newroles = append(newroles, r) // Append all roles not matching requested to be deleted role - } else { - exists = true } } - if exists == true { - var mroles, _ = json.Marshal(newroles) - if _, err := sq.Update("user").Set("roles", mroles).Where("user.username = ?", username).RunWith(auth.db).Exec(); err != nil { - log.Errorf("Error while removing role for user '%s'", user.Username) - return err - } - return nil - } else { - return fmt.Errorf("User '%v' already does not have role: %v", username, role) + var mroles, _ = json.Marshal(newroles) + if _, err := sq.Update("user").Set("roles", mroles).Where("user.username = ?", username).RunWith(auth.db).Exec(); err != nil { + log.Errorf("Error while removing role for user '%s'", user.Username) + return err } + return nil } func (auth *Authentication) AddProject( @@ -262,7 +264,7 @@ func (auth *Authentication) RemoveProject(ctx context.Context, username string, func FetchUser(ctx context.Context, db *sqlx.DB, username string) (*model.User, error) { me := GetUser(ctx) - if me != nil && me.Username != username && me.HasNotRoles([]string{RoleAdmin, RoleSupport, RoleManager}) { + if me != nil && me.Username != username && me.HasNotRoles([]Role{RoleAdmin, RoleSupport, RoleManager}) { return nil, errors.New("forbidden") } diff --git a/internal/graph/schema.resolvers.go b/internal/graph/schema.resolvers.go index 7e55afd..c119763 100644 --- a/internal/graph/schema.resolvers.go +++ b/internal/graph/schema.resolvers.go @@ -170,7 +170,7 @@ func (r *queryResolver) Job(ctx context.Context, id string) (*schema.Job, error) return nil, err } - if user := auth.GetUser(ctx); user != nil && job.User != user.Username && user.HasNotRoles([]string{auth.RoleAdmin, auth.RoleSupport, auth.RoleManager}) { + if user := auth.GetUser(ctx); user != nil && job.User != user.Username && user.HasNotRoles([]auth.Role{auth.RoleAdmin, auth.RoleSupport, auth.RoleManager}) { return nil, errors.New("you are not allowed to see this job") } diff --git a/internal/repository/job.go b/internal/repository/job.go index 2545362..2fcb554 100644 --- a/internal/repository/job.go +++ b/internal/repository/job.go @@ -534,7 +534,7 @@ func (r *JobRepository) FindColumnValue(user *auth.User, searchterm string, tabl compareStr = " LIKE ?" query = "%" + searchterm + "%" } - if user.HasAnyRole([]string{auth.RoleAdmin, auth.RoleSupport, auth.RoleManager}) { + if user.HasAnyRole([]auth.Role{auth.RoleAdmin, auth.RoleSupport, auth.RoleManager}) { err := sq.Select(table+"."+selectColumn).Distinct().From(table). Where(table+"."+whereColumn+compareStr, query). RunWith(r.stmtCache).QueryRow().Scan(&result) @@ -552,7 +552,7 @@ func (r *JobRepository) FindColumnValue(user *auth.User, searchterm string, tabl func (r *JobRepository) FindColumnValues(user *auth.User, query string, table string, selectColumn string, whereColumn string) (results []string, err error) { emptyResult := make([]string, 0) - if user.HasAnyRole([]string{auth.RoleAdmin, auth.RoleSupport, auth.RoleManager}) { + if user.HasAnyRole([]auth.Role{auth.RoleAdmin, auth.RoleSupport, auth.RoleManager}) { rows, err := sq.Select(table+"."+selectColumn).Distinct().From(table). Where(table+"."+whereColumn+" LIKE ?", fmt.Sprint("%", query, "%")). RunWith(r.stmtCache).Query() diff --git a/internal/repository/query.go b/internal/repository/query.go index 571ec93..fd90faf 100644 --- a/internal/repository/query.go +++ b/internal/repository/query.go @@ -104,7 +104,7 @@ func (r *JobRepository) CountJobs( func SecurityCheck(ctx context.Context, query sq.SelectBuilder) (queryOut sq.SelectBuilder, err error) { user := auth.GetUser(ctx) - if user == nil || user.HasAnyRole([]string{auth.RoleAdmin, auth.RoleSupport, auth.RoleApi}) { // Admin & Co. : All jobs + if user == nil || user.HasAnyRole([]auth.Role{auth.RoleAdmin, auth.RoleSupport, auth.RoleApi}) { // Admin & Co. : All jobs return query, nil } else if user.HasRole(auth.RoleManager) { // Manager : Add filter for managed projects' jobs only + personal jobs if len(user.Projects) != 0 { diff --git a/internal/routerConfig/routes.go b/internal/routerConfig/routes.go index 4f4e47d..0efe4d8 100644 --- a/internal/routerConfig/routes.go +++ b/internal/routerConfig/routes.go @@ -276,16 +276,15 @@ func SetupRoutes(router *mux.Router, version string, hash string, buildTime stri title = strings.Replace(route.Title, "", id.(string), 1) } - username, authLevel := "", 0 - - if user := auth.GetUser(r.Context()); user != nil { - username = user.Username - authLevel = user.GetAuthLevel() - } + // Get User -> What if NIL? + user := auth.GetUser(r.Context()) + // Get Roles + availableRoles, _ := auth.GetValidRolesMap(user) page := web.Page{ Title: title, - User: web.User{Username: username, AuthLevel: authLevel}, + User: *user, + Roles: availableRoles, Build: web.Build{Version: version, Hash: hash, Buildtime: buildTime}, Config: conf, Infos: infos, @@ -314,7 +313,7 @@ func HandleSearchBar(rw http.ResponseWriter, r *http.Request, api *api.RestApi) case "projectId": http.Redirect(rw, r, "/monitoring/jobs/?projectMatch=eq&project="+url.QueryEscape(strings.Trim(splitSearch[1], " ")), http.StatusTemporaryRedirect) // All Users: Redirect to Tablequery case "username": - if user.HasAnyRole([]string{auth.RoleAdmin, auth.RoleSupport, auth.RoleManager}) { + if user.HasAnyRole([]auth.Role{auth.RoleAdmin, auth.RoleSupport, auth.RoleManager}) { http.Redirect(rw, r, "/monitoring/users/?user="+url.QueryEscape(strings.Trim(splitSearch[1], " ")), http.StatusTemporaryRedirect) } else { http.Redirect(rw, r, "/monitoring/jobs/?", http.StatusTemporaryRedirect) // Users: Redirect to Tablequery @@ -325,7 +324,7 @@ func HandleSearchBar(rw http.ResponseWriter, r *http.Request, api *api.RestApi) joinedNames := strings.Join(usernames, "&user=") http.Redirect(rw, r, "/monitoring/users/?user="+joinedNames, http.StatusTemporaryRedirect) } else { - if user.HasAnyRole([]string{auth.RoleAdmin, auth.RoleSupport, auth.RoleManager}) { + if user.HasAnyRole([]auth.Role{auth.RoleAdmin, auth.RoleSupport, auth.RoleManager}) { http.Redirect(rw, r, "/monitoring/users/?user=NoUserNameFound", http.StatusTemporaryRedirect) } else { http.Redirect(rw, r, "/monitoring/jobs/?", http.StatusTemporaryRedirect) // Users: Redirect to Tablequery diff --git a/web/frontend/src/Config.root.svelte b/web/frontend/src/Config.root.svelte index 910455f..6df579f 100644 --- a/web/frontend/src/Config.root.svelte +++ b/web/frontend/src/Config.root.svelte @@ -10,11 +10,11 @@ const ccconfig = getContext('cc-config') - export let user + export let isAdmin -{#if user.AuthLevel == 5} +{#if isAdmin == true} Admin Options diff --git a/web/frontend/src/Header.svelte b/web/frontend/src/Header.svelte index 3fbed14..227a9d4 100644 --- a/web/frontend/src/Header.svelte +++ b/web/frontend/src/Header.svelte @@ -4,8 +4,9 @@ Dropdown, DropdownToggle, DropdownMenu, DropdownItem, InputGroupText } from 'sveltestrap' export let username // empty string if auth. is disabled, otherwise the username as string - export let authlevel // integer + export let authlevel // Integer export let clusters // array of names + export let roles // Role Enum-Like let isOpen = false @@ -39,9 +40,9 @@ ] const viewsPerCluster = [ - { title: 'Analysis', authLevel: 4, href: '/monitoring/analysis/', icon: 'graph-up' }, - { title: 'Systems', authLevel: 5, href: '/monitoring/systems/', icon: 'cpu' }, - { title: 'Status', authLevel: 5, href: '/monitoring/status/', icon: 'cpu' }, + { title: 'Analysis', requiredRole: roles.support, href: '/monitoring/analysis/', icon: 'graph-up' }, + { title: 'Systems', requiredRole: roles.admin, href: '/monitoring/systems/', icon: 'cpu' }, + { title: 'Status', requiredRole: roles.admin, href: '/monitoring/status/', icon: 'cpu' }, ] @@ -52,26 +53,26 @@ (isOpen = !isOpen)} /> (isOpen = detail.isOpen)}>