Add 'project' to user table, add 'manager' role, conditional web render

- Addresses issues #40 #45 #82
- Reworked Navigation Header for all roles
- 'Manager' role added, can be assigned a project-id in config by admins
- BREAKING! -> Added 'project' column in SQLite3 table 'user'
- Manager-Assigned project will be added to all graphql filters: Only show Jobs and Users of given project
- 'My Jobs' Tab for all Roles
- Switched from Bool "isAdmin" to integer authLevels
- Removed critical data frontend logging
- Reworked repo.query.SecurityCheck()
This commit is contained in:
Christoph Kluge 2023-01-27 18:36:58 +01:00
parent 834f9d9085
commit b2aed2f16b
33 changed files with 433 additions and 92 deletions

View File

@ -160,6 +160,7 @@ type Count {
type User { type User {
username: String! username: String!
name: String! name: String!
project: String
email: String! email: String!
} }

View File

@ -132,7 +132,7 @@ func main() {
} }
if err := authentication.AddUser(&auth.User{ if err := authentication.AddUser(&auth.User{
Username: parts[0], Password: parts[2], Roles: strings.Split(parts[1], ","), Username: parts[0], Project: "", Password: parts[2], Roles: strings.Split(parts[1], ","),
}); err != nil { }); err != nil {
log.Fatal(err) log.Fatal(err)
} }

View File

@ -822,17 +822,26 @@ func (api *RestApi) createUser(rw http.ResponseWriter, r *http.Request) {
return return
} }
username, password, role, name, email := r.FormValue("username"), r.FormValue("password"), r.FormValue("role"), r.FormValue("name"), r.FormValue("email") 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.RoleApi {
http.Error(rw, "only API users are allowed to have a blank password (login will be impossible)", http.StatusBadRequest) http.Error(rw, "only API users are allowed to have a blank password (login will be impossible)", http.StatusBadRequest)
return return
} }
if len(project) != 0 && role != 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) {
http.Error(rw, "managers require a project to manage (can be changed later)", http.StatusBadRequest)
return
}
if err := api.Authentication.AddUser(&auth.User{ if err := api.Authentication.AddUser(&auth.User{
Username: username, Username: username,
Name: name, Name: name,
Password: password, Password: password,
Email: email, Email: email,
Project: project,
Roles: []string{role}}); err != nil { Roles: []string{role}}); err != nil {
http.Error(rw, err.Error(), http.StatusUnprocessableEntity) http.Error(rw, err.Error(), http.StatusUnprocessableEntity)
return return
@ -880,6 +889,8 @@ func (api *RestApi) updateUser(rw http.ResponseWriter, r *http.Request) {
// Get Values // Get Values
newrole := r.FormValue("add-role") newrole := r.FormValue("add-role")
delrole := r.FormValue("remove-role") delrole := r.FormValue("remove-role")
newproj := r.FormValue("add-project")
delproj := r.FormValue("remove-project")
// TODO: Handle anything but roles... // TODO: Handle anything but roles...
if newrole != "" { if newrole != "" {
@ -894,8 +905,20 @@ func (api *RestApi) updateUser(rw http.ResponseWriter, r *http.Request) {
return return
} }
rw.Write([]byte("Remove Role Success")) rw.Write([]byte("Remove Role Success"))
} else if newproj != "" {
if err := api.Authentication.AddProject(r.Context(), mux.Vars(r)["id"], newproj); err != nil {
http.Error(rw, err.Error(), http.StatusUnprocessableEntity)
return
}
rw.Write([]byte("Set Project Success"))
} else if delproj != "" {
if err := api.Authentication.RemoveProject(r.Context(), mux.Vars(r)["id"], delproj); err != nil {
http.Error(rw, err.Error(), http.StatusUnprocessableEntity)
return
}
rw.Write([]byte("Reset Project Success"))
} else { } else {
http.Error(rw, "Not Add or Del?", http.StatusInternalServerError) http.Error(rw, "Not Add or Del [role|project]?", http.StatusInternalServerError)
} }
} }

View File

