mirror of
https://github.com/ClusterCockpit/cc-backend
synced 2024-12-26 13:29:05 +01:00
Merge branch 'master' of github.com:ClusterCockpit/cc-backend
This commit is contained in:
commit
9c6d37118b
103
api/rest.go
103
api/rest.go
@ -17,6 +17,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/ClusterCockpit/cc-backend/auth"
|
"github.com/ClusterCockpit/cc-backend/auth"
|
||||||
|
"github.com/ClusterCockpit/cc-backend/config"
|
||||||
"github.com/ClusterCockpit/cc-backend/graph"
|
"github.com/ClusterCockpit/cc-backend/graph"
|
||||||
"github.com/ClusterCockpit/cc-backend/graph/model"
|
"github.com/ClusterCockpit/cc-backend/graph/model"
|
||||||
"github.com/ClusterCockpit/cc-backend/log"
|
"github.com/ClusterCockpit/cc-backend/log"
|
||||||
@ -29,6 +30,7 @@ import (
|
|||||||
type RestApi struct {
|
type RestApi struct {
|
||||||
JobRepository *repository.JobRepository
|
JobRepository *repository.JobRepository
|
||||||
Resolver *graph.Resolver
|
Resolver *graph.Resolver
|
||||||
|
Authentication *auth.Authentication
|
||||||
MachineStateDir string
|
MachineStateDir string
|
||||||
OngoingArchivings sync.WaitGroup
|
OngoingArchivings sync.WaitGroup
|
||||||
}
|
}
|
||||||
@ -48,6 +50,14 @@ func (api *RestApi) MountRoutes(r *mux.Router) {
|
|||||||
|
|
||||||
r.HandleFunc("/jobs/metrics/{id}", api.getJobMetrics).Methods(http.MethodGet)
|
r.HandleFunc("/jobs/metrics/{id}", api.getJobMetrics).Methods(http.MethodGet)
|
||||||
|
|
||||||
|
if api.Authentication != nil {
|
||||||
|
r.HandleFunc("/jwt/", api.getJWT).Methods(http.MethodGet)
|
||||||
|
r.HandleFunc("/users/", api.createUser).Methods(http.MethodPost, http.MethodPut)
|
||||||
|
r.HandleFunc("/users/", api.getUsers).Methods(http.MethodGet)
|
||||||
|
r.HandleFunc("/users/", api.deleteUser).Methods(http.MethodDelete)
|
||||||
|
r.HandleFunc("/configuration/", api.updateConfiguration).Methods(http.MethodPost)
|
||||||
|
}
|
||||||
|
|
||||||
if api.MachineStateDir != "" {
|
if api.MachineStateDir != "" {
|
||||||
r.HandleFunc("/machine_state/{cluster}/{host}", api.getMachineState).Methods(http.MethodGet)
|
r.HandleFunc("/machine_state/{cluster}/{host}", api.getMachineState).Methods(http.MethodGet)
|
||||||
r.HandleFunc("/machine_state/{cluster}/{host}", api.putMachineState).Methods(http.MethodPut, http.MethodPost)
|
r.HandleFunc("/machine_state/{cluster}/{host}", api.putMachineState).Methods(http.MethodPut, http.MethodPost)
|
||||||
@ -465,6 +475,99 @@ func (api *RestApi) getJobMetrics(rw http.ResponseWriter, r *http.Request) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (api *RestApi) getJWT(rw http.ResponseWriter, r *http.Request) {
|
||||||
|
rw.Header().Set("Content-Type", "text/plain")
|
||||||
|
username := r.FormValue("username")
|
||||||
|
me := auth.GetUser(r.Context())
|
||||||
|
if !me.HasRole(auth.RoleAdmin) {
|
||||||
|
if username != me.Username {
|
||||||
|
http.Error(rw, "only admins are allowed to sign JWTs not for themselves", http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
user, err := api.Authentication.FetchUser(username)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(rw, err.Error(), http.StatusUnprocessableEntity)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
jwt, err := api.Authentication.ProvideJWT(user)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(rw, err.Error(), http.StatusUnprocessableEntity)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
rw.WriteHeader(http.StatusOK)
|
||||||
|
rw.Write([]byte(jwt))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (api *RestApi) createUser(rw http.ResponseWriter, r *http.Request) {
|
||||||
|
rw.Header().Set("Content-Type", "text/plain")
|
||||||
|
me := auth.GetUser(r.Context())
|
||||||
|
if !me.HasRole(auth.RoleAdmin) {
|
||||||
|
http.Error(rw, "only admins are allowed to create new users", http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
username, password, role, name, email := r.FormValue("username"), r.FormValue("password"), r.FormValue("role"), r.FormValue("name"), r.FormValue("email")
|
||||||
|
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)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := api.Authentication.CreateUser(username, name, password, email, []string{role}); err != nil {
|
||||||
|
http.Error(rw, err.Error(), http.StatusUnprocessableEntity)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
rw.Write([]byte(fmt.Sprintf("User %#v successfully created!\n", username)))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (api *RestApi) deleteUser(rw http.ResponseWriter, r *http.Request) {
|
||||||
|
if user := auth.GetUser(r.Context()); !user.HasRole(auth.RoleAdmin) {
|
||||||
|
http.Error(rw, "only admins are allowed to delete a user", http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
username := r.FormValue("username")
|
||||||
|
if err := api.Authentication.DelUser(username); err != nil {
|
||||||
|
http.Error(rw, err.Error(), http.StatusUnprocessableEntity)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
rw.WriteHeader(http.StatusOK)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (api *RestApi) getUsers(rw http.ResponseWriter, r *http.Request) {
|
||||||
|
if user := auth.GetUser(r.Context()); !user.HasRole(auth.RoleAdmin) {
|
||||||
|
http.Error(rw, "only admins are allowed to fetch a list of users", http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
users, err := api.Authentication.FetchUsers(r.URL.Query().Get("via-ldap") == "true")
|
||||||
|
if err != nil {
|
||||||
|
http.Error(rw, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
json.NewEncoder(rw).Encode(users)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (api *RestApi) updateConfiguration(rw http.ResponseWriter, r *http.Request) {
|
||||||
|
rw.Header().Set("Content-Type", "text/plain")
|
||||||
|
key, value := r.FormValue("key"), r.FormValue("value")
|
||||||
|
|
||||||
|
fmt.Printf("KEY: %#v\nVALUE: %#v\n", key, value)
|
||||||
|
|
||||||
|
if err := config.UpdateConfig(key, value, r.Context()); err != nil {
|
||||||
|
http.Error(rw, err.Error(), http.StatusUnprocessableEntity)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
rw.Write([]byte("success"))
|
||||||
|
}
|
||||||
|
|
||||||
func (api *RestApi) putMachineState(rw http.ResponseWriter, r *http.Request) {
|
func (api *RestApi) putMachineState(rw http.ResponseWriter, r *http.Request) {
|
||||||
if api.MachineStateDir == "" {
|
if api.MachineStateDir == "" {
|
||||||
http.Error(rw, "not enabled", http.StatusNotFound)
|
http.Error(rw, "not enabled", http.StatusNotFound)
|
||||||
|
92
auth/auth.go
92
auth/auth.go
@ -26,12 +26,12 @@ import (
|
|||||||
// If Name and Email is needed as well, use auth.FetchUser(), which does a database
|
// If Name and Email is needed as well, use auth.FetchUser(), which does a database
|
||||||
// query for all fields.
|
// query for all fields.
|
||||||
type User struct {
|
type User struct {
|
||||||
Username string
|
Username string `json:"username"`
|
||||||
Password string
|
Password string `json:"-"`
|
||||||
Name string
|
Name string `json:"name"`
|
||||||
Roles []string
|
Roles []string `json:"roles"`
|
||||||
ViaLdap bool
|
ViaLdap bool `json:"via-ldap"`
|
||||||
Email string
|
Email string `json:"email"`
|
||||||
}
|
}
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@ -130,32 +130,46 @@ func (auth *Authentication) AddUser(arg string) error {
|
|||||||
return errors.New("invalid argument format")
|
return errors.New("invalid argument format")
|
||||||
}
|
}
|
||||||
|
|
||||||
password := ""
|
roles := strings.Split(parts[1], ",")
|
||||||
if len(parts[2]) > 0 {
|
return auth.CreateUser(parts[0], "", parts[2], "", roles)
|
||||||
bytes, err := bcrypt.GenerateFromPassword([]byte(parts[2]), bcrypt.DefaultCost)
|
}
|
||||||
|
|
||||||
|
func (auth *Authentication) CreateUser(username, name, password, email string, roles []string) error {
|
||||||
|
for _, role := range roles {
|
||||||
|
if role != RoleAdmin && role != RoleApi && role != RoleUser {
|
||||||
|
return fmt.Errorf("invalid user role: %#v", role)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if username == "" {
|
||||||
|
return errors.New("username should not be empty")
|
||||||
|
}
|
||||||
|
|
||||||
|
if password != "" {
|
||||||
|
bytes, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
password = string(bytes)
|
password = string(bytes)
|
||||||
}
|
}
|
||||||
|
|
||||||
roles := []string{}
|
rolesJson, _ := json.Marshal(roles)
|
||||||
for _, role := range strings.Split(parts[1], ",") {
|
cols := []string{"username", "password", "roles"}
|
||||||
if len(role) == 0 {
|
vals := []interface{}{username, password, string(rolesJson)}
|
||||||
continue
|
if name != "" {
|
||||||
} else if role == RoleAdmin || role == RoleApi || role == RoleUser {
|
cols = append(cols, "name")
|
||||||
roles = append(roles, role)
|
vals = append(vals, name)
|
||||||
} else {
|
}
|
||||||
return fmt.Errorf("invalid user role: %#v", role)
|
if email != "" {
|
||||||
}
|
cols = append(cols, "email")
|
||||||
|
vals = append(vals, email)
|
||||||
}
|
}
|
||||||
|
|
||||||
rolesJson, _ := json.Marshal(roles)
|
if _, err := sq.Insert("user").Columns(cols...).Values(vals...).RunWith(auth.db).Exec(); err != nil {
|
||||||
_, err := sq.Insert("user").Columns("username", "password", "roles").Values(parts[0], password, string(rolesJson)).RunWith(auth.db).Exec()
|
|
||||||
if err != nil {
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
log.Infof("new user %#v added (roles: %s)", parts[0], roles)
|
|
||||||
|
log.Infof("new user %#v created (roles: %s)", username, roles)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -164,6 +178,40 @@ func (auth *Authentication) DelUser(username string) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (auth *Authentication) FetchUsers(viaLdap bool) ([]*User, error) {
|
||||||
|
q := sq.Select("username", "name", "email", "roles").From("user")
|
||||||
|
if !viaLdap {
|
||||||
|
q = q.Where("ldap = 0")
|
||||||
|
} else {
|
||||||
|
q = q.Where("ldap = 1")
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, err := q.RunWith(auth.db).Query()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
users := make([]*User, 0)
|
||||||
|
defer rows.Close()
|
||||||
|
for rows.Next() {
|
||||||
|
rawroles := ""
|
||||||
|
user := &User{}
|
||||||
|
var name, email sql.NullString
|
||||||
|
if err := rows.Scan(&user.Username, &name, &email, &rawroles); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := json.Unmarshal([]byte(rawroles), &user.Roles); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
user.Name = name.String
|
||||||
|
user.Email = email.String
|
||||||
|
users = append(users, user)
|
||||||
|
}
|
||||||
|
return users, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (auth *Authentication) FetchUser(username string) (*User, error) {
|
func (auth *Authentication) FetchUser(username string) (*User, error) {
|
||||||
user := &User{Username: username}
|
user := &User{Username: username}
|
||||||
var hashedPassword, name, rawRoles, email sql.NullString
|
var hashedPassword, name, rawRoles, email sql.NullString
|
||||||
|
@ -132,6 +132,7 @@ func GetUIConfig(r *http.Request) (map[string]interface{}, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
size := 0
|
size := 0
|
||||||
|
defer rows.Close()
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var key, rawval string
|
var key, rawval string
|
||||||
if err := rows.Scan(&key, &rawval); err != nil {
|
if err := rows.Scan(&key, &rawval); err != nil {
|
||||||
@ -173,12 +174,16 @@ func UpdateConfig(key, value string, ctx context.Context) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
cache.Del(user.Username)
|
if _, ok := uiDefaults[key]; !ok {
|
||||||
|
return errors.New("this configuration key does not exist")
|
||||||
|
}
|
||||||
|
|
||||||
if _, err := db.Exec(`REPLACE INTO configuration (username, confkey, value) VALUES (?, ?, ?)`,
|
if _, err := db.Exec(`REPLACE INTO configuration (username, confkey, value) VALUES (?, ?, ?)`,
|
||||||
user.Username, key, value); err != nil {
|
user.Username, key, value); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
cache.Del(user.Username)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
2
frontend
2
frontend
@ -1 +1 @@
|
|||||||
Subproject commit 984532dbf2a05d670c90a16cba9c93de813e304a
|
Subproject commit 93ec75b801215d9b4175cb32dd485217b1cbe810
|
@ -1379,6 +1379,8 @@ input JobFilter {
|
|||||||
partition: StringInput
|
partition: StringInput
|
||||||
duration: IntRange
|
duration: IntRange
|
||||||
|
|
||||||
|
minRunningFor: Int
|
||||||
|
|
||||||
numNodes: IntRange
|
numNodes: IntRange
|
||||||
numAccelerators: IntRange
|
numAccelerators: IntRange
|
||||||
numHWThreads: IntRange
|
numHWThreads: IntRange
|
||||||
@ -7218,6 +7220,14 @@ func (ec *executionContext) unmarshalInputJobFilter(ctx context.Context, obj int
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return it, err
|
return it, err
|
||||||
}
|
}
|
||||||
|
case "minRunningFor":
|
||||||
|
var err error
|
||||||
|
|
||||||
|
ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("minRunningFor"))
|
||||||
|
it.MinRunningFor, err = ec.unmarshalOInt2ᚖint(ctx, v)
|
||||||
|
if err != nil {
|
||||||
|
return it, err
|
||||||
|
}
|
||||||
case "numNodes":
|
case "numNodes":
|
||||||
var err error
|
var err error
|
||||||
|
|
||||||
|
@ -57,6 +57,7 @@ type JobFilter struct {
|
|||||||
Cluster *StringInput `json:"cluster"`
|
Cluster *StringInput `json:"cluster"`
|
||||||
Partition *StringInput `json:"partition"`
|
Partition *StringInput `json:"partition"`
|
||||||
Duration *IntRange `json:"duration"`
|
Duration *IntRange `json:"duration"`
|
||||||
|
MinRunningFor *int `json:"minRunningFor"`
|
||||||
NumNodes *IntRange `json:"numNodes"`
|
NumNodes *IntRange `json:"numNodes"`
|
||||||
NumAccelerators *IntRange `json:"numAccelerators"`
|
NumAccelerators *IntRange `json:"numAccelerators"`
|
||||||
NumHWThreads *IntRange `json:"numHWThreads"`
|
NumHWThreads *IntRange `json:"numHWThreads"`
|
||||||
|
@ -179,6 +179,8 @@ input JobFilter {
|
|||||||
partition: StringInput
|
partition: StringInput
|
||||||
duration: IntRange
|
duration: IntRange
|
||||||
|
|
||||||
|
minRunningFor: Int
|
||||||
|
|
||||||
numNodes: IntRange
|
numNodes: IntRange
|
||||||
numAccelerators: IntRange
|
numAccelerators: IntRange
|
||||||
numHWThreads: IntRange
|
numHWThreads: IntRange
|
||||||
|
@ -23,6 +23,8 @@ var groupBy2column = map[model.Aggregate]string{
|
|||||||
model.AggregateCluster: "job.cluster",
|
model.AggregateCluster: "job.cluster",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const ShortJobDuration int = 180
|
||||||
|
|
||||||
// Helper function for the jobsStatistics GraphQL query placed here so that schema.resolvers.go is not too full.
|
// Helper function for the jobsStatistics GraphQL query placed here so that schema.resolvers.go is not too full.
|
||||||
func (r *queryResolver) jobsStatistics(ctx context.Context, filter []*model.JobFilter, groupBy *model.Aggregate) ([]*model.JobsStatistics, error) {
|
func (r *queryResolver) jobsStatistics(ctx context.Context, filter []*model.JobFilter, groupBy *model.Aggregate) ([]*model.JobsStatistics, error) {
|
||||||
// In case `groupBy` is nil (not used), the model.JobsStatistics used is at the key '' (empty string)
|
// In case `groupBy` is nil (not used), the model.JobsStatistics used is at the key '' (empty string)
|
||||||
@ -90,7 +92,7 @@ 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 < 120")
|
query := sq.Select("COUNT(job.id)").From("job").Where("job.duration < ?", ShortJobDuration)
|
||||||
query = repository.SecurityCheck(ctx, query)
|
query = repository.SecurityCheck(ctx, query)
|
||||||
for _, f := range filter {
|
for _, f := range filter {
|
||||||
query = repository.BuildWhereClause(f, query)
|
query = repository.BuildWhereClause(f, query)
|
||||||
@ -100,7 +102,7 @@ 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 < 120")
|
query := sq.Select(col, "COUNT(job.id)").From("job").Where("job.duration < ?", ShortJobDuration)
|
||||||
query = repository.SecurityCheck(ctx, query)
|
query = repository.SecurityCheck(ctx, query)
|
||||||
for _, f := range filter {
|
for _, f := range filter {
|
||||||
query = repository.BuildWhereClause(f, query)
|
query = repository.BuildWhereClause(f, query)
|
||||||
|
@ -60,6 +60,7 @@ func (r *JobRepository) QueryJobs(
|
|||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
job, err := scanJob(rows)
|
job, err := scanJob(rows)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
rows.Close()
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
jobs = append(jobs, job)
|
jobs = append(jobs, job)
|
||||||
@ -126,6 +127,10 @@ func BuildWhereClause(filter *model.JobFilter, query sq.SelectBuilder) sq.Select
|
|||||||
now := time.Now().Unix() // There does not seam to be a portable way to get the current unix timestamp accross different DBs.
|
now := time.Now().Unix() // There does not seam to be a portable way to get the current unix timestamp accross different DBs.
|
||||||
query = query.Where("(CASE WHEN job.job_state = 'running' THEN (? - job.start_time) ELSE job.duration END) BETWEEN ? AND ?", now, filter.Duration.From, filter.Duration.To)
|
query = query.Where("(CASE WHEN job.job_state = 'running' THEN (? - job.start_time) ELSE job.duration END) BETWEEN ? AND ?", now, filter.Duration.From, filter.Duration.To)
|
||||||
}
|
}
|
||||||
|
if filter.MinRunningFor != nil {
|
||||||
|
now := time.Now().Unix() // There does not seam to be a portable way to get the current unix timestamp accross different DBs.
|
||||||
|
query = query.Where("(job.job_state != 'running' OR (? - job.start_time) > ?)", now, *filter.MinRunningFor)
|
||||||
|
}
|
||||||
if filter.State != nil {
|
if filter.State != nil {
|
||||||
states := make([]string, len(filter.State))
|
states := make([]string, len(filter.State))
|
||||||
for i, val := range filter.State {
|
for i, val := range filter.State {
|
||||||
|
@ -1,8 +1,6 @@
|
|||||||
package repository
|
package repository
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
|
||||||
|
|
||||||
"github.com/ClusterCockpit/cc-backend/metricdata"
|
"github.com/ClusterCockpit/cc-backend/metricdata"
|
||||||
"github.com/ClusterCockpit/cc-backend/schema"
|
"github.com/ClusterCockpit/cc-backend/schema"
|
||||||
sq "github.com/Masterminds/squirrel"
|
sq "github.com/Masterminds/squirrel"
|
||||||
@ -88,9 +86,8 @@ func (r *JobRepository) CountTags(user *string) (tags []schema.Tag, counts map[s
|
|||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var tagName string
|
var tagName string
|
||||||
var count int
|
var count int
|
||||||
err = rows.Scan(&tagName, &count)
|
if err := rows.Scan(&tagName, &count); err != nil {
|
||||||
if err != nil {
|
return nil, nil, err
|
||||||
fmt.Println(err)
|
|
||||||
}
|
}
|
||||||
counts[tagName] = count
|
counts[tagName] = count
|
||||||
}
|
}
|
||||||
|
@ -100,9 +100,10 @@ func setupRoutes(router *mux.Router, routes []Route) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
title := route.Title
|
||||||
infos := route.Setup(map[string]interface{}{}, r)
|
infos := route.Setup(map[string]interface{}{}, r)
|
||||||
if id, ok := infos["id"]; ok {
|
if id, ok := infos["id"]; ok {
|
||||||
route.Title = strings.Replace(route.Title, "<ID>", id.(string), 1)
|
title = strings.Replace(route.Title, "<ID>", id.(string), 1)
|
||||||
}
|
}
|
||||||
|
|
||||||
username, isAdmin := "", true
|
username, isAdmin := "", true
|
||||||
@ -112,7 +113,7 @@ func setupRoutes(router *mux.Router, routes []Route) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
page := templates.Page{
|
page := templates.Page{
|
||||||
Title: route.Title,
|
Title: title,
|
||||||
User: templates.User{Username: username, IsAdmin: isAdmin},
|
User: templates.User{Username: username, IsAdmin: isAdmin},
|
||||||
Config: conf,
|
Config: conf,
|
||||||
Infos: infos,
|
Infos: infos,
|
||||||
|
@ -113,6 +113,7 @@ var programConfig ProgramConfig = ProgramConfig{
|
|||||||
"plot_general_colorBackground": true,
|
"plot_general_colorBackground": true,
|
||||||
"plot_general_colorscheme": []string{"#00bfff", "#0000ff", "#ff00ff", "#ff0000", "#ff8000", "#ffff00", "#80ff00"},
|
"plot_general_colorscheme": []string{"#00bfff", "#0000ff", "#ff00ff", "#ff0000", "#ff8000", "#ffff00", "#80ff00"},
|
||||||
"plot_general_lineWidth": 1,
|
"plot_general_lineWidth": 1,
|
||||||
|
"plot_list_hideShortRunningJobs": 5 * 60,
|
||||||
"plot_list_jobsPerPage": 10,
|
"plot_list_jobsPerPage": 10,
|
||||||
"plot_list_selectedMetrics": []string{"cpu_load", "ipc", "mem_used", "flops_any", "mem_bw"},
|
"plot_list_selectedMetrics": []string{"cpu_load", "ipc", "mem_used", "flops_any", "mem_bw"},
|
||||||
"plot_view_plotsPerRow": 3,
|
"plot_view_plotsPerRow": 3,
|
||||||
@ -225,6 +226,7 @@ func setupTaglistRoute(i InfoType, r *http.Request) InfoType {
|
|||||||
|
|
||||||
var routes []Route = []Route{
|
var routes []Route = []Route{
|
||||||
{"/", "home.tmpl", "ClusterCockpit", false, setupHomeRoute},
|
{"/", "home.tmpl", "ClusterCockpit", false, setupHomeRoute},
|
||||||
|
{"/config", "config.tmpl", "Settings", false, func(i InfoType, r *http.Request) InfoType { return i }},
|
||||||
{"/monitoring/jobs/", "monitoring/jobs.tmpl", "Jobs - ClusterCockpit", true, func(i InfoType, r *http.Request) InfoType { return i }},
|
{"/monitoring/jobs/", "monitoring/jobs.tmpl", "Jobs - ClusterCockpit", true, func(i InfoType, r *http.Request) InfoType { return i }},
|
||||||
{"/monitoring/job/{id:[0-9]+}", "monitoring/job.tmpl", "Job <ID> - ClusterCockpit", false, setupJobRoute},
|
{"/monitoring/job/{id:[0-9]+}", "monitoring/job.tmpl", "Job <ID> - ClusterCockpit", false, setupJobRoute},
|
||||||
{"/monitoring/users/", "monitoring/list.tmpl", "Users - ClusterCockpit", true, func(i InfoType, r *http.Request) InfoType { i["listType"] = "USER"; return i }},
|
{"/monitoring/users/", "monitoring/list.tmpl", "Users - ClusterCockpit", true, func(i InfoType, r *http.Request) InfoType { i["listType"] = "USER"; return i }},
|
||||||
@ -295,8 +297,9 @@ func main() {
|
|||||||
|
|
||||||
// Initialize sub-modules...
|
// Initialize sub-modules...
|
||||||
|
|
||||||
authentication := &auth.Authentication{}
|
var authentication *auth.Authentication
|
||||||
if !programConfig.DisableAuthentication {
|
if !programConfig.DisableAuthentication {
|
||||||
|
authentication = &auth.Authentication{}
|
||||||
if d, err := time.ParseDuration(programConfig.SessionMaxAge); err != nil {
|
if d, err := time.ParseDuration(programConfig.SessionMaxAge); err != nil {
|
||||||
authentication.SessionMaxAge = d
|
authentication.SessionMaxAge = d
|
||||||
}
|
}
|
||||||
@ -397,6 +400,7 @@ func main() {
|
|||||||
JobRepository: jobRepo,
|
JobRepository: jobRepo,
|
||||||
Resolver: resolver,
|
Resolver: resolver,
|
||||||
MachineStateDir: programConfig.MachineStateDir,
|
MachineStateDir: programConfig.MachineStateDir,
|
||||||
|
Authentication: authentication,
|
||||||
}
|
}
|
||||||
|
|
||||||
handleGetLogin := func(rw http.ResponseWriter, r *http.Request) {
|
handleGetLogin := func(rw http.ResponseWriter, r *http.Request) {
|
||||||
|
234
templates/config.tmpl
Normal file
234
templates/config.tmpl
Normal file
@ -0,0 +1,234 @@
|
|||||||
|
{{define "content"}}
|
||||||
|
{{if .User.IsAdmin}}
|
||||||
|
<div class="row">
|
||||||
|
<div class="col card" style="margin: 10px;">
|
||||||
|
<form id="create-user-form" method="post" action="/api/users/" class="card-body">
|
||||||
|
<h5 class="card-title">Create User</h5>
|
||||||
|
<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">
|
||||||
|
<label for="username" class="form-label">Username</label>
|
||||||
|
<input type="text" class="form-control" id="username" name="username" aria-describedby="usernameHelp"/>
|
||||||
|
<div id="usernameHelp" class="form-text">Must be unique.</div>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="password" class="form-label">Password</label>
|
||||||
|
<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>
|
||||||
|
<div class="mb-3">
|
||||||
|
<p>Role:</p>
|
||||||
|
<div>
|
||||||
|
<input type="radio" id="user" name="role" value="user" checked/>
|
||||||
|
<label for="user">User (regular user, same as if created via LDAP sync.)</label>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<input type="radio" id="api" name="role" value="api"/>
|
||||||
|
<label for="api">API</label>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<input type="radio" id="admin" name="role" value="admin"/>
|
||||||
|
<label for="admin">Admin</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p>
|
||||||
|
<code class="form-result"></code>
|
||||||
|
</p>
|
||||||
|
<button type="submit" class="btn btn-primary">Submit</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<div class="col card" style="margin: 10px;">
|
||||||
|
<div class="card-body">
|
||||||
|
<h5 class="card-title">All Users not Created by an LDAP Sync:</h5>
|
||||||
|
<table class="table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Username</th>
|
||||||
|
<th>Name</th>
|
||||||
|
<th>Email</th>
|
||||||
|
<th>Roles</th>
|
||||||
|
<th>JWT</th>
|
||||||
|
<th>Delete</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="users-list"><tr><td colspan="4"><div class="spinner-border" role="status"><span class="visually-hidden">Loading...</span></div></td></tr></tbody>
|
||||||
|
<script>
|
||||||
|
fetch('/api/users/?via-ldap=false')
|
||||||
|
.then(res => res.json())
|
||||||
|
.then(users => {
|
||||||
|
let listElement = document.querySelector('#users-list')
|
||||||
|
listElement.innerHTML = users.map(user => `
|
||||||
|
<tr id="user-${user.username}">
|
||||||
|
<td>${user.username}</td>
|
||||||
|
<td>${user.name}</td>
|
||||||
|
<td>${user.email}</td>
|
||||||
|
<td><code>${user.roles.join(', ')}</code></td>
|
||||||
|
<td><button class="btn btn-success get-jwt">Gen. JWT</button></td>
|
||||||
|
<td><button class="btn btn-danger del-user">Delete</button></td>
|
||||||
|
</tr>
|
||||||
|
`).join('')
|
||||||
|
|
||||||
|
listElement.querySelectorAll('button.get-jwt').forEach(e => e.addEventListener('click', event => {
|
||||||
|
let row = event.target.parentElement.parentElement
|
||||||
|
let username = row.children[0].innerText
|
||||||
|
let formData = new FormData()
|
||||||
|
formData.append('username', username)
|
||||||
|
fetch('/api/jwt/', { method: 'POST', body: formData })
|
||||||
|
.then(res => res.text())
|
||||||
|
.then(text => alert(text))
|
||||||
|
}))
|
||||||
|
|
||||||
|
listElement.querySelectorAll('button.del-user').forEach(e => e.addEventListener('click', event => {
|
||||||
|
let row = event.target.parentElement.parentElement
|
||||||
|
let username = row.children[0].innerText
|
||||||
|
if (confirm('Are you sure?')) {
|
||||||
|
let formData = new FormData()
|
||||||
|
formData.append('username', username)
|
||||||
|
fetch('/api/users/', { method: 'DELETE', body: formData }).then(res => {
|
||||||
|
if (res.status == 200) {
|
||||||
|
row.remove()
|
||||||
|
} else {
|
||||||
|
event.target.innerText = res.statusText
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
<div class="row">
|
||||||
|
<div class="col card" style="margin: 10px;">
|
||||||
|
<form id="line-width-form" method="post" action="/api/configuration/" class="card-body">
|
||||||
|
<h5 class="card-title">Line Width</h5>
|
||||||
|
<input type="hidden" name="key" value="plot_general_lineWidth"/>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="value" class="form-label">Line Width</label>
|
||||||
|
<input type="number" class="form-control" id="value" name="value" aria-describedby="lineWidthHelp" value="{{ .Config.plot_general_lineWidth }}" min="1"/>
|
||||||
|
<div id="lineWidthHelp" class="form-text">Width of the lines in the timeseries plots.</div>
|
||||||
|
</div>
|
||||||
|
<p>
|
||||||
|
<code class="form-result"></code>
|
||||||
|
</p>
|
||||||
|
<button type="submit" class="btn btn-primary">Submit</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<div class="col card" style="margin: 10px;">
|
||||||
|
<form id="plots-per-row-form" method="post" action="/api/configuration/" class="card-body">
|
||||||
|
<h5 class="card-title">Plots per Row</h5>
|
||||||
|
<input type="hidden" name="key" value="plot_view_plotsPerRow"/>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="value" class="form-label">Plots per Row</label>
|
||||||
|
<input type="number" class="form-control" id="value" name="value" aria-describedby="plotsperrowHelp" value="{{ .Config.plot_view_plotsPerRow }}" min="1"/>
|
||||||
|
<div id="plotsperrowHelp" class="form-text">How many plots to show next to each other on pages such as /monitoring/job/, /monitoring/system/...</div>
|
||||||
|
</div>
|
||||||
|
<p>
|
||||||
|
<code class="form-result"></code>
|
||||||
|
</p>
|
||||||
|
<button type="submit" class="btn btn-primary">Submit</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<div class="col card" style="margin: 10px;">
|
||||||
|
<form id="backgrounds-form" method="post" action="/api/configuration/" class="card-body">
|
||||||
|
<h5 class="card-title">Colored Backgrounds</h5>
|
||||||
|
<input type="hidden" name="key" value="plot_general_colorBackground"/>
|
||||||
|
<div class="mb-3">
|
||||||
|
<div>
|
||||||
|
<input type="radio" id="true" name="value" value="true" {{if .Config.plot_general_colorBackground}}checked{{else}}{{end}} />
|
||||||
|
<label for="true">Yes</label>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<input type="radio" id="false" name="value" value="false" {{if .Config.plot_general_colorBackground}}{{else}}checked{{end}} />
|
||||||
|
<label for="false">No</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p>
|
||||||
|
<code class="form-result"></code>
|
||||||
|
</p>
|
||||||
|
<button type="submit" class="btn btn-primary">Submit</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<div class="col card" style="margin: 10px;">
|
||||||
|
<form id="colorscheme-form" method="post" action="/api/configuration/" class="card-body">
|
||||||
|
<h5 class="card-title">Colorscheme for Timeseries Plots</h5>
|
||||||
|
<input type="hidden" name="key" value="plot_general_colorscheme"/>
|
||||||
|
<style>
|
||||||
|
.color-dot {
|
||||||
|
height: 10px;
|
||||||
|
width: 10px;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<table class="table">
|
||||||
|
<script>
|
||||||
|
const colorschemes = {
|
||||||
|
'Default': ["#00bfff","#0000ff","#ff00ff","#ff0000","#ff8000","#ffff00","#80ff00"],
|
||||||
|
'Autumn': ['rgb(255,0,0)','rgb(255,11,0)','rgb(255,20,0)','rgb(255,30,0)','rgb(255,41,0)','rgb(255,50,0)','rgb(255,60,0)','rgb(255,71,0)','rgb(255,80,0)','rgb(255,90,0)','rgb(255,101,0)','rgb(255,111,0)','rgb(255,120,0)','rgb(255,131,0)','rgb(255,141,0)','rgb(255,150,0)','rgb(255,161,0)','rgb(255,171,0)','rgb(255,180,0)','rgb(255,190,0)','rgb(255,201,0)','rgb(255,210,0)','rgb(255,220,0)','rgb(255,231,0)','rgb(255,240,0)','rgb(255,250,0)'],
|
||||||
|
'Beach': ['rgb(0,252,0)','rgb(0,233,0)','rgb(0,212,0)','rgb(0,189,0)','rgb(0,169,0)','rgb(0,148,0)','rgb(0,129,4)','rgb(0,145,46)','rgb(0,162,90)','rgb(0,180,132)','rgb(29,143,136)','rgb(73,88,136)','rgb(115,32,136)','rgb(81,9,64)','rgb(124,51,23)','rgb(162,90,0)','rgb(194,132,0)','rgb(220,171,0)','rgb(231,213,0)','rgb(0,0,13)','rgb(0,0,55)','rgb(0,0,92)','rgb(0,0,127)','rgb(0,0,159)','rgb(0,0,196)','rgb(0,0,233)'],
|
||||||
|
'BlueRed': ['rgb(0,0,131)','rgb(0,0,168)','rgb(0,0,208)','rgb(0,0,247)','rgb(0,27,255)','rgb(0,67,255)','rgb(0,108,255)','rgb(0,148,255)','rgb(0,187,255)','rgb(0,227,255)','rgb(8,255,247)','rgb(48,255,208)','rgb(87,255,168)','rgb(127,255,127)','rgb(168,255,87)','rgb(208,255,48)','rgb(247,255,8)','rgb(255,224,0)','rgb(255,183,0)','rgb(255,143,0)','rgb(255,104,0)','rgb(255,64,0)','rgb(255,23,0)','rgb(238,0,0)','rgb(194,0,0)','rgb(150,0,0)'],
|
||||||
|
'Rainbow': ['rgb(125,0,255)','rgb(85,0,255)','rgb(39,0,255)','rgb(0,6,255)','rgb(0,51,255)','rgb(0,97,255)','rgb(0,141,255)','rgb(0,187,255)','rgb(0,231,255)','rgb(0,255,233)','rgb(0,255,189)','rgb(0,255,143)','rgb(0,255,99)','rgb(0,255,53)','rgb(0,255,9)','rgb(37,255,0)','rgb(83,255,0)','rgb(127,255,0)','rgb(173,255,0)','rgb(217,255,0)','rgb(255,248,0)','rgb(255,203,0)','rgb(255,159,0)','rgb(255,113,0)','rgb(255,69,0)','rgb(255,23,0)'],
|
||||||
|
'Binary': ['rgb(215,215,215)','rgb(206,206,206)','rgb(196,196,196)','rgb(185,185,185)','rgb(176,176,176)','rgb(166,166,166)','rgb(155,155,155)','rgb(145,145,145)','rgb(136,136,136)','rgb(125,125,125)','rgb(115,115,115)','rgb(106,106,106)','rgb(95,95,95)','rgb(85,85,85)','rgb(76,76,76)','rgb(66,66,66)','rgb(55,55,55)','rgb(46,46,46)','rgb(36,36,36)','rgb(25,25,25)','rgb(16,16,16)','rgb(6,6,6)'],
|
||||||
|
'GistEarth': ['rgb(0,0,0)','rgb(2,7,117)','rgb(9,30,118)','rgb(16,53,120)','rgb(23,73,122)','rgb(31,93,124)','rgb(39,110,125)','rgb(47,126,127)','rgb(51,133,119)','rgb(57,138,106)','rgb(62,145,94)','rgb(66,150,82)','rgb(74,157,71)','rgb(97,162,77)','rgb(121,168,83)','rgb(136,173,85)','rgb(153,176,88)','rgb(170,180,92)','rgb(185,182,94)','rgb(189,173,99)','rgb(192,164,101)','rgb(203,169,124)','rgb(215,178,149)','rgb(226,192,176)','rgb(238,212,204)','rgb(248,236,236)'],
|
||||||
|
'BlueWaves': ['rgb(83,0,215)','rgb(43,6,108)','rgb(9,16,16)','rgb(8,32,25)','rgb(0,50,8)','rgb(27,64,66)','rgb(69,67,178)','rgb(115,62,210)','rgb(155,50,104)','rgb(178,43,41)','rgb(180,51,34)','rgb(161,78,87)','rgb(124,117,187)','rgb(78,155,203)','rgb(34,178,85)','rgb(4,176,2)','rgb(9,152,27)','rgb(4,118,2)','rgb(34,92,85)','rgb(78,92,203)','rgb(124,127,187)','rgb(161,187,87)','rgb(180,248,34)','rgb(178,220,41)','rgb(155,217,104)','rgb(115,254,210)'],
|
||||||
|
'BlueGreenRedYellow': ['rgb(0,0,0)','rgb(0,0,20)','rgb(0,0,41)','rgb(0,0,62)','rgb(0,25,83)','rgb(0,57,101)','rgb(0,87,101)','rgb(0,118,101)','rgb(0,150,101)','rgb(0,150,69)','rgb(0,148,37)','rgb(0,141,6)','rgb(60,120,0)','rgb(131,87,0)','rgb(180,25,0)','rgb(203,13,0)','rgb(208,36,0)','rgb(213,60,0)','rgb(219,83,0)','rgb(224,106,0)','rgb(229,129,0)','rgb(233,152,0)','rgb(238,176,0)','rgb(243,199,0)','rgb(248,222,0)','rgb(254,245,0)']
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const name in colorschemes) {
|
||||||
|
const colorscheme = colorschemes[name]
|
||||||
|
const json = JSON.stringify(colorscheme)
|
||||||
|
const checked = json == `{{ .Config.plot_general_colorscheme }}`
|
||||||
|
document.write(
|
||||||
|
`<tr>
|
||||||
|
<th scope="col">${name}</th>
|
||||||
|
<td>
|
||||||
|
<input type="radio" name="value" value='${json}' ${checked ? 'checked' : ''}/>
|
||||||
|
</td>
|
||||||
|
<td>${colorscheme.map(color => `<span class="color-dot" style="background-color: ${color};"></span>`).join('')}</td>
|
||||||
|
</tr>`)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</table>
|
||||||
|
<p>
|
||||||
|
<code class="form-result"></code>
|
||||||
|
</p>
|
||||||
|
<button type="submit" class="btn btn-primary">Submit</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<script>
|
||||||
|
const handleSubmit = (formSel) => {
|
||||||
|
let form = document.querySelector(formSel)
|
||||||
|
form.addEventListener('submit', event => {
|
||||||
|
event.preventDefault()
|
||||||
|
let formData = new FormData(form)
|
||||||
|
|
||||||
|
fetch(form.action, { method: 'POST', body: formData })
|
||||||
|
.then(res => res.text())
|
||||||
|
.then(text => form.querySelector('.form-result').innerText = text)
|
||||||
|
.catch(err => form.querySelector('.form-result').innerText = err.toString())
|
||||||
|
|
||||||
|
return false
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
handleSubmit('#create-user-form')
|
||||||
|
handleSubmit('#line-width-form')
|
||||||
|
handleSubmit('#plots-per-row-form')
|
||||||
|
handleSubmit('#backgrounds-form')
|
||||||
|
handleSubmit('#colorscheme-form')
|
||||||
|
</script>
|
||||||
|
{{end}}
|
@ -41,6 +41,7 @@ func init() {
|
|||||||
files := []string{
|
files := []string{
|
||||||
"home.tmpl", "404.tmpl", "login.tmpl",
|
"home.tmpl", "404.tmpl", "login.tmpl",
|
||||||
"imprint.tmpl", "privacy.tmpl",
|
"imprint.tmpl", "privacy.tmpl",
|
||||||
|
"config.tmpl",
|
||||||
"monitoring/jobs.tmpl",
|
"monitoring/jobs.tmpl",
|
||||||
"monitoring/job.tmpl",
|
"monitoring/job.tmpl",
|
||||||
"monitoring/taglist.tmpl",
|
"monitoring/taglist.tmpl",
|
||||||
|
Loading…
Reference in New Issue
Block a user