Merge pull request #2177 from semaphoreui/shell_app

Apps management
This commit is contained in:
Denis Gukov 2024-07-10 02:09:56 +05:00 committed by GitHub
commit da6fa99980
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
51 changed files with 1522 additions and 1217 deletions

View File

@ -136,6 +136,7 @@ func resolveCapability(caps []string, resolved []string, uid string) {
AllowOverrideArgsInTask: false,
Description: &desc,
ViewID: &view.ID,
App: db.TemplateAnsible,
})
printError(err)

View File

@ -703,6 +703,9 @@ definitions:
example: ''
suppress_success_alerts:
type: boolean
app:
type: string
example: ansible
survey_vars:
type: array
items:

210
api/apps.go Normal file
View File

@ -0,0 +1,210 @@
package api
import (
"errors"
"fmt"
"github.com/ansible-semaphore/semaphore/api/helpers"
"github.com/ansible-semaphore/semaphore/db"
"github.com/ansible-semaphore/semaphore/util"
"github.com/gorilla/context"
"net/http"
"reflect"
"strings"
)
func structToFlatMap(obj interface{}) map[string]interface{} {
result := make(map[string]interface{})
val := reflect.ValueOf(obj)
typ := reflect.TypeOf(obj)
if typ.Kind() == reflect.Ptr {
val = val.Elem()
typ = typ.Elem()
}
if typ.Kind() != reflect.Struct {
return result
}
// Iterate over the struct fields
for i := 0; i < val.NumField(); i++ {
field := val.Field(i)
fieldType := typ.Field(i)
jsonTag := fieldType.Tag.Get("json")
// Use the json tag if it is set, otherwise use the field name
fieldName := jsonTag
if fieldName == "" || fieldName == "-" {
fieldName = fieldType.Name
} else {
// Handle the case where the json tag might have options like `json:"name,omitempty"`
fieldName = strings.Split(fieldName, ",")[0]
}
// Check if the field is a struct itself
if field.Kind() == reflect.Struct {
// Convert nested struct to map
nestedMap := structToFlatMap(field.Interface())
// Add nested map to result with a prefixed key
for k, v := range nestedMap {
result[fieldName+"."+k] = v
}
} else {
result[fieldName] = field.Interface()
}
}
return result
}
func validateAppID(str string) error {
return nil
}
func appMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
appID, err := helpers.GetStrParam("app_id", w, r)
if err != nil {
helpers.WriteErrorStatus(w, err.Error(), http.StatusBadRequest)
}
if err := validateAppID(appID); err != nil {
helpers.WriteErrorStatus(w, err.Error(), http.StatusBadRequest)
return
}
context.Set(r, "app_id", appID)
next.ServeHTTP(w, r)
})
}
func getApps(w http.ResponseWriter, r *http.Request) {
defaultApps := map[string]util.App{
string(db.TemplateAnsible): {},
string(db.TemplateTerraform): {},
string(db.TemplateTofu): {},
string(db.TemplateBash): {},
string(db.TemplatePowerShell): {},
string(db.TemplatePython): {},
}
for k, a := range util.Config.Apps {
defaultApps[k] = a
}
type app struct {
ID string `json:"id"`
Title string `json:"title"`
Icon string `json:"icon"`
Color string `json:"color"`
DarkColor string `json:"dark_color"`
Active bool `json:"active"`
}
apps := make([]app, 0)
for k, a := range defaultApps {
apps = append(apps, app{
ID: k,
Title: a.Title,
Icon: a.Icon,
Color: a.Color,
DarkColor: a.DarkColor,
Active: a.Active,
})
}
helpers.WriteJSON(w, http.StatusOK, apps)
}
func getApp(w http.ResponseWriter, r *http.Request) {
appID := context.Get(r, "app_id").(string)
app, ok := util.Config.Apps[appID]
if !ok {
helpers.WriteErrorStatus(w, "app not found", http.StatusNotFound)
return
}
helpers.WriteJSON(w, http.StatusOK, app)
}
func deleteApp(w http.ResponseWriter, r *http.Request) {
appID := context.Get(r, "app_id").(string)
store := helpers.Store(r)
err := store.DeleteOptions("apps." + appID)
if err != nil && !errors.Is(err, db.ErrNotFound) {
helpers.WriteErrorStatus(w, err.Error(), http.StatusInternalServerError)
return
}
delete(util.Config.Apps, appID)
w.WriteHeader(http.StatusNoContent)
}
func setAppOption(store db.Store, appID string, field string, val interface{}) error {
key := "apps." + appID + "." + field
v := fmt.Sprintf("%v", val)
if err := store.SetOption(key, v); err != nil {
return err
}
opts := make(map[string]string)
opts[key] = v
options := db.ConvertFlatToNested(opts)
_ = db.AssignMapToStruct(options, util.Config)
return nil
}
func setApp(w http.ResponseWriter, r *http.Request) {
appID := context.Get(r, "app_id").(string)
store := helpers.Store(r)
var app util.App
if !helpers.Bind(w, r, &app) {
return
}
options := structToFlatMap(app)
for k, v := range options {
if err := setAppOption(store, appID, k, v); err != nil {
helpers.WriteErrorStatus(w, err.Error(), http.StatusInternalServerError)
return
}
}
w.WriteHeader(http.StatusNoContent)
}
func setAppActive(w http.ResponseWriter, r *http.Request) {
appID := context.Get(r, "app_id").(string)
store := helpers.Store(r)
var body struct {
Active bool `json:"active"`
}
if !helpers.Bind(w, r, &body) {
return
}
if err := setAppOption(store, appID, "active", body.Active); err != nil {
helpers.WriteErrorStatus(w, err.Error(), http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusNoContent)
}

42
api/apps_test.go Normal file
View File

@ -0,0 +1,42 @@
package api
import (
"fmt"
"testing"
)
func TestStructToMap(t *testing.T) {
type Address struct {
City string `json:"city"`
State string `json:"state"`
}
type Person struct {
Name string `json:"name"`
Age int `json:"age"`
Email string `json:"email"`
Active bool `json:"active"`
Address Address `json:"address"`
}
// Create an instance of the struct
p := Person{
Name: "John Doe",
Age: 30,
Email: "johndoe@example.com",
Active: true,
Address: Address{
City: "New York",
State: "NY",
},
}
// Convert the struct to a flat map
flatMap := structToFlatMap(&p)
if flatMap["address.city"] != "New York" {
t.Fail()
}
// Print the map
fmt.Println(flatMap)
}

View File

@ -1,11 +1,11 @@
package api
import (
log "github.com/sirupsen/logrus"
"github.com/ansible-semaphore/semaphore/api/helpers"
"github.com/ansible-semaphore/semaphore/db"
"github.com/ansible-semaphore/semaphore/util"
"github.com/gorilla/context"
log "github.com/sirupsen/logrus"
"net/http"
"strings"
"time"
@ -120,3 +120,16 @@ func authenticationWithStore(next http.Handler) http.Handler {
}
})
}
func adminMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
user := context.Get(r, "user").(*db.User)
if !user.Admin {
w.WriteHeader(http.StatusForbidden)
return
}
next.ServeHTTP(w, r)
})
}

View File