@ -21,12 +21,12 @@ import (
const ( const (
RoleAdmin string = "admin" RoleAdmin string = "admin"
RoleSupport string = "support" RoleSupport string = "support"
RoleApi string = "api" RoleManager string = "manager"
RoleUser string = "user" RoleUser string = "user"
RoleProject string = "project" RoleApi string = "api"
) )
var validRoles = [5]string{RoleAdmin, RoleSupport, RoleApi, RoleUser, RoleProject} var validRoles = [5]string{RoleUser, RoleManager, RoleSupport, RoleAdmin, RoleApi}
const ( const (
AuthViaLocalPassword int8 = 0 AuthViaLocalPassword int8 = 0
@ -105,6 +105,31 @@ func (u *User) HasNotRoles(queryroles []string) bool {
} }
} }
// Find highest role, returns integer
func (u *User) GetAuthLevel() int {
if (u.HasRole(RoleAdmin)) {
return 5
} else if (u.HasRole(RoleSupport)) {
return 4
} else if (u.HasRole(RoleManager)) {
return 3
} else if (u.HasRole(RoleUser)) {
return 2
} else if (u.HasRole(RoleApi)) {
return 1
} else {
return 0
}
}
func (u *User) HasProject(project string) bool {
if (u.Project != "" && u.Project == project) {
return true
} else {
return false
}
}
func IsValidRole(role string) bool { func IsValidRole(role string) bool {
for _, r := range validRoles { for _, r := range validRoles {
if r == role { if r == role {
@ -156,7 +181,8 @@ func Init(db *sqlx.DB,
ldap tinyint NOT NULL DEFAULT 0, /* col called "ldap" for historic reasons, fills the "AuthSource" */ ldap tinyint NOT NULL DEFAULT 0, /* col called "ldap" for historic reasons, fills the "AuthSource" */
name varchar(255) DEFAULT NULL, name varchar(255) DEFAULT NULL,
roles varchar(255) NOT NULL DEFAULT "[]", roles varchar(255) NOT NULL DEFAULT "[]",
email varchar(255) DEFAULT NULL);`) email varchar(255) DEFAULT NULL,
project varchar(255) DEFAULT NULL);`)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -214,9 +240,11 @@ func (auth *Authentication) AuthViaSession(
} }
username, _ := session.Values["username"].(string) username, _ := session.Values["username"].(string)
project, _ := session.Values["project"].(string)
roles, _ := session.Values["roles"].([]string) roles, _ := session.Values["roles"].([]string)
return &User{ return &User{
Username: username, Username: username,
Project: project,
Roles: roles, Roles: roles,
AuthSource: -1, AuthSource: -1,
}, nil }, nil
@ -261,6 +289,7 @@ func (auth *Authentication) Login(
session.Options.MaxAge = int(auth.SessionMaxAge.Seconds()) session.Options.MaxAge = int(auth.SessionMaxAge.Seconds())
} }
session.Values["username"] = user.Username session.Values["username"] = user.Username
session.Values["project"] = user.Project
session.Values["roles"] = user.Roles session.Values["roles"] = user.Roles
if err := auth.sessionStore.Save(r, rw, session); err != nil { if err := auth.sessionStore.Save(r, rw, session); err != nil {
log.Errorf("session save failed: %s", err.Error()) log.Errorf("session save failed: %s", err.Error())
@ -268,7 +297,7 @@ func (auth *Authentication) Login(
return return
} }
log.Infof("login successfull: user: %#v (roles: %v)", user.Username, user.Roles) log.Infof("login successfull: user: %#v (roles: %v, project: %v)", user.Username, user.Roles, user.Project)
ctx := context.WithValue(r.Context(), ContextUserKey, user) ctx := context.WithValue(r.Context(), ContextUserKey, user)
onsuccess.ServeHTTP(rw, r.WithContext(ctx)) onsuccess.ServeHTTP(rw, r.WithContext(ctx))
return return

View File

@ -21,16 +21,17 @@ import (
func (auth *Authentication) GetUser(username string) (*User, error) { func (auth *Authentication) GetUser(username string) (*User, error) {
user := &User{Username: username} user := &User{Username: username}
var hashedPassword, name, rawRoles, email sql.NullString var hashedPassword, name, rawRoles, email, project sql.NullString
if err := sq.Select("password", "ldap", "name", "roles", "email").From("user"). if err := sq.Select("password", "ldap", "name", "roles", "email", "project").From("user").
Where("user.username = ?", username).RunWith(auth.db). Where("user.username = ?", username).RunWith(auth.db).
QueryRow().Scan(&hashedPassword, &user.AuthSource, &name, &rawRoles, &email); err != nil { QueryRow().Scan(&hashedPassword, &user.AuthSource, &name, &rawRoles, &email, &project); err != nil {
return nil, err return nil, err
} }
user.Password = hashedPassword.String user.Password = hashedPassword.String
user.Name = name.String user.Name = name.String
user.Email = email.String user.Email = email.String
user.Project = project.String
if rawRoles.Valid { if rawRoles.Valid {
if err := json.Unmarshal([]byte(rawRoles.String), &user.Roles); err != nil { if err := json.Unmarshal([]byte(rawRoles.String), &user.Roles); err != nil {
return nil, err return nil, err
@ -54,6 +55,10 @@ func (auth *Authentication) AddUser(user *User) error {
cols = append(cols, "email") cols = append(cols, "email")
vals = append(vals, user.Email) vals = append(vals, user.Email)
} }
if user.Project != "" {
cols = append(cols, "project")
vals = append(vals, user.Project)
}
if user.Password != "" { if user.Password != "" {
password, err := bcrypt.GenerateFromPassword([]byte(user.Password), bcrypt.DefaultCost) password, err := bcrypt.GenerateFromPassword([]byte(user.Password), bcrypt.DefaultCost)
if err != nil { if err != nil {
@ -67,7 +72,7 @@ func (auth *Authentication) AddUser(user *User) error {
return err return err
} }
log.Infof("new user %#v created (roles: %s, auth-source: %d)", user.Username, rolesJson, user.AuthSource) log.Infof("new user %#v created (roles: %s, auth-source: %d, project: %s)", user.Username, rolesJson, user.AuthSource, user.Project)
return nil return nil
} }
@ -79,7 +84,7 @@ func (auth *Authentication) DelUser(username string) error {
func (auth *Authentication) ListUsers(specialsOnly bool) ([]*User, error) { func (auth *Authentication) ListUsers(specialsOnly bool) ([]*User, error) {
q := sq.Select("username", "name", "email", "roles").From("user") q := sq.Select("username", "name", "email", "roles", "project").From("user")
if specialsOnly { if specialsOnly {
q = q.Where("(roles != '[\"user\"]' AND roles != '[]')") q = q.Where("(roles != '[\"user\"]' AND roles != '[]')")
} }
@ -94,8 +99,8 @@ func (auth *Authentication) ListUsers(specialsOnly bool) ([]*User, error) {
for rows.Next() { for rows.Next() {
rawroles := "" rawroles := ""
user := &User{} user := &User{}
var name, email sql.NullString var name, email, project sql.NullString
if err := rows.Scan(&user.Username, &name, &email, &rawroles); err != nil { if err := rows.Scan(&user.Username, &name, &email, &rawroles, &project); err != nil {
return nil, err return nil, err
} }
@ -105,6 +110,7 @@ func (auth *Authentication) ListUsers(specialsOnly bool) ([]*User, error) {
user.Name = name.String user.Name = name.String
user.Email = email.String user.Email = email.String
user.Project = project.String
users = append(users, user) users = append(users, user)
} }
return users, nil return users, nil
@ -145,6 +151,10 @@ func (auth *Authentication) RemoveRole(ctx context.Context, username string, rol
return fmt.Errorf("invalid user role: %#v", role) return fmt.Errorf("invalid user role: %#v", role)
} }
if (role == RoleManager && len(user.Project) != 0) {
return fmt.Errorf("Cannot remove role 'manager' while user %#v still has an assigned project!", username)
}
var exists bool var exists bool
var newroles []string var newroles []string
for _, r := range user.Roles { for _, r := range user.Roles {
@ -166,9 +176,53 @@ func (auth *Authentication) RemoveRole(ctx context.Context, username string, rol
} }
} }
func (auth *Authentication) AddProject(
ctx context.Context,
username string,
project string) error {
user, err := auth.GetUser(username)
if err != nil {
return err
}
if !user.HasRole(RoleManager) {
return fmt.Errorf("user '%#v' is not a manager!", username)
}
if user.HasProject(project) {
return fmt.Errorf("user '%#v' already manages project '%#v'", username, project)
}
if _, err := sq.Update("user").Set("project", project).Where("user.username = ?", username).RunWith(auth.db).Exec(); err != nil {
return err
}
return nil
}
func (auth *Authentication) RemoveProject(ctx context.Context, username string, project string) error {
user, err := auth.GetUser(username)
if err != nil {
return err
}
if !user.HasRole(RoleManager) {
return fmt.Errorf("user '%#v' is not a manager!", username)
}
if !user.HasProject(project) {
return fmt.Errorf("user '%#v': Cannot remove project '%#v' - Does not match!", username, project)
}
if _, err := sq.Update("user").Set("project", "").Where("user.username = ?", username).Where("user.project = ?", project).RunWith(auth.db).Exec(); err != nil {
return err
}
return nil
}
func FetchUser(ctx context.Context, db *sqlx.DB, username string) (*model.User, error) { func FetchUser(ctx context.Context, db *sqlx.DB, username string) (*model.User, error) {
me := GetUser(ctx) me := GetUser(ctx)
if me != nil && me.Username != username && me.HasNotRoles([]string{RoleAdmin, RoleSupport}) { if me != nil && me.Username != username && me.HasNotRoles([]string{RoleAdmin, RoleSupport, RoleManager}) {
return nil, errors.New("forbidden") return nil, errors.New("forbidden")
} }
@ -185,5 +239,6 @@ func FetchUser(ctx context.Context, db *sqlx.DB, username string) (*model.User,
user.Name = name.String user.Name = name.String
user.Email = email.String user.Email = email.String
// user.Project = project.String
return user, nil return user, nil
} }

View File

@ -152,7 +152,7 @@ func (r *queryResolver) Job(ctx context.Context, id string) (*schema.Job, error)
return nil, err return nil, err
} }
if user := auth.GetUser(ctx); user != nil && job.User != user.Username && user.HasNotRoles([]string{auth.RoleAdmin, auth.RoleSupport}){ if user := auth.GetUser(ctx); user != nil && job.User != user.Username && user.HasNotRoles([]string{auth.RoleAdmin, auth.RoleSupport, auth.RoleManager}){
return nil, errors.New("you are not allowed to see this job") return nil, errors.New("you are not allowed to see this job")
} }

View File

@ -39,9 +39,9 @@ func (r *queryResolver) jobsStatistics(ctx context.Context, filter []*model.JobF
for _, cluster := range archive.Clusters { for _, cluster := range archive.Clusters {
for _, subcluster := range cluster.SubClusters { for _, subcluster := range cluster.SubClusters {
corehoursCol := fmt.Sprintf("CAST(ROUND(SUM(job.duration * job.num_nodes * %d * %d) / 3600) as int)", subcluster.SocketsPerNode, subcluster.CoresPerSocket) corehoursCol := fmt.Sprintf("CAST(ROUND(SUM(job.duration * job.num_nodes * %d * %d) / 3600) as int)", subcluster.SocketsPerNode, subcluster.CoresPerSocket)
var query sq.SelectBuilder var rawQuery sq.SelectBuilder
if groupBy == nil { if groupBy == nil {
query = sq.Select( rawQuery = sq.Select(
"''", "''",
"COUNT(job.id)", "COUNT(job.id)",
"CAST(ROUND(SUM(job.duration) / 3600) as int)", "CAST(ROUND(SUM(job.duration) / 3600) as int)",
@ -49,7 +49,7 @@ func (r *queryResolver) jobsStatistics(ctx context.Context, filter []*model.JobF
).From("job") ).From("job")
} else { } else {
col := groupBy2column[*groupBy] col := groupBy2column[*groupBy]
query = sq.Select( rawQuery = sq.Select(
col, col,
"COUNT(job.id)", "COUNT(job.id)",
"CAST(ROUND(SUM(job.duration) / 3600) as int)", "CAST(ROUND(SUM(job.duration) / 3600) as int)",
@ -57,11 +57,16 @@ func (r *queryResolver) jobsStatistics(ctx context.Context, filter []*model.JobF
).From("job").GroupBy(col) ).From("job").GroupBy(col)
} }
query = query. rawQuery = rawQuery.
Where("job.cluster = ?", cluster.Name). Where("job.cluster = ?", cluster.Name).
Where("job.subcluster = ?", subcluster.Name) Where("job.subcluster = ?", subcluster.Name)
query = repository.SecurityCheck(ctx, query) query, qerr := repository.SecurityCheck(ctx, rawQuery)
if qerr != nil {
return nil, qerr
}
for _, f := range filter { for _, f := range filter {
query = repository.BuildWhereClause(f, query) query = repository.BuildWhereClause(f, query)
} }
@ -97,8 +102,13 @@ func (r *queryResolver) jobsStatistics(ctx context.Context, filter []*model.JobF
} }
if groupBy == nil { if groupBy == nil {
query := sq.Select("COUNT(job.id)").From("job").Where("job.duration < ?", ShortJobDuration)
query = repository.SecurityCheck(ctx, query) query, qerr := repository.SecurityCheck(ctx, sq.Select("COUNT(job.id)").From("job").Where("job.duration < ?", ShortJobDuration))
if qerr != nil {
return nil, qerr
}
for _, f := range filter { for _, f := range filter {
query = repository.BuildWhereClause(f, query) query = repository.BuildWhereClause(f, query)
} }
@ -107,8 +117,13 @@ func (r *queryResolver) jobsStatistics(ctx context.Context, filter []*model.JobF
} }
} else { } else {
col := groupBy2column[*groupBy] col := groupBy2column[*groupBy]
query := sq.Select(col, "COUNT(job.id)").From("job").Where("job.duration < ?", ShortJobDuration)
query = repository.SecurityCheck(ctx, query) query, qerr := repository.SecurityCheck(ctx, sq.Select(col, "COUNT(job.id)").From("job").Where("job.duration < ?", ShortJobDuration))
if qerr != nil {
return nil, qerr
}
for _, f := range filter { for _, f := range filter {
query = repository.BuildWhereClause(f, query) query = repository.BuildWhereClause(f, query)
} }
@ -170,8 +185,13 @@ func (r *queryResolver) jobsStatistics(ctx context.Context, filter []*model.JobF
// `value` must be the column grouped by, but renamed to "value". `id` and `col` can optionally be used // `value` must be the column grouped by, but renamed to "value". `id` and `col` can optionally be used
// to add a condition to the query of the kind "<col> = <id>". // to add a condition to the query of the kind "<col> = <id>".
func (r *queryResolver) jobsStatisticsHistogram(ctx context.Context, value string, filters []*model.JobFilter, id, col string) ([]*model.HistoPoint, error) { func (r *queryResolver) jobsStatisticsHistogram(ctx context.Context, value string, filters []*model.JobFilter, id, col string) ([]*model.HistoPoint, error) {
query := sq.Select(value, "COUNT(job.id) AS count").From("job")
query = repository.SecurityCheck(ctx, query) query, qerr := repository.SecurityCheck(ctx, sq.Select(value, "COUNT(job.id) AS count").From("job"))
if qerr != nil {
return nil, qerr
}
for _, f := range filters { for _, f := range filters {
query = repository.BuildWhereClause(f, query) query = repository.BuildWhereClause(f, query)
} }

View File

@ -295,8 +295,12 @@ func (r *JobRepository) CountGroupedJobs(ctx context.Context, aggreg model.Aggre
} }
} }
q := sq.Select("job."+string(aggreg), count).From("job").GroupBy("job." + string(aggreg)).OrderBy("count DESC") q, qerr := SecurityCheck(ctx, sq.Select("job."+string(aggreg), count).From("job").GroupBy("job." + string(aggreg)).OrderBy("count DESC"))
q = SecurityCheck(ctx, q)
if qerr != nil {
return nil, qerr
}
for _, f := range filters { for _, f := range filters {
q = BuildWhereClause(f, q) q = BuildWhereClause(f, q)
} }

View File

@ -26,8 +26,11 @@ func (r *JobRepository) QueryJobs(
page *model.PageRequest, page *model.PageRequest,
order *model.OrderByInput) ([]*schema.Job, error) { order *model.OrderByInput) ([]*schema.Job, error) {
query := sq.Select(jobColumns...).From("job") query, qerr := SecurityCheck(ctx, sq.Select(jobColumns...).From("job"))
query = SecurityCheck(ctx, query)
if qerr != nil {
return nil, qerr
}
if order != nil { if order != nil {
field := toSnakeCase(order.Field) field := toSnakeCase(order.Field)
@ -79,8 +82,12 @@ func (r *JobRepository) CountJobs(
filters []*model.JobFilter) (int, error) { filters []*model.JobFilter) (int, error) {
// count all jobs: // count all jobs:
query := sq.Select("count(*)").From("job") query, qerr := SecurityCheck(ctx, sq.Select("count(*)").From("job"))
query = SecurityCheck(ctx, query)
if qerr != nil {
return 0, qerr
}
for _, f := range filters { for _, f := range filters {
query = BuildWhereClause(f, query) query = BuildWhereClause(f, query)
} }
@ -92,13 +99,18 @@ func (r *JobRepository) CountJobs(
return count, nil return count, nil
} }
func SecurityCheck(ctx context.Context, query sq.SelectBuilder) sq.SelectBuilder { func SecurityCheck(ctx context.Context, query sq.SelectBuilder) (queryOut sq.SelectBuilder, err error) {
user := auth.GetUser(ctx) user := auth.GetUser(ctx)
if user == nil || user.HasAnyRole([]string{auth.RoleAdmin, auth.RoleApi, auth.RoleSupport}) { if user == nil || user.HasAnyRole([]string{auth.RoleAdmin, auth.RoleSupport, auth.RoleApi}) {
return query return query, nil
} else if (user.HasRole(auth.RoleManager)) { // Manager (Might be doublefiltered by frontend: should not matter)
return query.Where("job.project = ?", user.Project), nil
} else if (user.HasRole(auth.RoleUser)) { // User
return query.Where("job.user = ?", user.Username), nil
} else { // Unauthorized
var qnil sq.SelectBuilder
return qnil, errors.New(fmt.Sprintf("User '%s' with unknown roles! [%#v]\n", user.Username, user.Roles))
} }
return query.Where("job.user = ?", user.Username)
} }
// Build a sq.SelectBuilder out of a schema.JobFilter. // Build a sq.SelectBuilder out of a schema.JobFilter.

View File

@ -103,9 +103,11 @@ func setupUserRoute(i InfoType, r *http.Request) InfoType {
username := mux.Vars(r)["id"] username := mux.Vars(r)["id"]
i["id"] = username i["id"] = username
i["username"] = username i["username"] = username
// TODO: If forbidden (== err exists), redirect to error page
if user, _ := auth.FetchUser(r.Context(), jobRepo.DB, username); user != nil { if user, _ := auth.FetchUser(r.Context(), jobRepo.DB, username); user != nil {
i["name"] = user.Name i["name"] = user.Name
i["email"] = user.Email i["email"] = user.Email
// i["project"] = user.Project
} }
return i return i
} }
@ -270,17 +272,17 @@ func SetupRoutes(router *mux.Router, version string, hash string, buildTime stri
title = strings.Replace(route.Title, "<ID>", id.(string), 1) title = strings.Replace(route.Title, "<ID>", id.(string), 1)
} }
username, isAdmin, isSupporter := "", true, true username, project, authLevel := "", "", 0
if user := auth.GetUser(r.Context()); user != nil { if user := auth.GetUser(r.Context()); user != nil {
username = user.Username username = user.Username
isAdmin = user.HasRole(auth.RoleAdmin) project = user.Project
isSupporter = user.HasRole(auth.RoleSupport) authLevel = user.GetAuthLevel()
} }
page := web.Page{ page := web.Page{
Title: title, Title: title,
User: web.User{Username: username, IsAdmin: isAdmin, IsSupporter: isSupporter}, User: web.User{Username: username, Project: project, AuthLevel: authLevel},
Build: web.Build{Version: version, Hash: hash, Buildtime: buildTime}, Build: web.Build{Version: version, Hash: hash, Buildtime: buildTime},
Config: conf, Config: conf,
Infos: infos, Infos: infos,

View File

@ -14,7 +14,7 @@
</script> </script>
{#if user.IsAdmin} {#if user.AuthLevel == 5}
<Card style="margin-bottom: 1.5em;"> <Card style="margin-bottom: 1.5em;">
<CardHeader> <CardHeader>
<CardTitle class="mb-1">Admin Options</CardTitle> <CardTitle class="mb-1">Admin Options</CardTitle>

View File

@ -4,23 +4,44 @@
Dropdown, DropdownToggle, DropdownMenu, DropdownItem } from 'sveltestrap' Dropdown, DropdownToggle, DropdownMenu, DropdownItem } from 'sveltestrap'
export let username // empty string if auth. is disabled, otherwise the username as string export let username // empty string if auth. is disabled, otherwise the username as string
export let isAdmin // boolean export let project // empty string if user has no project in db (= not manager), otherwise the managed projectid as string
export let authlevel // integer
export let clusters // array of names export let clusters // array of names
let isOpen = false let isOpen = false
const views = [ const userviews = [
isAdmin { title: 'My Jobs', href: `/monitoring/user/${username}`, icon: 'bar-chart-line-fill' },
? { title: 'Jobs', adminOnly: false, href: '/monitoring/jobs/', icon: 'card-list' } { title: 'Tags', href: '/monitoring/tags/', icon: 'tags' }
: { title: 'My Jobs', adminOnly: false, href: `/monitoring/user/${username}`, icon: 'bar-chart-line-fill' },
{ title: 'Users', adminOnly: true, href: '/monitoring/users/', icon: 'people-fill' },
{ title: 'Projects', adminOnly: true, href: '/monitoring/projects/', icon: 'folder' },
{ title: 'Tags', adminOnly: false, href: '/monitoring/tags/', icon: 'tags' }
] ]
const managerviews = [
{ title: 'My Jobs', href: `/monitoring/user/${username}`, icon: 'bar-chart-line-fill' },
{ title: `'${project}' Jobs`, href: '/monitoring/jobs/', icon: 'card-list' },
{ title: `'${project}' Users`, href: '/monitoring/users/', icon: 'people-fill' },
{ title: 'Tags', href: '/monitoring/tags/', icon: 'tags' }
]
const supportviews = [
{ title: 'My Jobs', href: `/monitoring/user/${username}`, icon: 'bar-chart-line-fill' },
{ title: 'Jobs', href: '/monitoring/jobs/', icon: 'card-list' },
{ title: 'Users', href: '/monitoring/users/', icon: 'people-fill' },
{ title: 'Projects', href: '/monitoring/projects/', icon: 'folder' },
{ title: 'Tags', href: '/monitoring/tags/', icon: 'tags' }
]
const adminviews = [
{ title: 'My Jobs', href: `/monitoring/user/${username}`, icon: 'bar-chart-line-fill' },
{ title: 'Jobs', href: '/monitoring/jobs/', icon: 'card-list' },
{ title: 'Users', href: '/monitoring/users/', icon: 'people-fill' },
{ title: 'Projects', href: '/monitoring/projects/', icon: 'folder' },
{ title: 'Tags', href: '/monitoring/tags/', icon: 'tags' }
]
const viewsPerCluster = [ const viewsPerCluster = [
{ title: 'Analysis', adminOnly: true, href: '/monitoring/analysis/', icon: 'graph-up' }, { title: 'Analysis', authLevel: 4, href: '/monitoring/analysis/', icon: 'graph-up' },
{ title: 'Systems', adminOnly: true, href: '/monitoring/systems/', icon: 'cpu' }, { title: 'Systems', authLevel: 5, href: '/monitoring/systems/', icon: 'cpu' },
{ title: 'Status', adminOnly: true, href: '/monitoring/status/', icon: 'cpu' }, { title: 'Status', authLevel: 5, href: '/monitoring/status/', icon: 'cpu' },
] ]
</script> </script>
@ -31,10 +52,26 @@
<NavbarToggler on:click={() => (isOpen = !isOpen)} /> <NavbarToggler on:click={() => (isOpen = !isOpen)} />
<Collapse {isOpen} navbar expand="lg" on:update={({ detail }) => (isOpen = detail.isOpen)}> <Collapse {isOpen} navbar expand="lg" on:update={({ detail }) => (isOpen = detail.isOpen)}>
<Nav pills> <Nav pills>
{#each views.filter(item => isAdmin || !item.adminOnly) as item} {#if authlevel == 5} <!-- admin -->
<NavLink href={item.href} active={window.location.pathname == item.href}><Icon name={item.icon}/> {item.title}</NavLink> {#each adminviews as item}
{/each} <NavLink href={item.href} active={window.location.pathname == item.href}><Icon name={item.icon}/> {item.title}</NavLink>
{#each viewsPerCluster.filter(item => !item.adminOnly || isAdmin) as item} {/each}
{:else if authlevel == 4} <!-- support -->
{#each supportviews as item}
<NavLink href={item.href} active={window.location.pathname == item.href}><Icon name={item.icon}/> {item.title}</NavLink>
{/each}
{:else if authlevel == 3} <!-- manager -->
{#each managerviews as item}
<NavLink href={item.href} active={window.location.pathname == item.href}><Icon name={item.icon}/> {item.title}</NavLink>
{/each}
{:else if authlevel == 2} <!-- user -->
{#each userviews as item}
<NavLink href={item.href} active={window.location.pathname == item.href}><Icon name={item.icon}/> {item.title}</NavLink>
{/each}
{:else}
<p>API User or Unauthorized!</p>
{/if}
{#each viewsPerCluster.filter(item => item.authLevel <= authlevel) as item}
<NavItem> <NavItem>
<Dropdown nav inNavbar> <Dropdown nav inNavbar>
<DropdownToggle nav caret> <DropdownToggle nav caret>
@ -55,7 +92,7 @@
<div class="d-flex"> <div class="d-flex">
<form method="GET" action="/search"> <form method="GET" action="/search">
<InputGroup> <InputGroup>
<Input type="text" placeholder={isAdmin ? "Search jobId / username" : "Search jobId"} name="searchId"/> <Input type="text" placeholder={(authlevel >= 4) ? "Search jobId / username" : "Search jobId"} name="searchId"/>
<Button outline type="submit"><Icon name="search"/></Button> <Button outline type="submit"><Icon name="search"/></Button>
</InputGroup> </InputGroup>
</form> </form>

View File

@ -14,6 +14,8 @@
const ccconfig = getContext('cc-config') const ccconfig = getContext('cc-config')
export let filterPresets = {} export let filterPresets = {}
export let project = ""
export let isManager = false
let filters, jobList, matchedJobs = null let filters, jobList, matchedJobs = null
let sorting = { field: 'startTime', order: 'DESC' }, isSortingOpen = false, isMetricsSelectionOpen = false let sorting = { field: 'startTime', order: 'DESC' }, isSortingOpen = false, isMetricsSelectionOpen = false
@ -70,6 +72,8 @@
<Row> <Row>
<Col> <Col>
<JobList <JobList
project={project}
isManager={isManager}
bind:metrics={metrics} bind:metrics={metrics}
bind:sorting={sorting} bind:sorting={sorting}
bind:matchedJobs={matchedJobs} bind:matchedJobs={matchedJobs}

View File

@ -1,4 +1,4 @@
<!-- <!--
@component List of users or projects @component List of users or projects
--> -->
<script> <script>
@ -14,9 +14,19 @@
export let type export let type
export let filterPresets export let filterPresets
export let project = false
export let isManager = false
console.assert(type == 'USER' || type == 'PROJECT', 'Invalid list type provided!') console.assert(type == 'USER' || type == 'PROJECT', 'Invalid list type provided!')
let projectFilter = null
//Setup default filter
if (type == 'USER' && isManager == true && project != '') {
projectFilter = { project: {eq: project} }
} else if (type == 'USER' && isManager == true && project == '') {
projectFilter = { project: {eq: "noProjectForManager"} }
}
const stats = operationStore(`query($filter: [JobFilter!]!) { const stats = operationStore(`query($filter: [JobFilter!]!) {
rows: jobsStatistics(filter: $filter, groupBy: ${type}) { rows: jobsStatistics(filter: $filter, groupBy: ${type}) {
id id
@ -54,7 +64,7 @@
: (sorting.direction == 'up' : (sorting.direction == 'up'
? (a, b) => a[sorting.field] - b[sorting.field] ? (a, b) => a[sorting.field] - b[sorting.field]
: (a, b) => b[sorting.field] - a[sorting.field]) : (a, b) => b[sorting.field] - a[sorting.field])
return stats.filter(u => u.id.includes(nameFilter)).sort(cmp) return stats.filter(u => u.id.includes(nameFilter)).sort(cmp)
} }
@ -78,6 +88,9 @@
menuText="Only {type.toLowerCase()}s with jobs that match the filters will show up" menuText="Only {type.toLowerCase()}s with jobs that match the filters will show up"
on:update={({ detail }) => { on:update={({ detail }) => {
$stats.variables = { filter: detail.filters } $stats.variables = { filter: detail.filters }
if (projectFilter != null) {
$stats.variables.filter.push(projectFilter)
}
$stats.context.pause = false $stats.context.pause = false
$stats.reexecute() $stats.reexecute()
}} /> }} />
@ -148,4 +161,4 @@
{/each} {/each}
{/if} {/if}
</tbody> </tbody>
</Table> </Table>

View File

@ -48,7 +48,7 @@
query(nodesQuery) query(nodesQuery)
$: console.log($nodesQuery?.data?.nodeMetrics[0].metrics) // $: console.log($nodesQuery?.data?.nodeMetrics[0].metrics)
</script> </script>
<Row> <Row>

View File

@ -48,7 +48,7 @@
if (s1 == null || s2 == null) if (s1 == null || s2 == null)
return -1 return -1
return s.dir != 'up' ? s1[stat] - s2[stat] : s2[stat] - s1[stat] return s.dir != 'up' ? s1[stat] - s2[stat] : s2[stat] - s1[stat]
}) })
} }
@ -61,7 +61,7 @@
<thead> <thead>
<tr> <tr>
<th> <th>
<Button outline on:click={() => (isMetricSelectionOpen = true, console.log(isMetricSelectionOpen))}> <Button outline on:click={() => (isMetricSelectionOpen = true)}> <!-- log to click ', console.log(isMetricSelectionOpen)' -->
Metrics Metrics
</Button> </Button>
</th> </th>

View File

@ -2,6 +2,7 @@
import { Row, Col } from 'sveltestrap' import { Row, Col } from 'sveltestrap'
import { onMount } from 'svelte' import { onMount } from 'svelte'
import EditRole from './admin/EditRole.svelte' import EditRole from './admin/EditRole.svelte'
import EditProject from './admin/EditProject.svelte'
import AddUser from './admin/AddUser.svelte' import AddUser from './admin/AddUser.svelte'
import ShowUsers from './admin/ShowUsers.svelte' import ShowUsers from './admin/ShowUsers.svelte'
import Options from './admin/Options.svelte' import Options from './admin/Options.svelte'
@ -30,6 +31,9 @@
<Col> <Col>
<EditRole on:reload={getUserList}/> <EditRole on:reload={getUserList}/>
</Col> </Col>
<Col>
<EditProject on:reload={getUserList}/>
</Col>
<Col> <Col>
<Options/> <Options/>
</Col> </Col>

View File

@ -45,17 +45,7 @@
<form id="create-user-form" method="post" action="/api/users/" class="card-body" on:submit|preventDefault={handleUserSubmit}> <form id="create-user-form" method="post" action="/api/users/" class="card-body" on:submit|preventDefault={handleUserSubmit}>
<CardTitle class="mb-3">Create User</CardTitle> <CardTitle class="mb-3">Create User</CardTitle>
<div class="mb-3"> <div class="mb-3">
<label for="name" class="form-label">Name</label> <label for="username" class="form-label">Username (ID)</label>
<input type="text" class="form-control" id="name" name="name" aria-describedby="nameHelp"/>
<div id="nameHelp" class="form-text">Optional, can be blank.</div>
</div>
<div class="mb-3">
<label for="email" class="form-label">Email address</label>
<input type="email" class="form-control" id="email" name="email" aria-describedby="emailHelp"/>
<div id="emailHelp" class="form-text">Optional, can be blank.</div>
</div>
<div class="mb-3">
<label for="username" class="form-label">Username</label>
<input type="text" class="form-control" id="username" name="username" aria-describedby="usernameHelp"/> <input type="text" class="form-control" id="username" name="username" aria-describedby="usernameHelp"/>
<div id="usernameHelp" class="form-text">Must be unique.</div> <div id="usernameHelp" class="form-text">Must be unique.</div>
</div> </div>
@ -64,6 +54,23 @@
<input type="password" class="form-control" id="password" name="password" aria-describedby="passwordHelp"/> <input type="password" class="form-control" id="password" name="password" aria-describedby="passwordHelp"/>
<div id="passwordHelp" class="form-text">Only API users are allowed to have a blank password. Users with a blank password can only authenticate via Tokens.</div> <div id="passwordHelp" class="form-text">Only API users are allowed to have a blank password. Users with a blank password can only authenticate via Tokens.</div>
</div> </div>
<div class="mb-3">
<label for="name" class="form-label">Project</label>
<input type="text" class="form-control" id="project" name="project" aria-describedby="projectHelp"/>
<div id="projectHelp" class="form-text">Only Manager users can have a project. Allows to inspect jobs and users of given project.</div>
</div>
<div class="mb-3">
<label for="name" class="form-label">Name</label>
<input type="text" class="form-control" id="name" name="name" aria-describedby="nameHelp"/>
<div id="nameHelp" class="form-text">Optional, can be blank.</div>
</div>
<div class="mb-3">
<label for="email" class="form-label">Email address</label>
<input type="email" class="form-control" id="email" name="email" aria-describedby="emailHelp"/>
<div id="emailHelp" class="form-text">Optional, can be blank.</div>
</div>
<div class="mb-3"> <div class="mb-3">
<p>Role:</p> <p>Role:</p>
<div> <div>
@ -74,6 +81,10 @@
<input type="radio" id="api" name="role" value="api"/> <input type="radio" id="api" name="role" value="api"/>
<label for="api">API</label> <label for="api">API</label>
</div> </div>
<div>
<input type="radio" id="manager" name="role" value="manager"/>
<label for="manager">Manager</label>
</div>
<div> <div>
<input type="radio" id="support" name="role" value="support"/> <input type="radio" id="support" name="role" value="support"/>
<label for="support">Support</label> <label for="support">Support</label>

View File

@ -0,0 +1,97 @@
<script>
import { Card, CardTitle, CardBody } from 'sveltestrap'
import { createEventDispatcher } from 'svelte'
import { fade } from 'svelte/transition'
const dispatch = createEventDispatcher()
let message = {msg: '', color: '#d63384'}
let displayMessage = false
async function handleAddProject() {
const username = document.querySelector('#project-username').value
const project = document.querySelector('#project-id').value
if (username == "" || project == "") {
alert('Please fill in a username and select a project.')
return
}
let formData = new FormData()
formData.append('username', username)
formData.append('add-project', project)
try {
const res = await fetch(`/api/user/${username}`, { method: 'POST', body: formData })
if (res.ok) {
let text = await res.text()
popMessage(text, '#048109')
reloadUserList()
} else {
let text = await res.text()
// console.log(res.statusText)
throw new Error('Response Code ' + res.status + '-> ' + text)
}
} catch (err) {
popMessage(err, '#d63384')
}
}
async function handleRemoveProject() {
const username = document.querySelector('#project-username').value
const project = document.querySelector('#project-id').value
if (username == "" || project == "") {
alert('Please fill in a username and select a project.')
return
}
let formData = new FormData()
formData.append('username', username)
formData.append('remove-project', project)
try {
const res = await fetch(`/api/user/${username}`, { method: 'POST', body: formData })
if (res.ok) {
let text = await res.text()
popMessage(text, '#048109')
reloadUserList()
} else {
let text = await res.text()
// console.log(res.statusText)
throw new Error('Response Code ' + res.status + '-> ' + text)
}
} catch (err) {
popMessage(err, '#d63384')
}
}
function popMessage(response, rescolor) {
message = {msg: response, color: rescolor}
displayMessage = true
setTimeout(function() {
displayMessage = false
}, 3500)
}
function reloadUserList() {
dispatch('reload')
}
</script>
<Card>
<CardBody>
<CardTitle class="mb-3">Edit Project Managed By User (Manager Only)</CardTitle>
<div class="input-group mb-3">
<input type="text" class="form-control" placeholder="username" id="project-username"/>
<input type="text" class="form-control" placeholder="project-id" id="project-id"/>
<!-- PreventDefault on Sveltestrap-Button more complex to achieve than just use good ol' html button -->
<!-- see: https://stackoverflow.com/questions/69630422/svelte-how-to-use-event-modifiers-in-my-own-components -->
<button class="btn btn-primary" type="button" id="add-project-button" on:click|preventDefault={handleAddProject}>Reset</button>
<button class="btn btn-danger" type="button" id="remove-project-button" on:click|preventDefault={handleRemoveProject}>Remove</button>
</div>
<p>
{#if displayMessage}<b><code style="color: {message.color};" out:fade>Update: {message.msg}</code></b>{/if}
</p>
</CardBody>
</Card>

View File

@ -87,6 +87,7 @@
<select class="form-select" id="role-select"> <select class="form-select" id="role-select">
<option selected value="">Role...</option> <option selected value="">Role...</option>
<option value="user">User</option> <option value="user">User</option>
<option value="manager">Manager</option>
<option value="support">Support</option> <option value="support">Support</option>
<option value="admin">Admin</option> <option value="admin">Admin</option>
<option value="api">API</option> <option value="api">API</option>

View File

@ -41,6 +41,7 @@
<tr> <tr>
<th>Username</th> <th>Username</th>
<th>Name</th> <th>Name</th>
<th>Project</th>
<th>Email</th> <th>Email</th>
<th>Roles</th> <th>Roles</th>
<th>JWT</th> <th>JWT</th>

View File

@ -16,6 +16,7 @@
<td>{user.username}</td> <td>{user.username}</td>
<td>{user.name}</td> <td>{user.name}</td>
<td>{user.project}</td>
<td>{user.email}</td> <td>{user.email}</td>
<td><code>{user.roles.join(', ')}</code></td> <td><code>{user.roles.join(', ')}</code></td>
<td> <td>

View File

@ -115,7 +115,7 @@
opts.push(`state=${state}`) opts.push(`state=${state}`)
if (filters.startTime.from && filters.startTime.to) if (filters.startTime.from && filters.startTime.to)
opts.push(`startTime=${dateToUnixEpoch(filters.startTime.from)}-${dateToUnixEpoch(filters.startTime.to)}`) opts.push(`startTime=${dateToUnixEpoch(filters.startTime.from)}-${dateToUnixEpoch(filters.startTime.to)}`)
for (let tag of filters.tags) for (let tag of filters.tags)
opts.push(`tag=${tag}`) opts.push(`tag=${tag}`)
if (filters.duration.from && filters.duration.to) if (filters.duration.from && filters.duration.to)
opts.push(`duration=${filters.duration.from}-${filters.duration.to}`) opts.push(`duration=${filters.duration.from}-${filters.duration.to}`)
@ -214,7 +214,7 @@
on:change={({ detail: { from, to } }) => { on:change={({ detail: { from, to } }) => {
filters.startTime.from = from?.toISOString() filters.startTime.from = from?.toISOString()
filters.startTime.to = to?.toISOString() filters.startTime.to = to?.toISOString()
console.log(filters.startTime) // console.log(filters.startTime)
update() update()
}} }}
/> />

View File

@ -1,7 +1,7 @@
<script> <script>
import { createEventDispatcher, getContext } from 'svelte' import { createEventDispatcher, getContext } from 'svelte'
import { Button, Modal, ModalBody, ModalHeader, ModalFooter } from 'sveltestrap' import { Button, Modal, ModalBody, ModalHeader, ModalFooter } from 'sveltestrap'
import Header from '../Header.svelte'; import Header from '../Header.svelte';
import DoubleRangeSlider from './DoubleRangeSlider.svelte' import DoubleRangeSlider from './DoubleRangeSlider.svelte'
const clusters = getContext('clusters'), const clusters = getContext('clusters'),
@ -23,7 +23,7 @@ import Header from '../Header.svelte';
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)
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
$: { $: {
if ($initialized) { if ($initialized) {

View File

@ -23,10 +23,20 @@
export let sorting = { field: "startTime", order: "DESC" } export let sorting = { field: "startTime", order: "DESC" }
export let matchedJobs = 0 export let matchedJobs = 0
export let metrics = ccconfig.plot_list_selectedMetrics export let metrics = ccconfig.plot_list_selectedMetrics
export let project
export let isManager
let itemsPerPage = ccconfig.plot_list_jobsPerPage let itemsPerPage = ccconfig.plot_list_jobsPerPage
let page = 1 let page = 1
let paging = { itemsPerPage, page } let paging = { itemsPerPage, page }
let filter = []
//Setup default filter
if (isManager == true && project != '') {
filter.push({project: {eq: project}})
} else if (isManager == true && project == '') {
filter.push({project: {eq: "noProjectForManager"}})
}
const jobs = operationStore(` const jobs = operationStore(`
query($filter: [JobFilter!]!, $sorting: OrderByInput!, $paging: PageRequest! ){ query($filter: [JobFilter!]!, $sorting: OrderByInput!, $paging: PageRequest! ){
@ -45,7 +55,7 @@
}`, { }`, {
paging, paging,
sorting, sorting,
filter: [] filter,
}, { }, {
pause: true pause: true
}) })
@ -67,8 +77,15 @@
filters.push({ minRunningFor }) filters.push({ minRunningFor })
} }
// (Re-)Add Manager-Filter
if (isManager == true && project != '') {
filters.push({project: {eq: project}})
} else if (isManager == true && project == '') {
filters.push({project: {eq: "noProjectForManager"}})
}
$jobs.variables.filter = filters $jobs.variables.filter = filters
console.log('filters:', ...filters.map(f => Object.entries(f)).flat(2)) // console.log('filters:', ...filters.map(f => Object.entries(f)).flat(2))
} }
page = 1 page = 1

View File

@ -4,7 +4,9 @@ import Jobs from './Jobs.root.svelte'
new Jobs({ new Jobs({
target: document.getElementById('svelte-app'), target: document.getElementById('svelte-app'),
props: { props: {
filterPresets: filterPresets filterPresets: filterPresets,
project: project,
isManager: isManager
}, },
context: new Map([ context: new Map([
['cc-config', clusterCockpitConfig] ['cc-config', clusterCockpitConfig]

View File

@ -6,6 +6,8 @@ new List({
props: { props: {
filterPresets: filterPresets, filterPresets: filterPresets,
type: listType, type: listType,
project: project,
isManager: isManager
}, },
context: new Map([ context: new Map([
['cc-config', clusterCockpitConfig] ['cc-config', clusterCockpitConfig]

View File

@ -1,4 +1,4 @@
<!-- <!--
@component @component
Only width/height should change reactively. Only width/height should change reactively.
@ -285,7 +285,7 @@
else if (scope == 'hwthread') else if (scope == 'hwthread')
divisor = subCluster.topology.node.length divisor = subCluster.topology.node.length
else { else {
console.log('TODO: how to calc thresholds for ', scope) // console.log('TODO: how to calc thresholds for ', scope)
return null return null
} }

View File

@ -16,7 +16,8 @@
<script> <script>
const header = { const header = {
"username": "{{ .User.Username }}", "username": "{{ .User.Username }}",
"isAdmin": {{ .User.IsAdmin }}, "project": "{{ .User.Project }}",
"authlevel": {{ .User.AuthLevel }},
"clusters": {{ .Clusters }}, "clusters": {{ .Clusters }},
}; };
</script> </script>

View File

@ -9,14 +9,14 @@
<th>Running Jobs (short ones not listed)</th> <th>Running Jobs (short ones not listed)</th>
<th>Total Jobs</th> <th>Total Jobs</th>
<th>Short Jobs in past 24h</th> <th>Short Jobs in past 24h</th>
{{if .User.IsAdmin}} {{if ge .User.AuthLevel 4}}
<th>System View</th> <th>System View</th>
<th>Analysis View</th> <th>Analysis View</th>
{{end}} {{end}}
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{{if .User.IsAdmin}} {{if ge .User.AuthLevel 4}}
{{range .Infos.clusters}} {{range .Infos.clusters}}
<tr> <tr>
<td>{{.Name}}</td> <td>{{.Name}}</td>

View File

@ -10,6 +10,8 @@
<script> <script>
const filterPresets = {{ .FilterPresets }}; const filterPresets = {{ .FilterPresets }};
const clusterCockpitConfig = {{ .Config }}; const clusterCockpitConfig = {{ .Config }};
const project = {{ .User.Project }};
const isManager = {{ eq .User.AuthLevel 3 }};
</script> </script>
<script src='/build/jobs.js'></script> <script src='/build/jobs.js'></script>
{{end}} {{end}}

View File

@ -10,6 +10,8 @@
const listType = {{ .Infos.listType }}; const listType = {{ .Infos.listType }};
const filterPresets = {{ .FilterPresets }}; const filterPresets = {{ .FilterPresets }};
const clusterCockpitConfig = {{ .Config }}; const clusterCockpitConfig = {{ .Config }};
const project = {{ .User.Project }};
const isManager = {{ eq .User.AuthLevel 3 }};
</script> </script>
<script src='/build/list.js'></script> <script src='/build/list.js'></script>
{{end}} {{end}}

View File

@ -55,8 +55,8 @@ func init() {
type User struct { type User struct {
Username string // Username of the currently logged in user Username string // Username of the currently logged in user
IsAdmin bool Project string // Project of the user (relevant for managers only)
IsSupporter bool AuthLevel int // Level of authorization
} }
type Build struct { type Build struct {