@ -58,7 +58,7 @@ func ReceiveIntegration(w http.ResponseWriter, r *http.Request) {
var integrations []db.Integration
if util.Config.GlobalIntegrationAlias != "" && integrationAlias == util.Config.GlobalIntegrationAlias {
if util.Config.IntegrationAlias != "" && integrationAlias == util.Config.IntegrationAlias {
integrations, err = helpers.Store(r).GetAllSearchableIntegrations()
} else {
integrations, err = helpers.Store(r).GetIntegrationsByAlias(integrationAlias)

View File

@ -65,7 +65,7 @@ func tryFindLDAPUser(username, password string) (*db.User, error) {
return nil, err
}
// Search for the given username
// Filter for the given username
searchRequest := ldap.NewSearchRequest(
util.Config.LdapSearchDN,
ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 0, 0, false,

55
api/options.go Normal file
View File

@ -0,0 +1,55 @@
package api
import (
"github.com/ansible-semaphore/semaphore/api/helpers"
"github.com/ansible-semaphore/semaphore/db"
"github.com/gorilla/context"
"net/http"
)
func setOption(w http.ResponseWriter, r *http.Request) {
currentUser := context.Get(r, "user").(*db.User)
if !currentUser.Admin {
helpers.WriteJSON(w, http.StatusForbidden, map[string]string{
"error": "User must be admin",
})
return
}
var option db.Option
if !helpers.Bind(w, r, &option) {
return
}
err := helpers.Store(r).SetOption(option.Key, option.Value)
if err != nil {
helpers.WriteJSON(w, http.StatusInternalServerError, map[string]string{
"error": "Can not set option",
})
return
}
helpers.WriteJSON(w, http.StatusOK, option)
}
func getOptions(w http.ResponseWriter, r *http.Request) {
currentUser := context.Get(r, "user").(*db.User)
if !currentUser.Admin {
helpers.WriteJSON(w, http.StatusForbidden, map[string]string{
"error": "User must be admin",
})
return
}
options, err := helpers.Store(r).GetOptions(db.RetrieveQueryParams{})
if err != nil {
helpers.WriteJSON(w, http.StatusInternalServerError, map[string]string{
"error": "Can not get options",
})
return
}
helpers.WriteJSON(w, http.StatusOK, options)
}

View File

@ -102,7 +102,6 @@ func Route() *mux.Router {
authenticatedWS.Path("/ws").HandlerFunc(sockets.Handler).Methods("GET", "HEAD")
authenticatedAPI := r.PathPrefix(webPath + "api").Subrouter()
authenticatedAPI.Use(StoreMiddleware, JSONMiddleware, authentication)
authenticatedAPI.Path("/info").HandlerFunc(getSystemInfo).Methods("GET", "HEAD")
@ -117,11 +116,25 @@ func Route() *mux.Router {
authenticatedAPI.Path("/users").HandlerFunc(addUser).Methods("POST")
authenticatedAPI.Path("/user").HandlerFunc(getUser).Methods("GET", "HEAD")
authenticatedAPI.Path("/apps").HandlerFunc(getApps).Methods("GET", "HEAD")
tokenAPI := authenticatedAPI.PathPrefix("/user").Subrouter()
tokenAPI.Path("/tokens").HandlerFunc(getAPITokens).Methods("GET", "HEAD")
tokenAPI.Path("/tokens").HandlerFunc(createAPIToken).Methods("POST")
tokenAPI.HandleFunc("/tokens/{token_id}", expireAPIToken).Methods("DELETE")
adminAPI := authenticatedAPI.NewRoute().Subrouter()
adminAPI.Use(adminMiddleware)
adminAPI.Path("/options").HandlerFunc(getOptions).Methods("GET", "HEAD")
adminAPI.Path("/options").HandlerFunc(setOption).Methods("POST")
appsAPI := adminAPI.PathPrefix("/apps").Subrouter()
appsAPI.Use(appMiddleware)
appsAPI.Path("/{app_id}").HandlerFunc(getApp).Methods("GET", "HEAD")
appsAPI.Path("/{app_id}").HandlerFunc(setApp).Methods("PUT", "POST")
appsAPI.Path("/{app_id}/active").HandlerFunc(setAppActive).Methods("POST")
appsAPI.Path("/{app_id}").HandlerFunc(deleteApp).Methods("DELETE")
userAPI := authenticatedAPI.Path("/users/{user_id}").Subrouter()
userAPI.Use(getUserMiddleware)

View File

@ -108,5 +108,13 @@ func createStore(token string) db.Store {
panic(err)
}
err = db.FillConfigFromDB(store)
if err != nil {
panic(err)
}
util.CheckDefaultApps()
return store
}

View File

@ -70,6 +70,7 @@ func GetMigrations() []Migration {
{Version: "2.9.100"},
{Version: "2.10.12"},
{Version: "2.10.15"},
{Version: "2.10.16"},
}
}

View File

@ -1,6 +1,24 @@
package db
import (
"fmt"
"regexp"
)
type Option struct {
Key string `db:"key" json:"key"`
Value string `db:"value" json:"value"`
}
func ValidateOptionKey(key string) error {
m, err := regexp.Match(`^[\w.]+$`, []byte(key))
if err != nil {
return err
}
if !m {
return fmt.Errorf("invalid key format")
}
return nil
}

View File

@ -38,6 +38,7 @@ type RetrieveQueryParams struct {
Count int
SortBy string
SortInverted bool
Filter string
}
type ObjectReferrer struct {
@ -109,8 +110,11 @@ type Store interface {
// if a rollback exists
TryRollbackMigration(version Migration)
GetOptions(params RetrieveQueryParams) (map[string]string, error)
GetOption(key string) (string, error)
SetOption(key string, value string) error
DeleteOption(key string) error
DeleteOptions(filter string) error
GetEnvironment(projectID int, environmentID int) (Environment, error)
GetEnvironmentRefs(projectID int, environmentID int) (ObjectReferrers, error)

View File

@ -107,6 +107,7 @@ type TaskWithTpl struct {
TemplatePlaybook string `db:"tpl_playbook" json:"tpl_playbook"`
TemplateAlias string `db:"tpl_alias" json:"tpl_alias"`
TemplateType TemplateType `db:"tpl_type" json:"tpl_type"`
TemplateApp string `db:"tpl_app" json:"tpl_app"`
UserName *string `db:"user_name" json:"user_name"`
BuildTask *Task `db:"-" json:"build_task"`
}

View File

@ -15,11 +15,13 @@ const (
type TemplateApp string
const (
TemplateAnsible TemplateApp = ""
TemplateTerraform TemplateApp = "terraform"
TemplateTofu TemplateApp = "tofu"
TemplateBash TemplateApp = "bash"
TemplatePulumi TemplateApp = "pulumi"
TemplateAnsible TemplateApp = "ansible"
TemplateTerraform TemplateApp = "terraform"
TemplateTofu TemplateApp = "tofu"
TemplateBash TemplateApp = "bash"
TemplatePowerShell TemplateApp = "powershell"
TemplatePython TemplateApp = "python"
TemplatePulumi TemplateApp = "pulumi"
)
func (t TemplateApp) IsTerraform() bool {

View File

@ -43,6 +43,8 @@ func (d *BoltDb) ApplyMigration(m db.Migration) (err error) {
err = migration_2_8_91{migration{d.db}}.Apply()
case "2.10.12":
err = migration_2_10_12{migration{d.db}}.Apply()
case "2.10.16":
err = migration_2_10_16{migration{d.db}}.Apply()
}
if err != nil {

View File

@ -0,0 +1,38 @@
package bolt
type migration_2_10_16 struct {
migration
}
func (d migration_2_10_16) Apply() (err error) {
projectIDs, err := d.getProjectIDs()
if err != nil {
return
}
templates := make(map[string]map[string]map[string]interface{})
for _, projectID := range projectIDs {
var err2 error
templates[projectID], err2 = d.getObjects(projectID, "template")
if err2 != nil {
return err2
}
}
for projectID, projectTemplates := range templates {
for repoID, tpl := range projectTemplates {
if tpl["app"] != nil && tpl["app"] != "" {
continue
}
tpl["app"] = "ansible"
err = d.setObject(projectID, "template", repoID, tpl)
if err != nil {
return err
}
}
}
return
}

View File

@ -0,0 +1,88 @@
package bolt
import (
"encoding/json"
"go.etcd.io/bbolt"
"testing"
)
func TestMigration_2_10_16_Apply(t *testing.T) {
store := CreateTestStore()
err := store.db.Update(func(tx *bbolt.Tx) error {
b, err := tx.CreateBucketIfNotExists([]byte("project"))
if err != nil {
return err
}
err = b.Put([]byte("0000000001"), []byte("{}"))
if err != nil {
return err
}
r, err := tx.CreateBucketIfNotExists([]byte("project__template_0000000001"))
if err != nil {
return err
}
err = r.Put([]byte("0000000001"),
[]byte("{\"id\":\"1\",\"project_id\":\"1\"}"))
return err
})
if err != nil {
t.Fatal(err)
}
err = migration_2_10_16{migration{store.db}}.Apply()
if err != nil {
t.Fatal(err)
}
var repo map[string]interface{}
err = store.db.View(func(tx *bbolt.Tx) error {
b := tx.Bucket([]byte("project__template_0000000001"))
str := string(b.Get([]byte("0000000001")))
return json.Unmarshal([]byte(str), &repo)
})
if err != nil {
t.Fatal(err)
}
if repo["app"] == nil {
t.Fatal("app must be set")
}
if repo["app"].(string) != "ansible" {
t.Fatal("invalid app: " + repo["app"].(string))
}
if repo["alias"] != nil {
t.Fatal("alias must be deleted")
}
}
func TestMigration_2_10_16_Apply2(t *testing.T) {
store := CreateTestStore()
err := store.db.Update(func(tx *bbolt.Tx) error {
b, err := tx.CreateBucketIfNotExists([]byte("project"))
if err != nil {
return err
}
err = b.Put([]byte("0000000001"), []byte("{}"))
return err
})
if err != nil {
t.Fatal(err)
}
err = migration_2_10_16{migration{store.db}}.Apply()
if err != nil {
t.Fatal(err)
}
}

View File

@ -3,8 +3,29 @@ package bolt
import (
"errors"
"github.com/ansible-semaphore/semaphore/db"
"go.etcd.io/bbolt"
"strings"
)
func (d *BoltDb) GetOptions(params db.RetrieveQueryParams) (res map[string]string, err error) {
res = make(map[string]string)
var options []db.Option
err = d.getObjects(0, db.OptionProps, db.RetrieveQueryParams{}, func(i interface{}) bool {
option := i.(db.Option)
if params.Filter == "" {
return true
}
return option.Key == params.Filter || strings.HasPrefix(option.Key, params.Filter+".")
}, &options)
for _, opt := range options {
res[opt.Key] = opt.Value
}
return
}
func (d *BoltDb) SetOption(key string, value string) error {
opt := db.Option{
@ -42,3 +63,37 @@ func (d *BoltDb) GetOption(key string) (value string, err error) {
return
}
func (d *BoltDb) DeleteOption(key string) (err error) {
err = db.ValidateOptionKey(key)
if err != nil {
return
}
return d.db.Update(func(tx *bbolt.Tx) error {
return d.deleteObject(-1, db.OptionProps, strObjectID(key), tx)
})
}
func (d *BoltDb) DeleteOptions(filter string) (err error) {
err = db.ValidateOptionKey(filter)
if err != nil {
return
}
var options []db.Option
err = d.getObjects(0, db.OptionProps, db.RetrieveQueryParams{}, func(i interface{}) bool {
opt := i.(db.Option)
return opt.Key == filter || strings.HasPrefix(opt.Key, filter+".")
}, &options)
for _, opt := range options {
err = d.DeleteOption(opt.Key)
if err != nil {
return
}
}
return
}

169
db/config.go Normal file
View File

@ -0,0 +1,169 @@
package db
import (
"fmt"
"github.com/ansible-semaphore/semaphore/util"
"reflect"
"strings"
)
func ConvertFlatToNested(flatMap map[string]string) map[string]interface{} {
nestedMap := make(map[string]interface{})
for key, value := range flatMap {
parts := strings.Split(key, ".")
currentMap := nestedMap
for i, part := range parts {
if i == len(parts)-1 {
currentMap[part] = value
} else {
if _, exists := currentMap[part]; !exists {
currentMap[part] = make(map[string]interface{})
}
currentMap = currentMap[part].(map[string]interface{})
}
}
}
return nestedMap
}
func AssignMapToStruct[P *S, S any](m map[string]interface{}, s P) error {
v := reflect.ValueOf(s).Elem()
return assignMapToStructRecursive(m, v)
}
func cloneStruct(origValue reflect.Value) reflect.Value {
// Create a new instance of the same type as the original struct
cloneValue := reflect.New(origValue.Type()).Elem()
// Iterate over the fields of the struct
for i := 0; i < origValue.NumField(); i++ {
// Get the field value
fieldValue := origValue.Field(i)
// Set the field value in the clone
cloneValue.Field(i).Set(fieldValue)
}
// Return the cloned struct
return cloneValue
}
func assignMapToStructRecursive(m map[string]interface{}, structValue reflect.Value) error {
structType := structValue.Type()
for i := 0; i < structType.NumField(); i++ {
field := structType.Field(i)
jsonTag := field.Tag.Get("json")
if jsonTag == "" {
jsonTag = field.Name
} else {
jsonTag = strings.Split(jsonTag, ",")[0]
}
if value, ok := m[jsonTag]; ok {
fieldValue := structValue.FieldByName(field.Name)
if fieldValue.CanSet() {
val := reflect.ValueOf(value)
switch fieldValue.Kind() {
case reflect.Struct:
if val.Kind() != reflect.Map {
return fmt.Errorf("expected map for nested struct field %s but got %T", field.Name, value)
}
mapValue, ok := value.(map[string]interface{})
if !ok {
return fmt.Errorf("cannot assign value of type %T to field %s of type %s", value, field.Name, field.Type)
}
err := assignMapToStructRecursive(mapValue, fieldValue)
if err != nil {
return err
}
case reflect.Map:
if fieldValue.IsNil() {
mapValue := reflect.MakeMap(fieldValue.Type())
fieldValue.Set(mapValue)
}
// Handle map
if val.Kind() != reflect.Map {
return fmt.Errorf("expected map for field %s but got %T", field.Name, value)
}
for _, key := range val.MapKeys() {
mapElemValue := val.MapIndex(key)
mapElemType := fieldValue.Type().Elem()
srcVal := fieldValue.MapIndex(key)
var mapElem reflect.Value
if srcVal.IsValid() {
mapElem = cloneStruct(srcVal)
} else {
mapElem = reflect.New(mapElemType).Elem()
}
if mapElemType.Kind() == reflect.Struct {
if err := assignMapToStructRecursive(mapElemValue.Interface().(map[string]interface{}), mapElem); err != nil {
return err
}
} else {
if mapElemValue.Type().ConvertibleTo(mapElemType) {
mapElem.Set(mapElemValue.Convert(mapElemType))
} else {
newVal, converted := util.CastValueToKind(mapElemValue.Interface(), mapElemType.Kind())
if !converted {
return fmt.Errorf("cannot assign value of type %s to map element of type %s",
mapElemValue.Type(), mapElemType)
}
mapElem.Set(reflect.ValueOf(newVal))
}
}
fieldValue.SetMapIndex(key, mapElem)
}
default:
// Handle simple types
if val.Type().ConvertibleTo(fieldValue.Type()) {
fieldValue.Set(val.Convert(fieldValue.Type()))
} else {
newVal, converted := util.CastValueToKind(val.Interface(), fieldValue.Type().Kind())
if !converted {
return fmt.Errorf("cannot assign value of type %s to map element of type %s",
val.Type(), val)
}
fieldValue.Set(reflect.ValueOf(newVal))
}
}
}
}
}
return nil
}
func FillConfigFromDB(store Store) (err error) {
opts, err := store.GetOptions(RetrieveQueryParams{})
if err != nil {
return
}
options := ConvertFlatToNested(opts)
if options["apps"] == nil {
options["apps"] = make(map[string]interface{})
}
err = AssignMapToStruct(options, util.Config)
return
}

75
db/config_test.go Normal file
View File

@ -0,0 +1,75 @@
package db
import "testing"
func TestConfig_assignMapToStruct(t *testing.T) {
type Address struct {
Street string `json:"street"`
City string `json:"city"`
}
type Detail struct {
Value string `json:"value"`
Description string `json:"description"`
}
type User struct {
//Name string `json:"name"`
//Age int `json:"age"`
//Email string `json:"email"`
//Address Address `json:"address"`
Details map[string]Detail `json:"details"`
}
johnData := map[string]interface{}{
//"name": "John Doe",
//"age": 30,
//"email": "john.doe@example.com",
//"address": map[string]interface{}{
// "street": "123 Main St",
// "city": "Anytown",
//},
"details": map[string]interface{}{
//"occupation": map[string]interface{}{
// "value": "engineer",
// "description": "Works with computers",
//},
//"hobby": map[string]interface{}{
// "value": "hiking",
// "description": "Enjoys the outdoors",
//},
"interests": map[string]interface{}{
"description": "Ho ho ho",
},
},
}
var john User
john.Details = make(map[string]Detail)
john.Details["interests"] = Detail{
Value: "politics",
Description: "Follows current events",
}
err := AssignMapToStruct(johnData, &john)
if err != nil {
t.Fatal(err)
}
//if john.Name != "John Doe" {
// t.Errorf("Expected name to be John Doe but got %s", john.Name)
//}
if john.Details["interests"].Description != "Ho ho ho" {
t.Errorf("Expected interests description to be 'Ho ho ho' but got %s", john.Details["interests"].Description)
}
if john.Details["interests"].Value != "politics" {
t.Errorf("Expected interests to be politics but got '%s'", john.Details["interests"].Value)
}
//if john.Details["occupation"].Value != "engineer" {
// t.Errorf("Expected occupation to be engineer but got %s", john.Details["occupation"].Value)
//}
}

View File

@ -204,7 +204,7 @@ func (d *SqlDb) getObject(projectID int, props db.ObjectProps, objectID int, obj
func (d *SqlDb) makeObjectsQuery(projectID int, props db.ObjectProps, params db.RetrieveQueryParams) squirrel.SelectBuilder {
q := squirrel.Select("*").
From(props.TableName + " pe")
From("`" + props.TableName + "` pe")
if !props.IsGlobal {
q = q.Where("pe.project_id=?", projectID)
@ -235,8 +235,14 @@ func (d *SqlDb) makeObjectsQuery(projectID int, props db.ObjectProps, params db.
return q
}
func (d *SqlDb) getObjects(projectID int, props db.ObjectProps, params db.RetrieveQueryParams, objects interface{}) (err error) {
query, args, err := d.makeObjectsQuery(projectID, props, params).ToSql()
func (d *SqlDb) getObjects(projectID int, props db.ObjectProps, params db.RetrieveQueryParams, prepare func(squirrel.SelectBuilder) squirrel.SelectBuilder, objects interface{}) (err error) {
q := d.makeObjectsQuery(projectID, props, params)
if prepare != nil {
q = prepare(q)
}
query, args, err := q.ToSql()
if err != nil {
return
@ -247,7 +253,7 @@ func (d *SqlDb) getObjects(projectID int, props db.ObjectProps, params db.Retrie
return
}
func (d *SqlDb) deleteObject(projectID int, props db.ObjectProps, objectID int) error {
func (d *SqlDb) deleteObject(projectID int, props db.ObjectProps, objectID any) error {
if props.IsGlobal {
return validateMutationResult(
d.exec(

View File

@ -105,7 +105,7 @@ func (d *SqlDb) RekeyAccessKeys(oldKey string) (err error) {
for i := 0; ; i++ {
var keys []db.AccessKey
err = d.getObjects(-1, globalProps, db.RetrieveQueryParams{Count: RekeyBatchSize, Offset: i * RekeyBatchSize}, &keys)
err = d.getObjects(-1, globalProps, db.RetrieveQueryParams{Count: RekeyBatchSize, Offset: i * RekeyBatchSize}, nil, &keys)
if err != nil {
return

View File

@ -15,7 +15,7 @@ func (d *SqlDb) GetEnvironmentRefs(projectID int, environmentID int) (db.ObjectR
func (d *SqlDb) GetEnvironments(projectID int, params db.RetrieveQueryParams) ([]db.Environment, error) {
var environment []db.Environment
err := d.getObjects(projectID, db.EnvironmentProps, params, &environment)
err := d.getObjects(projectID, db.EnvironmentProps, params, nil, &environment)
return environment, err
}

View File

@ -37,7 +37,7 @@ func (d *SqlDb) CreateIntegration(integration db.Integration) (newIntegration db
}
func (d *SqlDb) GetIntegrations(projectID int, params db.RetrieveQueryParams) (integrations []db.Integration, err error) {
err = d.getObjects(projectID, db.IntegrationProps, params, &integrations)
err = d.getObjects(projectID, db.IntegrationProps, params, nil, &integrations)
return integrations, err
}

View File

@ -14,7 +14,7 @@ func (d *SqlDb) GetInventory(projectID int, inventoryID int) (inventory db.Inven
func (d *SqlDb) GetInventories(projectID int, params db.RetrieveQueryParams) ([]db.Inventory, error) {
var inventories []db.Inventory
err := d.getObjects(projectID, db.InventoryProps, params, &inventories)
err := d.getObjects(projectID, db.InventoryProps, params, nil, &inventories)
return inventories, err
}

View File

@ -0,0 +1,3 @@
update `project__template` set `app` = 'ansible' where `app` = '';
alter table `project__template` change `app` `app` varchar(50) not null;

View File

@ -22,6 +22,35 @@ func (d *SqlDb) SetOption(key string, value string) error {
return err
}
func (d *SqlDb) GetOptions(params db.RetrieveQueryParams) (res map[string]string, err error) {
var options []db.Option
res = make(map[string]string)
if params.Filter != "" {
err = db.ValidateOptionKey(params.Filter)
if err != nil {
return
}
}
err = d.getObjects(0, db.OptionProps, params, func(q squirrel.SelectBuilder) squirrel.SelectBuilder {
if params.Filter == "" {
return q
}
return q.Where("`key` = ? OR `key` LIKE ?", params.Filter, params.Filter+".%")
}, &options)
if err != nil {
return
}
for _, opt := range options {
res[opt.Key] = opt.Value
}
return
}
func (d *SqlDb) getOption(key string) (value string, err error) {
q := squirrel.Select("*").
From("`"+db.OptionProps.TableName+"`").
@ -56,3 +85,25 @@ func (d *SqlDb) GetOption(key string) (value string, err error) {
return
}
func (d *SqlDb) DeleteOption(key string) (err error) {
err = db.ValidateOptionKey(key)
if err != nil {
return
}
err = d.deleteObject(0, db.OptionProps, key)
return
}
func (d *SqlDb) DeleteOptions(filter string) (err error) {
err = db.ValidateOptionKey(filter)
if err != nil {
return
}
_, err = d.exec("DELETE FROM `option` WHERE `key` = ? OR `key` LIKE ?", filter, filter+".%")
return
}

View File

@ -24,7 +24,7 @@ func (d *SqlDb) GetGlobalRunner(runnerID int) (runner db.Runner, err error) {
}
func (d *SqlDb) GetGlobalRunners() (runners []db.Runner, err error) {
err = d.getObjects(0, db.GlobalRunnerProps, db.RetrieveQueryParams{}, &runners)
err = d.getObjects(0, db.GlobalRunnerProps, db.RetrieveQueryParams{}, nil, &runners)
return
}

View File

@ -116,7 +116,8 @@ func (d *SqlDb) getTasks(projectID int, templateIDs []int, params db.RetrieveQue
fields += ", tpl.playbook as tpl_playbook" +
", `user`.name as user_name" +
", tpl.name as tpl_alias" +
", tpl.type as tpl_type"
", tpl.type as tpl_type" +
", tpl.app as tpl_app"
q := squirrel.Select(fields).
From("task").

View File

@ -8,7 +8,7 @@ func (d *SqlDb) GetView(projectID int, viewID int) (view db.View, err error) {
}
func (d *SqlDb) GetViews(projectID int) (views []db.View, err error) {
err = d.getObjects(projectID, db.ViewProps, db.RetrieveQueryParams{}, &views)
err = d.getObjects(projectID, db.ViewProps, db.RetrieveQueryParams{}, nil, &views)
return
}

View File

@ -32,13 +32,14 @@ func CreateApp(template db.Template, repository db.Repository, logger task_logge
Logger: logger,
Name: TerraformAppTofu,
}
case db.TemplateBash:
return &BashApp{
case db.TemplateBash, db.TemplatePowerShell, db.TemplatePython:
return &ShellApp{
Template: template,
Repository: repository,
Logger: logger,
App: template.App,
}
default:
panic("unknown app")
panic("unknown app: " + template.App)
}
}

View File

@ -11,10 +11,11 @@ import (
"time"
)
type BashApp struct {
type ShellApp struct {
Logger task_logger.Logger
Template db.Template
Repository db.Repository
App db.TemplateApp
reader bashReader
}
@ -39,7 +40,7 @@ func (r *bashReader) Read(p []byte) (n int, err error) {
return len(*r.input) + 1, nil
}
func (t *BashApp) makeCmd(command string, args []string, environmentVars *[]string) *exec.Cmd {
func (t *ShellApp) makeCmd(command string, args []string, environmentVars *[]string) *exec.Cmd {
cmd := exec.Command(command, args...) //nolint: gas
cmd.Dir = t.GetFullPath()
@ -47,10 +48,6 @@ func (t *BashApp) makeCmd(command string, args []string, environmentVars *[]stri
cmd.Env = append(cmd.Env, fmt.Sprintf("HOME=%s", util.Config.TmpPath))
cmd.Env = append(cmd.Env, fmt.Sprintf("PWD=%s", cmd.Dir))
if environmentVars != nil {
cmd.Env = append(cmd.Env, args...)
}
if environmentVars != nil {
cmd.Env = append(cmd.Env, *environmentVars...)
}
@ -63,18 +60,18 @@ func (t *BashApp) makeCmd(command string, args []string, environmentVars *[]stri
return cmd
}
func (t *BashApp) runCmd(command string, args []string) error {
func (t *ShellApp) runCmd(command string, args []string) error {
cmd := t.makeCmd(command, args, nil)
t.Logger.LogCmd(cmd)
return cmd.Run()
}
func (t *BashApp) GetFullPath() (path string) {
func (t *ShellApp) GetFullPath() (path string) {
path = t.Repository.GetFullPath(t.Template.ID)
return
}
func (t *BashApp) SetLogger(logger task_logger.Logger) task_logger.Logger {
func (t *ShellApp) SetLogger(logger task_logger.Logger) task_logger.Logger {
t.Logger = logger
t.Logger.AddStatusListener(func(status task_logger.TaskStatus) {
@ -83,12 +80,27 @@ func (t *BashApp) SetLogger(logger task_logger.Logger) task_logger.Logger {
return logger
}
func (t *BashApp) InstallRequirements() error {
func (t *ShellApp) InstallRequirements() error {
return nil
}
func (t *BashApp) Run(args []string, environmentVars *[]string, inputs map[string]string, cb func(*os.Process)) error {
cmd := t.makeCmd("bash", args, environmentVars)
func (t *ShellApp) makeShellCmd(args []string, environmentVars *[]string) *exec.Cmd {
var command string
var appArgs []string
switch t.App {
case db.TemplateBash:
command = "bash"
case db.TemplatePython:
command = "python"
case db.TemplatePowerShell:
command = "powershell"
appArgs = []string{"-File"}
}
return t.makeCmd(command, append(appArgs, args...), environmentVars)
}
func (t *ShellApp) Run(args []string, environmentVars *[]string, inputs map[string]string, cb func(*os.Process)) error {
cmd := t.makeShellCmd(args, environmentVars)
t.Logger.LogCmd(cmd)
//cmd.Stdin = &t.reader
cmd.Stdin = strings.NewReader("")

View File

@ -378,7 +378,7 @@ func (t *LocalJob) Run(username string, incomingVersion *string) (err error) {
args, inputs, err = t.getPlaybookArgs(username, incomingVersion)
case db.TemplateTerraform, db.TemplateTofu:
args, err = t.getTerraformArgs(username, incomingVersion)
case db.TemplateBash:
case db.TemplateBash, db.TemplatePowerShell, db.TemplatePython:
args, err = t.getBashArgs(username, incomingVersion)
default:
panic("unknown template app")

12
util/App.go Normal file
View File

@ -0,0 +1,12 @@
package util
type App struct {
Active bool `json:"active"`
Order int `json:"order"`
Title string `json:"title"`
Icon string `json:"icon"`
Color string `json:"color"`
DarkColor string `json:"dark_color"`
AppPath string `json:"path"`
AppArgs []string `json:"args"`
}

37
util/OdbcProvider.go Normal file
View File

@ -0,0 +1,37 @@
package util
type OidcProvider struct {
ClientID string `json:"client_id"`
ClientIDFile string `json:"client_id_file"`
ClientSecret string `json:"client_secret"`
ClientSecretFile string `json:"client_secret_file"`
RedirectURL string `json:"redirect_url"`
Scopes []string `json:"scopes"`
DisplayName string `json:"display_name"`
Color string `json:"color"`
Icon string `json:"icon"`
AutoDiscovery string `json:"provider_url"`
Endpoint oidcEndpoint `json:"endpoint"`
UsernameClaim string `json:"username_claim" default:"preferred_username"`
NameClaim string `json:"name_claim" default:"preferred_username"`
EmailClaim string `json:"email_claim" default:"email"`
Order int `json:"order"`
}
type ClaimsProvider interface {
GetUsernameClaim() string
GetEmailClaim() string
GetNameClaim() string
}
func (p *OidcProvider) GetUsernameClaim() string {
return p.UsernameClaim
}
func (p *OidcProvider) GetEmailClaim() string {
return p.EmailClaim
}
func (p *OidcProvider) GetNameClaim() string {
return p.NameClaim
}

View File

@ -71,42 +71,6 @@ type oidcEndpoint struct {
Algorithms []string `json:"algorithms"`
}
type OidcProvider struct {
ClientID string `json:"client_id"`
ClientIDFile string `json:"client_id_file"`
ClientSecret string `json:"client_secret"`
ClientSecretFile string `json:"client_secret_file"`
RedirectURL string `json:"redirect_url"`
Scopes []string `json:"scopes"`
DisplayName string `json:"display_name"`
Color string `json:"color"`
Icon string `json:"icon"`
AutoDiscovery string `json:"provider_url"`
Endpoint oidcEndpoint `json:"endpoint"`
UsernameClaim string `json:"username_claim" default:"preferred_username"`
NameClaim string `json:"name_claim" default:"preferred_username"`
EmailClaim string `json:"email_claim" default:"email"`
Order int `json:"order"`
}
type ClaimsProvider interface {
GetUsernameClaim() string
GetEmailClaim() string
GetNameClaim() string
}
func (p *OidcProvider) GetUsernameClaim() string {
return p.UsernameClaim
}
func (p *OidcProvider) GetEmailClaim() string {
return p.EmailClaim
}
func (p *OidcProvider) GetNameClaim() string {
return p.NameClaim
}
const (
// GoGitClientId is builtin Git client. It is not require external dependencies and is preferred.
// Use it if you don't need external SSH authorization.
@ -143,17 +107,6 @@ type RunnerSettings struct {
MaxParallelTasks int `json:"max_parallel_tasks" default:"1" env:"SEMAPHORE_RUNNER_MAX_PARALLEL_TASKS"`
}
type AppVersion struct {
Semver string `json:"semver"`
DownloadURL string `json:"download_url"`
Path string `json:"path"`
}
type AppConfig struct {
Name string `json:"name"`
Versions []AppVersion `json:"versions"`
}
// ConfigType mapping between Config and the json file that sets it
type ConfigType struct {
MySQL DbConfig `json:"mysql"`
@ -238,9 +191,9 @@ type ConfigType struct {
Runner RunnerSettings `json:"runner"`
GlobalIntegrationAlias string `json:"global_integration_alias" env:"g"`
IntegrationAlias string `json:"global_integration_alias" env:"SEMAPHORE_INTEGRATION_ALIAS"`
Apps []AppConfig `json:"apps" env:"SEMAPHORE_APPS"`
Apps map[string]App `json:"apps" env:"SEMAPHORE_APPS"`
}
// Config exposes the application configuration storage for use in the application
@ -441,28 +394,52 @@ func castStringToBool(value string) bool {
}
func CastValueToKind(value interface{}, kind reflect.Kind) (res interface{}, ok bool) {
res = value
switch kind {
case reflect.Slice:
if reflect.ValueOf(value).Kind() == reflect.String {
var arr []string
err := json.Unmarshal([]byte(value.(string)), &arr)
if err != nil {
panic(err)
}
res = arr
ok = true
}
case reflect.String:
ok = true
case reflect.Int:
if reflect.ValueOf(value).Kind() != reflect.Int {
res = castStringToInt(fmt.Sprintf("%v", reflect.ValueOf(value)))
ok = true
}
case reflect.Bool:
if reflect.ValueOf(value).Kind() != reflect.Bool {
res = castStringToBool(fmt.Sprintf("%v", reflect.ValueOf(value)))
ok = true
}
case reflect.Map:
if reflect.ValueOf(value).Kind() == reflect.String {
mapValue := make(map[string]string)
err := json.Unmarshal([]byte(value.(string)), &mapValue)
if err != nil {
panic(err)
}
res = mapValue
ok = true
}
default:
}
return
}
func setConfigValue(attribute reflect.Value, value interface{}) {
if attribute.IsValid() {
switch attribute.Kind() {
case reflect.Int:
if reflect.ValueOf(value).Kind() != reflect.Int {
value = castStringToInt(fmt.Sprintf("%v", reflect.ValueOf(value)))
}
case reflect.Bool:
if reflect.ValueOf(value).Kind() != reflect.Bool {
value = castStringToBool(fmt.Sprintf("%v", reflect.ValueOf(value)))
}
case reflect.Map:
if reflect.ValueOf(value).Kind() == reflect.String {
mapValue := make(map[string]string)
err := json.Unmarshal([]byte(value.(string)), &mapValue)
if err != nil {
panic(err)
}
value = mapValue
}
}
value, _ = CastValueToKind(value, attribute.Kind())
attribute.Set(reflect.ValueOf(value))
} else {
panic(fmt.Errorf("got non-existent config attribute"))
@ -822,3 +799,32 @@ func (conf *ConfigType) GenerateSecrets() {
conf.CookieEncryption = base64.StdEncoding.EncodeToString(encryption)
conf.AccessKeyEncryption = base64.StdEncoding.EncodeToString(accessKeyEncryption)
}
func CheckDefaultApps() {
appCommands := map[string]string{
"": "ansible-playbook",
"terraform": "terraform",
"tofu": "tofu",
"bash": "bash",
}
for app, cmd := range appCommands {
if _, ok := Config.Apps[app]; ok {
continue
}
_, err := exec.LookPath(cmd)
if err != nil {
continue
}
if Config.Apps == nil {
Config.Apps = make(map[string]App)
}
Config.Apps[app] = App{
Active: true,
}
}
}

View File

@ -0,0 +1,97 @@
<template>
<v-form
ref="form"
lazy-validation
v-model="formValid"
v-if="item != null"
>
<v-alert
:value="formError"
color="error"
class="pb-2"
>{{ formError }}</v-alert>
<v-text-field
v-model="id"
:label="$t('ID')"
:rules="[v => !!v || $t('id_required')]"
required
:disabled="formSaving"
></v-text-field>
<v-text-field
v-model="item.icon"
:label="$t('Icon')"
:rules="[v => !!v || $t('icon_required')]"
required
:disabled="formSaving"
></v-text-field>
<v-text-field
v-model="item.title"
:label="$t('name')"
:rules="[v => !!v || $t('name_required')]"
required
:disabled="formSaving"
></v-text-field>
<v-text-field
v-model="item.path"
:label="$t('Path')"
:rules="[v => !!v || $t('path_required')]"
required
:disabled="formSaving"
></v-text-field>
<v-checkbox
v-model="item.active"
:label="$t('Active')"
></v-checkbox>
</v-form>
</template>
<script>
import ItemFormBase from '@/components/ItemFormBase';
export default {
mixins: [ItemFormBase],
computed: {
isNew() {
return this.itemId === '';
},
},
data() {
return {
id: null,
};
},
watch: {
itemId() {
this.id = this.itemId;
},
},
methods: {
beforeLoadData() {
if (!this.isNew) {
this.id = this.itemId;
}
},
afterReset() {
this.id = null;
},
getItemsUrl() {
return `/api/apps/${this.id}`;
},
getSingleItemUrl() {
return `/api/apps/${this.id}`;
},
},
};
</script>

View File

@ -0,0 +1,75 @@
import axios from 'axios';
import { APP_ICONS, APP_TITLE } from '../lib/constants';
export default {
data() {
return {
activeAppIds: [],
apps: null,
};
},
async created() {
const apps = await this.loadAppsDataFromBackend();
this.activeAppIds = apps.filter((app) => app.active).map((app) => app.id);
this.apps = apps.reduce((prev, app) => ({
...prev,
[app.id]: app,
}), {});
},
computed: {
isAppsLoaded() {
return this.apps != null;
},
},
methods: {
async loadAppsDataFromBackend() {
return (await axios({
method: 'get',
url: '/api/apps',
responseType: 'json',
})).data;
},
getAppColor(id) {
if (APP_ICONS[id]) {
return this.$vuetify.theme.dark ? APP_ICONS[id].darkColor : APP_ICONS[id].color;
}
if (this.apps[id]) {
return this.apps[id].color || 'gray';
}
return 'gray';
},
getAppTitle(id) {
if (APP_TITLE[id]) {
return APP_TITLE[id];
}
if (this.apps[id]) {
return this.apps[id].title;
}
return '';
},
getAppIcon(id) {
if (APP_ICONS[id]) {
return APP_ICONS[id].icon;
}
if (this.apps[id]) {
return `mdi-${this.apps[id].icon}`;
}
return 'mdi-help';
},
},
};

View File

@ -1,504 +0,0 @@
<template>
<v-form
ref="form"
lazy-validation
v-model="formValid"
v-if="isLoaded"
>
<v-dialog
v-model="helpDialog"
hide-overlay
width="300"
>
<v-alert
border="top"
colored-border
type="info"
elevation="2"
class="mb-0 pb-0"
>
<div v-if="helpKey === 'build_version'">
<p>
{{ $t('definesStartVersionOfYourArtifactEachRunIncrements') }}
</p>
<p>
{{ $t('forMoreInformationAboutBuildingSeeThe') }}
<a href="https://docs.ansible-semaphore.com/user-guide/task-templates#build"
target="_blank"
>{{ $t('taskTemplateReference') }}</a>.
</p>
</div>
<div v-else-if="helpKey === 'build'">
<p>
{{ $t('definesWhatArtifactShouldBeDeployedWhenTheTaskRun') }}
</p>
<p>
{{ $t('forMoreInformationAboutDeployingSeeThe') }}
<a href="https://docs.ansible-semaphore.com/user-guide/task-templates#build"
target="_blank"
>{{ $t('taskTemplateReference2') }}</a>.
</p>
</div>
<div v-if="helpKey === 'cron'">
<p>{{ $t('definesAutorunSchedule') }}</p>
<p>
{{ $t('forMoreInformationAboutCronSeeThe') }}
<a href="https://pkg.go.dev/github.com/robfig/cron/v3#hdr-CRON_Expression_Format"
target="_blank"
>{{ $t('cronExpressionFormatReference') }}</a>.
</p>
</div>
</v-alert>
</v-dialog>
<v-alert
:value="formError"
color="error"
class="pb-2"
>{{ formError }}
</v-alert>
<v-row>
<v-col cols="12" md="6" class="pb-0">
<v-card class="mb-6" :color="$vuetify.theme.dark ? '#212121' : 'white'">
<v-tabs
fixed-tabs
v-model="itemTypeIndex"
>
<v-tab
style="padding: 0"
v-for="(key) in Object.keys(TEMPLATE_TYPE_ICONS)"
:key="key"
>
<v-icon small class="mr-2">{{ TEMPLATE_TYPE_ICONS[key] }}</v-icon>
{{ $t(TEMPLATE_TYPE_TITLES[key]) }}
</v-tab>
</v-tabs>
<div class="ml-4 mr-4 mt-6" v-if="item.type">
<v-text-field
v-if="item.type === 'build'"
v-model="item.start_version"
:label="$t('startVersion')"
:rules="[v => !!v || $t('start_version_required')]"
required
:disabled="formSaving"
:placeholder="$t('example000')"
append-outer-icon="mdi-help-circle"
@click:append-outer="showHelpDialog('build_version')"
></v-text-field>
<v-autocomplete
v-if="item.type === 'deploy'"
v-model="item.build_template_id"
:label="$t('buildTemplate')"
:items="buildTemplates"
item-value="id"
item-text="name"
:rules="[v => !!v || $t('build_template_required')]"
required
:disabled="formSaving"
append-outer-icon="mdi-help-circle"
@click:append-outer="showHelpDialog('build')"
></v-autocomplete>
<v-checkbox
v-if="item.type === 'deploy'"
class="mt-0"
:label="$t('autorun')"
v-model="item.autorun"
/>
</div>
</v-card>
<v-text-field
v-model="item.name"
:label="$t('name2')"
:rules="[v => !!v || $t('name_required')]"
outlined
dense
required
:disabled="formSaving"
></v-text-field>
<v-textarea
v-model="item.description"
:label="$t('description')"
:disabled="formSaving"
rows="1"
:auto-grow="true"
outlined
dense
></v-textarea>
<v-text-field
v-model="item.playbook"
label="Script Filename *"
:rules="[v => !!v || $t('playbook_filename_required')]"
outlined
dense
required
:disabled="formSaving"
:placeholder="$t('exampleSiteyml')"
></v-text-field>
<v-select
v-model="item.repository_id"
:label="$t('repository') + ' *'"
:items="repositories"
item-value="id"
item-text="name"
:rules="[v => !!v || $t('repository_required')]"
outlined
dense
required
:disabled="formSaving"
></v-select>
<v-select
v-model="item.environment_id"
:label="$t('environment3')"
:items="environment"
item-value="id"
item-text="name"
:rules="[v => !!v || $t('environment_required')]"
outlined
dense
required
:disabled="formSaving"
></v-select>
</v-col>
<v-col cols="12" md="6" class="pb-0">
<SurveyVars style="margin-top: -10px;" :vars="item.survey_vars" @change="setSurveyVars"/>
<v-select
v-model="item.view_id"
:label="$t('view')"
clearable
:items="views"
item-value="id"
item-text="title"
:disabled="formSaving"
outlined
dense
></v-select>
<v-row>
<v-col cols="5" class="pr-1">
<v-text-field
style="font-size: 14px"
v-model="cronFormat"
:label="$t('cron')"
:disabled="formSaving"
placeholder="* * * * *"
v-if="schedules == null || schedules.length <= 1"
outlined
dense
hide-details
></v-text-field>
</v-col>
<v-col cols="7">
<a
v-if="!cronRepositoryIdVisible && cronRepositoryId == null"
@click="cronRepositoryIdVisible = true"
class="text-caption d-block"
style="line-height: 1.1;"
>
{{ $t('iWantToRunATaskByTheCronOnlyForForNewCommitsOfSome') }}
</a>
<v-select
style="font-size: 14px"
v-if="cronRepositoryIdVisible || cronRepositoryId != null"
v-model="cronRepositoryId"
:label="$t('repository2')"
:placeholder="$t('cronChecksNewCommitBeforeRun')"
:items="repositories"
item-value="id"
item-text="name"
clearable
:disabled="formSaving"
outlined
dense
hide-details
></v-select>
</v-col>
</v-row>
<small class="mt-1 mb-4 d-block">
{{ $t('readThe') }}
<a target="_blank" href="https://pkg.go.dev/github.com/robfig/cron/v3#hdr-CRON_Expression_Format">{{ $t('docs') }}</a>
{{ $t('toLearnMoreAboutCron') }}
</small>
<v-checkbox
class="mt-0"
:label="$t('suppressSuccessAlerts')"
v-model="item.suppress_success_alerts"
/>
</v-col>
</v-row>
</v-form>
</template>
<style lang="scss">
.CodeMirror-placeholder {
color: #a4a4a4 !important;
}
</style>
<script>
/* eslint-disable import/no-extraneous-dependencies,import/extensions */
import axios from 'axios';
import ItemFormBase from '@/components/ItemFormBase';
import { TEMPLATE_TYPE_ICONS, TEMPLATE_TYPE_TITLES } from '../lib/constants';
import SurveyVars from './SurveyVars';
export default {
mixins: [ItemFormBase],
components: {
SurveyVars,
},
props: {
sourceItemId: Number,
},
data() {
return {
itemTypeIndex: 0,
TEMPLATE_TYPE_ICONS,
TEMPLATE_TYPE_TITLES,
cmOptions: {
tabSize: 2,
mode: 'application/json',
lineNumbers: true,
line: true,
lint: true,
indentWithTabs: false,
},
item: null,
keys: null,
repositories: null,
environment: null,
views: null,
schedules: null,
buildTemplates: null,
cronFormat: null,
cronRepositoryId: null,
cronRepositoryIdVisible: false,
helpDialog: null,
helpKey: null,
advancedOptions: false,
};
},
watch: {
needReset(val) {
if (val) {
if (this.item != null) {
this.item.template_id = this.templateId;
}
}
},
sourceItemId(val) {
this.item.template_id = val;
},
itemTypeIndex(val) {
this.item.type = Object.keys(TEMPLATE_TYPE_ICONS)[val];
},
},
computed: {
isLoaded() {
if (this.isNew && this.sourceItemId == null) {
return true;
}
return this.keys != null
&& this.repositories != null
&& this.environment != null
&& this.item != null
&& this.schedules != null
&& this.views != null;
},
loginPasswordKeys() {
if (this.keys == null) {
return null;
}
return this.keys.filter((key) => key.type === 'login_password');
},
},
methods: {
setSurveyVars(v) {
this.item.survey_vars = v;
},
showHelpDialog(key) {
this.helpKey = key;
this.helpDialog = true;
},
async afterLoadData() {
if (this.sourceItemId) {
this.item = (await axios({
keys: 'get',
url: `/api/project/${this.projectId}/templates/${this.sourceItemId}`,
responseType: 'json',
})).data;
}
this.advancedOptions = this.item.arguments != null || this.item.allow_override_args_in_task;
this.keys = (await axios({
keys: 'get',
url: `/api/project/${this.projectId}/keys`,
responseType: 'json',
})).data;
this.repositories = (await axios({
keys: 'get',
url: `/api/project/${this.projectId}/repositories`,
responseType: 'json',
})).data;
this.environment = (await axios({
keys: 'get',
url: `/api/project/${this.projectId}/environment`,
responseType: 'json',
})).data;
const template = (await axios({
keys: 'get',
url: `/api/project/${this.projectId}/templates`,
responseType: 'json',
})).data;
const builds = [];
const deploys = [];
template.forEach((t) => {
switch (t.type) {
case 'build':
if (builds.length === 0) {
builds.push({ header: 'Build Templates' });
}
builds.push(t);
break;
case 'deploy':
if (deploys.length === 0) {
deploys.push({ header: 'Deploy Templates' });
}
deploys.push(t);
break;
default:
break;
}
});
this.buildTemplates = builds;
if (this.buildTemplates.length > 0 && deploys.length > 0) {
this.buildTemplates.push({ divider: true });
}
this.buildTemplates.push(...deploys);
this.schedules = this.isNew ? [] : (await axios({
keys: 'get',
url: `/api/project/${this.projectId}/templates/${this.itemId}/schedules`,
responseType: 'json',
})).data;
this.views = (await axios({
keys: 'get',
url: `/api/project/${this.projectId}/views`,
responseType: 'json',
})).data;
if (this.schedules.length === 1) {
this.cronFormat = this.schedules[0].cron_format;
this.cronRepositoryId = this.schedules[0].repository_id;
}
this.itemTypeIndex = Object.keys(TEMPLATE_TYPE_ICONS).indexOf(this.item.type);
},
getItemsUrl() {
return `/api/project/${this.projectId}/templates`;
},
getSingleItemUrl() {
return `/api/project/${this.projectId}/templates/${this.itemId}`;
},
async beforeSave() {
this.item.app = 'bash';
if (this.cronFormat == null || this.cronFormat === '') {
return;
}
await axios({
method: 'post',
url: `/api/project/${this.projectId}/schedules/validate`,
responseType: 'json',
data: {
cron_format: this.cronFormat,
},
});
},
async afterSave(newItem) {
if (newItem || this.schedules.length === 0) {
if (this.cronFormat != null && this.cronFormat !== '') {
// new schedule
await axios({
method: 'post',
url: `/api/project/${this.projectId}/schedules`,
responseType: 'json',
data: {
project_id: this.projectId,
template_id: newItem ? newItem.id : this.itemId,
cron_format: this.cronFormat,
repository_id: this.cronRepositoryId,
},
});
}
} else if (this.schedules.length > 1) {
// do nothing
} else if (this.cronFormat == null || this.cronFormat === '') {
// drop schedule
await axios({
method: 'delete',
url: `/api/project/${this.projectId}/schedules/${this.schedules[0].id}`,
responseType: 'json',
});
} else {
// update schedule
await axios({
method: 'put',
url: `/api/project/${this.projectId}/schedules/${this.schedules[0].id}`,
responseType: 'json',
data: {
id: this.schedules[0].id,
project_id: this.projectId,
template_id: this.itemId,
cron_format: this.cronFormat,
repository_id: this.cronRepositoryId,
},
});
}
},
},
};
</script>

View File

@ -4,15 +4,14 @@
:min-content-height="457"
v-model="dialog"
:save-button-text="itemId === 'new' ? $t('create') : $t('save')"
:icon="APP_ICONS[itemApp].icon"
:icon-color="$vuetify.theme.dark ? APP_ICONS[itemApp].darkColor : APP_ICONS[itemApp].color"
:icon="getAppIcon(itemApp)"
:icon-color="getAppColor(itemApp)"
:title="(itemId === 'new' ? $t('newTemplate') : $t('editTemplate')) +
' \'' + APP_TITLE[itemApp] + '\''"
' \'' + getAppTitle(itemApp) + '\''"
@save="onSave"
>
<template v-slot:form="{ onSave, onError, needSave, needReset }">
<TerraformTemplateForm
v-if="['terraform', 'tofu'].includes(itemApp)"
<TemplateForm
:project-id="projectId"
:item-id="itemId"
@save="onSave"
@ -21,26 +20,7 @@
:need-reset="needReset"
:source-item-id="sourceItemId"
:app="itemApp"
/>
<BashTemplateForm
v-else-if="itemApp === 'bash'"
:project-id="projectId"
:item-id="itemId"
@save="onSave"
@error="onError"
:need-save="needSave"
:need-reset="needReset"
:source-item-id="sourceItemId"
/>
<TemplateForm
v-else
:project-id="projectId"
:item-id="itemId"
@save="onSave"
@error="onError"
:need-save="needSave"
:need-reset="needReset"
:source-item-id="sourceItemId"
:fields="fields"
/>
</template>
</EditDialog>
@ -52,20 +32,63 @@
<script>
import { APP_ICONS, APP_TITLE } from '../lib/constants';
import TerraformTemplateForm from './TerraformTemplateForm.vue';
import BashTemplateForm from './BashTemplateForm.vue';
import TemplateForm from './TemplateForm.vue';
import EditDialog from './EditDialog.vue';
import AppsMixin from './AppsMixin';
const ANSIBLE_FIELDS = {
playbook: {
label: 'playbookFilename',
},
inventory: {
label: 'inventory2',
},
repository: {
label: 'repository',
},
environment: {
label: 'environment3',
},
vault: {
label: 'vaultPassword2',
},
};
const TERRAFORM_FIELDS = {
...ANSIBLE_FIELDS,
playbook: {
label: 'Subdirectory path (Optional)',
},
inventory: {
label: 'Default Workspace',
},
vault: undefined,
};
const UNKNOWN_APP_FIELDS = {
...ANSIBLE_FIELDS,
playbook: {
label: 'Script Filename *',
},
inventory: undefined,
vault: undefined,
};
const APP_FIELDS = {
'': ANSIBLE_FIELDS,
ansible: ANSIBLE_FIELDS,
terraform: TERRAFORM_FIELDS,
tofu: TERRAFORM_FIELDS,
};
export default {
components: {
BashTemplateForm,
TerraformTemplateForm,
TemplateForm,
EditDialog,
},
mixins: [AppsMixin],
props: {
value: Boolean,
itemApp: String,
@ -76,12 +99,16 @@ export default {
data() {
return {
APP_TITLE,
APP_ICONS,
dialog: false,
};
},
computed: {
fields() {
return APP_FIELDS[this.itemApp] || UNKNOWN_APP_FIELDS;
},
},
watch: {
async dialog(val) {
this.$emit('input', val);

View File

@ -69,6 +69,7 @@ export default {
if (this.$refs.form) {
this.$refs.form.resetValidation();
}
await this.afterReset();
await this.loadData();
},
@ -84,10 +85,18 @@ export default {
},
afterReset() {
},
afterSave() {
},
beforeLoadData() {
},
afterLoadData() {
},
@ -97,6 +106,8 @@ export default {
},
async loadData() {
await this.beforeLoadData();
if (this.isNew) {
this.item = this.getNewItem();
} else {

View File

@ -148,18 +148,19 @@
<v-text-field
v-model="item.playbook"
:label="$t('playbookFilename')"
:label="fieldLabel('playbook')"
:rules="[v => !!v || $t('playbook_filename_required')]"
outlined
dense
required
:disabled="formSaving"
:placeholder="$t('exampleSiteyml')"
v-if="needField('playbook')"
></v-text-field>
<v-select
v-model="item.inventory_id"
:label="$t('inventory2')"
:label="fieldLabel('inventory')"
:items="inventory"
item-value="id"
item-text="name"
@ -167,11 +168,12 @@
dense
required
:disabled="formSaving"
v-if="needField('inventory')"
></v-select>
<v-select
v-model="item.repository_id"
:label="$t('repository') + ' *'"
:label="fieldLabel('repository') + ' *'"
:items="repositories"
item-value="id"
item-text="name"
@ -180,11 +182,12 @@
dense
required
:disabled="formSaving"
v-if="needField('repository')"
></v-select>
<v-select
v-model="item.environment_id"
:label="$t('environment3')"
:label="fieldLabel('environment')"
:items="environment"
item-value="id"
item-text="name"
@ -193,12 +196,13 @@
dense
required
:disabled="formSaving"
v-if="needField('environment')"
></v-select>
<v-select
v-if="itemTypeIndex === 0"
v-if="itemTypeIndex === 0 && needField('vault')"
v-model="item.vault_key_id"
:label="$t('vaultPassword')"
:label="fieldLabel('vault')"
clearable
:items="loginPasswordKeys"
item-value="id"
@ -212,9 +216,9 @@
<v-col cols="12" md="6" class="pb-0">
<v-select
v-if="itemTypeIndex > 0"
v-if="itemTypeIndex > 0 && needField('vault')"
v-model="item.vault_key_id"
:label="$t('vaultPassword2')"
:label="fieldLabel('vault')"
clearable
:items="loginPasswordKeys"
item-value="id"
@ -336,6 +340,7 @@ export default {
props: {
sourceItemId: Number,
fields: Array,
},
data() {
@ -428,6 +433,14 @@ export default {
},
methods: {
fieldLabel(f) {
return this.$t((this.fields[f] || { label: f }).label);
},
needField(f) {
return this.fields[f] != null;
},
setSurveyVars(v) {
this.item.survey_vars = v;
},

View File

@ -1,536 +0,0 @@
<template>
<div v-if="!isLoaded">
<v-row>
<v-col>
<v-skeleton-loader
type="table-heading, list-item-two-line, image, table-tfoot"
></v-skeleton-loader>
</v-col>
<v-col>
<v-skeleton-loader
type="table-heading, list-item-two-line, image, table-tfoot"
></v-skeleton-loader>
</v-col>
</v-row>
</div>
<v-form
v-else
ref="form"
lazy-validation
v-model="formValid"
>
<v-dialog
v-model="helpDialog"
hide-overlay
width="300"
>
<v-alert
border="top"
colored-border
type="info"
elevation="2"
class="mb-0 pb-0"
>
<div v-if="helpKey === 'build_version'">
<p>
{{ $t('definesStartVersionOfYourArtifactEachRunIncrements') }}
</p>
<p>
{{ $t('forMoreInformationAboutBuildingSeeThe') }}
<a href="https://docs.ansible-semaphore.com/user-guide/task-templates#build"
target="_blank"
>{{ $t('taskTemplateReference') }}</a>.
</p>
</div>
<div v-else-if="helpKey === 'build'">
<p>
{{ $t('definesWhatArtifactShouldBeDeployedWhenTheTaskRun') }}
</p>
<p>
{{ $t('forMoreInformationAboutDeployingSeeThe') }}
<a href="https://docs.ansible-semaphore.com/user-guide/task-templates#build"
target="_blank"
>{{ $t('taskTemplateReference2') }}</a>.
</p>
</div>
<div v-if="helpKey === 'cron'">
<p>{{ $t('definesAutorunSchedule') }}</p>
<p>
{{ $t('forMoreInformationAboutCronSeeThe') }}
<a href="https://pkg.go.dev/github.com/robfig/cron/v3#hdr-CRON_Expression_Format"
target="_blank"
>{{ $t('cronExpressionFormatReference') }}</a>.
</p>
</div>
</v-alert>
</v-dialog>
<v-alert
:value="formError"
color="error"
class="pb-2"
>{{ formError }}
</v-alert>
<v-row>
<v-col cols="12" md="6" class="pb-0">
<v-card class="mb-6" :color="$vuetify.theme.dark ? '#212121' : 'white'">
<v-tabs
fixed-tabs
v-model="itemTypeIndex"
>
<v-tab
style="padding: 0"
v-for="(key) in Object.keys(TEMPLATE_TYPE_ICONS)"
:key="key"
>
<v-icon small class="mr-2">{{ TEMPLATE_TYPE_ICONS[key] }}</v-icon>
{{ $t(TEMPLATE_TYPE_TITLES[key]) }}
</v-tab>
</v-tabs>
<div class="ml-4 mr-4 mt-6" v-if="item.type">
<v-text-field
v-if="item.type === 'build'"
v-model="item.start_version"
:label="$t('startVersion')"
:rules="[v => !!v || $t('start_version_required')]"
required
:disabled="formSaving"
:placeholder="$t('example000')"
append-outer-icon="mdi-help-circle"
@click:append-outer="showHelpDialog('build_version')"
></v-text-field>
<v-autocomplete
v-if="item.type === 'deploy'"
v-model="item.build_template_id"
:label="$t('buildTemplate')"
:items="buildTemplates"
item-value="id"
item-text="name"
:rules="[v => !!v || $t('build_template_required')]"
required
:disabled="formSaving"
append-outer-icon="mdi-help-circle"
@click:append-outer="showHelpDialog('build')"
></v-autocomplete>
<v-checkbox
v-if="item.type === 'deploy'"
class="mt-0"
:label="$t('autorun')"
v-model="item.autorun"
/>
</div>
</v-card>
<v-text-field
v-model="item.name"
:label="$t('name2')"
:rules="[v => !!v || $t('name_required')]"
outlined
dense
required
:disabled="formSaving"
></v-text-field>
<v-textarea
v-model="item.description"
:label="$t('description')"
:disabled="formSaving"
rows="1"
:auto-grow="true"
outlined
dense
></v-textarea>
<v-text-field
v-model="item.playbook"
label="Subdirectory path (Optional)"
outlined
dense
required
:disabled="formSaving"
placeholder="Example: path/to/terraform/dir"
></v-text-field>
<v-select
v-model="item.inventory_id"
label="Default Workspace"
:items="inventory"
item-value="id"
item-text="name"
outlined
dense
required
:disabled="formSaving"
></v-select>
<v-select
v-model="item.repository_id"
:label="$t('repository') + ' *'"
:items="repositories"
item-value="id"
item-text="name"
:rules="[v => !!v || $t('repository_required')]"
outlined
dense
required
:disabled="formSaving"
></v-select>
<v-select
v-model="item.environment_id"
:label="$t('environment3')"
:items="environment"
item-value="id"
item-text="name"
:rules="[v => !!v || $t('environment_required')]"
outlined
dense
required
:disabled="formSaving"
></v-select>
</v-col>
<v-col cols="12" md="6" class="pb-0">
<SurveyVars style="margin-top: -10px;" :vars="item.survey_vars" @change="setSurveyVars"/>
<v-select
v-model="item.view_id"
:label="$t('view')"
clearable
:items="views"
item-value="id"
item-text="title"
:disabled="formSaving"
outlined
dense
></v-select>
<v-row>
<v-col cols="5" class="pr-1">
<v-text-field
style="font-size: 14px"
v-model="cronFormat"
:label="$t('cron')"
:disabled="formSaving"
placeholder="* * * * *"
v-if="schedules == null || schedules.length <= 1"
outlined
dense
hide-details
></v-text-field>
</v-col>
<v-col cols="7">
<a
v-if="!cronRepositoryIdVisible && cronRepositoryId == null"
@click="cronRepositoryIdVisible = true"
class="text-caption d-block"
style="line-height: 1.1;"
>
{{ $t('iWantToRunATaskByTheCronOnlyForForNewCommitsOfSome') }}
</a>
<v-select
style="font-size: 14px"
v-if="cronRepositoryIdVisible || cronRepositoryId != null"
v-model="cronRepositoryId"
:label="$t('repository2')"
:placeholder="$t('cronChecksNewCommitBeforeRun')"
:items="repositories"
item-value="id"
item-text="name"
clearable
:disabled="formSaving"
outlined
dense
hide-details
></v-select>
</v-col>
</v-row>
<small class="mt-1 mb-4 d-block">
{{ $t('readThe') }}
<a target="_blank"
href="https://pkg.go.dev/github.com/robfig/cron/v3#hdr-CRON_Expression_Format">{{
$t('docs')
}}</a>
{{ $t('toLearnMoreAboutCron') }}
</small>
<v-checkbox
class="mt-0"
:label="$t('suppressSuccessAlerts')"
v-model="item.suppress_success_alerts"
/>
</v-col>
</v-row>
</v-form>
</template>
<style lang="scss">
.CodeMirror-placeholder {
color: #a4a4a4 !important;
}
</style>
<script>
/* eslint-disable import/no-extraneous-dependencies,import/extensions */
import axios from 'axios';
import ItemFormBase from '@/components/ItemFormBase';
import { TEMPLATE_TYPE_ICONS, TEMPLATE_TYPE_TITLES } from '../lib/constants';
import SurveyVars from './SurveyVars';
export default {
mixins: [ItemFormBase],
components: {
SurveyVars,
},
props: {
sourceItemId: Number,
app: String,
},
data() {
return {
itemTypeIndex: 0,
TEMPLATE_TYPE_ICONS,
TEMPLATE_TYPE_TITLES,
cmOptions: {
tabSize: 2,
mode: 'application/json',
lineNumbers: true,
line: true,
lint: true,
indentWithTabs: false,
},
item: null,
keys: null,
inventory: null,
repositories: null,
environment: null,
views: null,
schedules: null,
buildTemplates: null,
cronFormat: null,
cronRepositoryId: null,
cronRepositoryIdVisible: false,
helpDialog: null,
helpKey: null,
advancedOptions: false,
};
},
watch: {
needReset(val) {
if (val) {
if (this.item != null) {
this.item.template_id = this.templateId;
}
}
},
sourceItemId(val) {
this.item.template_id = val;
},
itemTypeIndex(val) {
this.item.type = Object.keys(TEMPLATE_TYPE_ICONS)[val];
},
},
computed: {
isLoaded() {
if (this.isNew && this.sourceItemId == null) {
return true;
}
return this.keys != null
&& this.repositories != null
&& this.inventory != null
&& this.environment != null
&& this.item != null
&& this.schedules != null
&& this.views != null;
},
},
methods: {
setSurveyVars(v) {
this.item.survey_vars = v;
},
showHelpDialog(key) {
this.helpKey = key;
this.helpDialog = true;
},
async afterLoadData() {
if (this.sourceItemId) {
this.item = (await axios({
keys: 'get',
url: `/api/project/${this.projectId}/templates/${this.sourceItemId}`,
responseType: 'json',
})).data;
}
this.advancedOptions = this.item.arguments != null || this.item.allow_override_args_in_task;
this.keys = (await axios({
keys: 'get',
url: `/api/project/${this.projectId}/keys`,
responseType: 'json',
})).data;
this.repositories = (await axios({
keys: 'get',
url: `/api/project/${this.projectId}/repositories`,
responseType: 'json',
})).data;
this.inventory = (await axios({
keys: 'get',
url: `/api/project/${this.projectId}/inventory`,
responseType: 'json',
})).data.filter((inv) => inv.type === `${this.app}-workspace` && inv.holder_id == null);
this.environment = (await axios({
keys: 'get',
url: `/api/project/${this.projectId}/environment`,
responseType: 'json',
})).data;
const template = (await axios({
keys: 'get',
url: `/api/project/${this.projectId}/templates`,
responseType: 'json',
})).data;
const builds = [];
const deploys = [];
template.forEach((t) => {
switch (t.type) {
case 'build':
if (builds.length === 0) {
builds.push({ header: 'Build Templates' });
}
builds.push(t);
break;
case 'deploy':
if (deploys.length === 0) {
deploys.push({ header: 'Deploy Templates' });
}
deploys.push(t);
break;
default:
break;
}
});
this.buildTemplates = builds;
if (this.buildTemplates.length > 0 && deploys.length > 0) {
this.buildTemplates.push({ divider: true });
}
this.buildTemplates.push(...deploys);
this.schedules = this.isNew ? [] : (await axios({
keys: 'get',
url: `/api/project/${this.projectId}/templates/${this.itemId}/schedules`,
responseType: 'json',
})).data;
this.views = (await axios({
keys: 'get',
url: `/api/project/${this.projectId}/views`,
responseType: 'json',
})).data;
if (this.schedules.length === 1) {
this.cronFormat = this.schedules[0].cron_format;
this.cronRepositoryId = this.schedules[0].repository_id;
}
this.itemTypeIndex = Object.keys(TEMPLATE_TYPE_ICONS).indexOf(this.item.type);
},
getItemsUrl() {
return `/api/project/${this.projectId}/templates`;
},
getSingleItemUrl() {
return `/api/project/${this.projectId}/templates/${this.itemId}`;
},
async beforeSave() {
this.item.app = this.app;
if (this.cronFormat == null || this.cronFormat === '') {
return;
}
await axios({
method: 'post',
url: `/api/project/${this.projectId}/schedules/validate`,
responseType: 'json',
data: {
cron_format: this.cronFormat,
},
});
},
async afterSave(newItem) {
if (newItem || this.schedules.length === 0) {
if (this.cronFormat != null && this.cronFormat !== '') {
// new schedule
await axios({
method: 'post',
url: `/api/project/${this.projectId}/schedules`,
responseType: 'json',
data: {
project_id: this.projectId,
template_id: newItem ? newItem.id : this.itemId,
cron_format: this.cronFormat,
repository_id: this.cronRepositoryId,
},
});
}
} else if (this.schedules.length > 1) {
// do nothing
} else if (this.cronFormat == null || this.cronFormat === '') {
// drop schedule
await axios({
method: 'delete',
url: `/api/project/${this.projectId}/schedules/${this.schedules[0].id}`,
responseType: 'json',
});
} else {
// update schedule
await axios({
method: 'put',
url: `/api/project/${this.projectId}/schedules/${this.schedules[0].id}`,
responseType: 'json',
data: {
id: this.schedules[0].id,
project_id: this.projectId,
template_id: this.itemId,
cron_format: this.cronFormat,
repository_id: this.cronRepositoryId,
},
});
}
},
},
};
</script>

View File

@ -74,7 +74,7 @@ export const EXTRACT_VALUE_BODY_DATA_TYPE_ICONS = {
};
export const APP_ICONS = {
'': {
ansible: {
icon: 'mdi-ansible',
color: 'black',
darkColor: 'white',
@ -102,7 +102,7 @@ export const APP_ICONS = {
};
export const APP_TITLE = {
'': 'Ansible Playbook',
ansible: 'Ansible Playbook',
terraform: 'Terraform Code',
tofu: 'OpenTofu Code',
bash: 'Bash Script',
@ -110,7 +110,9 @@ export const APP_TITLE = {
};
export const APP_INVENTORY_TITLE = {
'': 'Ansible Inventory',
ansible: 'Ansible Inventory',
terraform: 'Terraform Workspace',
tofu: 'OpenTofu Workspace',
};
export const DEFAULT_APPS = Object.keys(APP_ICONS);

View File

@ -16,6 +16,7 @@ import Auth from '../views/Auth.vue';
import New from '../views/project/New.vue';
import Integrations from '../views/project/Integrations.vue';
import IntegrationExtractor from '../views/project/IntegrationExtractor.vue';
import Apps from '../views/Apps.vue';
Vue.use(VueRouter);
@ -100,6 +101,10 @@ const routes = [
path: '/users',
component: Users,
},
{
path: '/apps',
component: Apps,
},
];
const router = new VueRouter({

178
web/src/views/Apps.vue Normal file
View File

@ -0,0 +1,178 @@
<template xmlns:v-slot="http://www.w3.org/1999/XSL/Transform">
<div v-if="items != null">
<EditDialog
v-model="editDialog"
save-button-text="Save"
:title="$t('Edit App')"
@save="loadItems()"
>
<template v-slot:form="{ onSave, onError, needSave, needReset }">
<AppForm
:project-id="projectId"
:item-id="itemId"
@save="onSave"
@error="onError"
:need-save="needSave"
:need-reset="needReset"
/>
</template>
</EditDialog>
<YesNoDialog
:title="$t('Delete App')"
:text="$t('Do you really want to delete this app?')"
v-model="deleteItemDialog"
@yes="deleteItem(itemId)"
/>
<v-toolbar flat >
<v-btn
icon
class="mr-4"
@click="returnToProjects()"
>
<v-icon>mdi-arrow-left</v-icon>
</v-btn>
<v-toolbar-title>{{ $t('Applications') }}</v-toolbar-title>
<v-spacer></v-spacer>
<v-btn
color="primary"
@click="editItem('')"
>{{ $t('New App') }}</v-btn>
</v-toolbar>
<v-data-table
:headers="headers"
:items="items"
class="mt-4"
:footer-props="{ itemsPerPageOptions: [20] }"
>
<template v-slot:item.active="{ item }">
<v-switch
v-model="item.active"
inset
@change="setActive(item.id, item.active)"
></v-switch>
</template>
<template v-slot:item.title="{ item }">
<v-icon
class="mr-2"
small
>
{{ getAppIcon(item.id) }}
</v-icon>
{{ getAppTitle(item.id) }}
</template>
<template v-slot:item.id="{ item }">
<code>{{ item.id }}</code>
</template>
<template v-slot:item.actions="{ item }">
<div style="white-space: nowrap">
<v-btn
v-if="!isDefaultApp(item.id)"
icon
class="mr-1"
@click="askDeleteItem(item.id)"
:disabled="item.id === userId"
>
<v-icon>mdi-delete</v-icon>
</v-btn>
<v-btn
v-if="!isDefaultApp(item.id)"
icon
class="mr-1"
@click="editItem(item.id)"
>
<v-icon>mdi-pencil</v-icon>
</v-btn>
</div>
</template>
</v-data-table>
</div>
</template>
<script>
import EventBus from '@/event-bus';
import YesNoDialog from '@/components/YesNoDialog.vue';
import ItemListPageBase from '@/components/ItemListPageBase';
import EditDialog from '@/components/EditDialog.vue';
import axios from 'axios';
import AppForm from '../components/AppForm.vue';
import { DEFAULT_APPS } from '../lib/constants';
import AppsMixin from '../components/AppsMixin';
import delay from '../lib/delay';
export default {
mixins: [ItemListPageBase, AppsMixin],
components: {
AppForm,
YesNoDialog,
EditDialog,
},
methods: {
getHeaders() {
return [{
text: '',
value: 'active',
}, {
text: this.$i18n.t('name'),
value: 'title',
}, {
text: 'ID',
value: 'id',
width: '100%',
}, {
text: this.$i18n.t('actions'),
value: 'actions',
sortable: false,
}];
},
async loadAppsDataFromBackend() {
while (this.items == null) {
// eslint-disable-next-line no-await-in-loop
await delay(100);
}
return this.items;
},
async returnToProjects() {
EventBus.$emit('i-open-last-project');
},
getItemsUrl() {
return '/api/apps';
},
getSingleItemUrl() {
return `/api/apps/${this.itemId}`;
},
getEventName() {
return 'i-app';
},
async setActive(appId, active) {
await axios({
method: 'post',
url: `/api/apps/${appId}/active`,
responseType: 'json',
data: {
active,
},
});
},
isDefaultApp(appId) {
return DEFAULT_APPS.includes(appId);
},
},
};
</script>

View File

View File

@ -30,6 +30,13 @@
>
<template v-slot:item.tpl_alias="{ item }">
<div class="d-flex">
<v-icon
class="mr-3"
small
>
{{ getAppIcon(item.tpl_app) }}
</v-icon>
<v-icon class="mr-3" small>
{{ TEMPLATE_TYPE_ICONS[item.tpl_type] }}
</v-icon>
@ -94,9 +101,10 @@ import TaskStatus from '@/components/TaskStatus.vue';
import TaskLink from '@/components/TaskLink.vue';
import socket from '@/socket';
import { TEMPLATE_TYPE_ICONS } from '@/lib/constants';
import AppsMixin from '@/components/AppsMixin';
export default {
mixins: [ItemListPageBase],
mixins: [ItemListPageBase, AppsMixin],
data() {
return { TEMPLATE_TYPE_ICONS };

View File

@ -202,7 +202,7 @@ import {
} from '@/lib/constants';
import ObjectRefsDialog from '@/components/ObjectRefsDialog.vue';
import NewTaskDialog from '@/components/NewTaskDialog.vue';
import EditTemplateDialogue from '@/components/EditTemplateDialogue.vue';
import EditTemplateDialogue from '@/components/EditTemplateDialog.vue';
import PermissionsCheck from '@/components/PermissionsCheck';
export default {

View File

@ -26,13 +26,13 @@
</v-card>
</v-dialog>
<EditTemplateDialogue
<EditTemplateDialog
v-model="editDialog"
:project-id="projectId"
:item-app="itemApp"
item-id="new"
@save="loadItems()"
></EditTemplateDialogue>
></EditTemplateDialog>
<NewTaskDialog
v-model="newTaskDialog"
@ -54,7 +54,7 @@
<v-menu
offset-y
:disabled="templateApps.length === 0"
:disabled="apps.length === 0"
>
<template v-slot:activator="{ on, attrs }">
<v-btn
@ -63,28 +63,39 @@
color="primary"
class="mr-1 pr-2"
v-if="can(USER_PERMISSIONS.manageProjectResources)"
@click="templateApps.length > 0 || editItem('new')"
@click="activeAppIds.length > 0 || editItem('new')"
>
{{ $t('newTemplate') }}
<v-icon v-if="templateApps.length > 0">mdi-chevron-down</v-icon>
<v-icon v-if="activeAppIds.length > 0">mdi-chevron-down</v-icon>
<span v-else class="pl-2"></span>
</v-btn>
</template>
<v-list>
<v-list-item
v-for="item in templateApps"
:key="item"
v-for="appID in activeAppIds"
:key="appID"
link
@click="editItem('new'); itemApp = item;"
@click="editItem('new'); itemApp = appID;"
>
<v-list-item-icon>
<v-icon
:color="$vuetify.theme.dark ? APP_ICONS[item].darkColor : APP_ICONS[item].color"
:color="getAppColor(appID)"
>
{{ APP_ICONS[item].icon }}
{{ getAppIcon(appID) }}
</v-icon>
</v-list-item-icon>
<v-list-item-title>{{ APP_TITLE[item] }}</v-list-item-title>
<v-list-item-title>{{ getAppTitle(appID) }}</v-list-item-title>
</v-list-item>
<v-divider/>
<v-list-item
key="other"
link
href="/apps"
>
<v-list-item-icon>
<v-icon>mdi-cogs</v-icon>
</v-list-item-icon>
<v-list-item-title>Applications</v-list-item-title>
</v-list-item>
</v-list>
</v-menu>
@ -132,9 +143,8 @@
<v-icon
class="mr-3"
small
v-if="templateApps.length > 0"
>
{{ APP_ICONS[item.app].icon }}
{{ getAppIcon(item.app) }}
</v-icon>
<v-icon class="mr-3" small>
@ -258,16 +268,15 @@ import socket from '@/socket';
import NewTaskDialog from '@/components/NewTaskDialog.vue';
import {
APP_ICONS,
APP_TITLE,
TEMPLATE_TYPE_ACTION_TITLES,
TEMPLATE_TYPE_ICONS,
} from '@/lib/constants';
import EditTemplateDialogue from '@/components/EditTemplateDialogue.vue';
import EditTemplateDialog from '@/components/EditTemplateDialog.vue';
import AppsMixin from '@/components/AppsMixin';
export default {
components: {
EditTemplateDialogue,
EditTemplateDialog,
TableSettingsSheet,
TaskStatus,
TaskLink,
@ -275,7 +284,7 @@ export default {
EditViewsForm,
NewTaskDialog,
},
mixins: [ItemListPageBase],
mixins: [ItemListPageBase, AppsMixin],
async created() {
socket.addListener((data) => this.onWebsocketDataReceived(data));
@ -283,8 +292,6 @@ export default {
},
data() {
return {
APP_TITLE,
APP_ICONS,
TEMPLATE_TYPE_ICONS,
TEMPLATE_TYPE_ACTION_TITLES,
inventory: null,
@ -298,13 +305,7 @@ export default {
editViewsDialog: null,
viewItemsLoading: null,
viewTab: null,
templateApps: [
'', // Ansible
'terraform',
'tofu',
'bash',
// 'pulumi',
],
apps: null,
itemApp: '',
};
},
@ -342,7 +343,8 @@ export default {
&& this.inventory
&& this.environment
&& this.repositories
&& this.views;
&& this.views
&& this.isAppsLoaded;
},
},
watch: {