Просмотр исходного кода

feature: The executor now controls the public action map - meaning actions work without the API, and pages load faster (and much more!) (#324)

* feature: The executor now controls the public action map

* bugfix: Build the action map if there are no entities!

* bugfix: Sort by title if the actions have the same config order
James Read 2 лет назад
Родитель
Сommit
4bac315568

+ 1 - 0
OliveTin.proto

@@ -12,6 +12,7 @@ message Action {
 	bool can_exec = 4;
 	bool can_exec = 4;
 	repeated ActionArgument arguments = 5;
 	repeated ActionArgument arguments = 5;
 	string popup_on_start = 6;
 	string popup_on_start = 6;
+	int32 order = 7;
 }
 }
 
 
 message ActionArgument {
 message ActionArgument {

+ 4 - 2
cmd/OliveTin/main.go

@@ -135,9 +135,10 @@ func main() {
 
 
 	log.Debugf("Config: %+v", cfg)
 	log.Debugf("Config: %+v", cfg)
 
 
-	executor := executor.DefaultExecutor()
+	executor := executor.DefaultExecutor(cfg)
+	executor.RebuildActionMap()
 	executor.AddListener(websocket.ExecutionListener)
 	executor.AddListener(websocket.ExecutionListener)
-	config.AddListener(websocket.OnConfigChanged)
+	config.AddListener(executor.RebuildActionMap)
 
 
 	go onstartup.Execute(cfg, executor)
 	go onstartup.Execute(cfg, executor)
 	go oncron.Schedule(cfg, executor)
 	go oncron.Schedule(cfg, executor)
@@ -145,6 +146,7 @@ func main() {
 	go oncalendarfile.Schedule(cfg, executor)
 	go oncalendarfile.Schedule(cfg, executor)
 
 
 	entityfiles.AddListener(websocket.OnEntityChanged)
 	entityfiles.AddListener(websocket.OnEntityChanged)
+	entityfiles.AddListener(executor.RebuildActionMap)
 	go entityfiles.SetupEntityFileWatchers(cfg)
 	go entityfiles.SetupEntityFileWatchers(cfg)
 
 
 	go updatecheck.StartUpdateChecker(version, commit, cfg, cfg.GetDir())
 	go updatecheck.StartUpdateChecker(version, commit, cfg, cfg.GetDir())

+ 15 - 1
internal/executor/executor.go

@@ -31,12 +31,23 @@ var (
 	})
 	})
 )
 )
 
 
+type ActionBinding struct {
+	Action       *config.Action
+	EntityPrefix string
+	ConfigOrder  int
+}
+
 // Executor represents a helper class for executing commands. It's main method
 // Executor represents a helper class for executing commands. It's main method
 // is ExecRequest
 // is ExecRequest
 type Executor struct {
 type Executor struct {
 	Logs           map[string]*InternalLogEntry
 	Logs           map[string]*InternalLogEntry
 	LogsByActionId map[string][]*InternalLogEntry
 	LogsByActionId map[string][]*InternalLogEntry
 
 
+	MapActionIdToBinding     map[string]*ActionBinding
+	MapActionIdToBindingLock sync.RWMutex
+
+	Cfg *config.Config
+
 	listeners []listener
 	listeners []listener
 
 
 	chainOfCommand []executorStepFunc
 	chainOfCommand []executorStepFunc
@@ -92,10 +103,12 @@ type executorStepFunc func(*ExecutionRequest) bool
 
 
 // DefaultExecutor returns an Executor, with a sensible "chain of command" for
 // DefaultExecutor returns an Executor, with a sensible "chain of command" for
 // executing actions.
 // executing actions.
-func DefaultExecutor() *Executor {
+func DefaultExecutor(cfg *config.Config) *Executor {
 	e := Executor{}
 	e := Executor{}
+	e.Cfg = cfg
 	e.Logs = make(map[string]*InternalLogEntry)
 	e.Logs = make(map[string]*InternalLogEntry)
 	e.LogsByActionId = make(map[string][]*InternalLogEntry)
 	e.LogsByActionId = make(map[string][]*InternalLogEntry)
+	e.MapActionIdToBinding = make(map[string]*ActionBinding)
 
 
 	e.chainOfCommand = []executorStepFunc{
 	e.chainOfCommand = []executorStepFunc{
 		stepRequestAction,
 		stepRequestAction,
@@ -117,6 +130,7 @@ func DefaultExecutor() *Executor {
 type listener interface {
 type listener interface {
 	OnExecutionStarted(actionTitle string)
 	OnExecutionStarted(actionTitle string)
 	OnExecutionFinished(logEntry *InternalLogEntry)
 	OnExecutionFinished(logEntry *InternalLogEntry)
+	OnActionMapRebuilt()
 }
 }
 
 
 func (e *Executor) AddListener(m listener) {
 func (e *Executor) AddListener(m listener) {

+ 89 - 0
internal/executor/executor_actions.go

@@ -0,0 +1,89 @@
+package executor
+
+import (
+	"crypto/sha256"
+	"fmt"
+	config "github.com/OliveTin/OliveTin/internal/config"
+	sv "github.com/OliveTin/OliveTin/internal/stringvariables"
+	log "github.com/sirupsen/logrus"
+	"strconv"
+)
+
+func (e *Executor) FindActionBindingByID(id string) *config.Action {
+	e.MapActionIdToBindingLock.RLock()
+	pair, found := e.MapActionIdToBinding[id]
+	e.MapActionIdToBindingLock.RUnlock()
+
+	if found {
+		log.Infof("findActionBinding %v, %v", id, pair.Action.ID)
+		return pair.Action
+	}
+
+	return nil
+}
+
+func (e *Executor) RebuildActionMap() {
+	e.MapActionIdToBindingLock.Lock()
+
+	clear(e.MapActionIdToBinding)
+
+	for configOrder, action := range e.Cfg.Actions {
+		if action.Entity != "" {
+			registerActionsFromEntities(e, configOrder, action.Entity, action)
+		} else {
+			registerAction(e, configOrder, action)
+		}
+	}
+
+	e.MapActionIdToBindingLock.Unlock()
+
+	for _, l := range e.listeners {
+		l.OnActionMapRebuilt()
+	}
+}
+
+func registerAction(e *Executor, configOrder int, action *config.Action) {
+	actionId := hashActionToID(action, "")
+
+	e.MapActionIdToBinding[actionId] = &ActionBinding{
+		Action:       action,
+		EntityPrefix: "noent",
+		ConfigOrder:  configOrder,
+	}
+}
+
+func registerActionsFromEntities(e *Executor, configOrder int, entityTitle string, tpl *config.Action) {
+	entityCount, _ := strconv.Atoi(sv.Get("entities." + entityTitle + ".count"))
+
+	for i := 0; i < entityCount; i++ {
+		registerActionFromEntity(e, configOrder, tpl, entityTitle, i)
+	}
+}
+
+func registerActionFromEntity(e *Executor, configOrder int, tpl *config.Action, entityTitle string, entityIndex int) {
+	prefix := sv.GetEntityPrefix(entityTitle, entityIndex)
+
+	virtualActionId := hashActionToID(tpl, prefix)
+
+	e.MapActionIdToBinding[virtualActionId] = &ActionBinding{
+		Action:       tpl,
+		EntityPrefix: prefix,
+		ConfigOrder:  configOrder,
+	}
+}
+
+func hashActionToID(action *config.Action, entityPrefix string) string {
+	if action.ID != "" && entityPrefix == "" {
+		return action.ID
+	}
+
+	h := sha256.New()
+
+	if entityPrefix == "" {
+		h.Write([]byte(action.Title))
+	} else {
+		h.Write([]byte(action.ID + "." + entityPrefix))
+	}
+
+	return fmt.Sprintf("%x", h.Sum(nil))
+}

+ 2 - 2
internal/executor/executor_test.go

@@ -9,10 +9,10 @@ import (
 )
 )
 
 
 func testingExecutor() (*Executor, *config.Config) {
 func testingExecutor() (*Executor, *config.Config) {
-	e := DefaultExecutor()
-
 	cfg := config.DefaultConfig()
 	cfg := config.DefaultConfig()
 
 
+	e := DefaultExecutor(cfg)
+
 	a1 := &config.Action{
 	a1 := &config.Action{
 		Title: "Do some tickles",
 		Title: "Do some tickles",
 		Shell: "echo 'Tickling {{ person }}'",
 		Shell: "echo 'Tickling {{ person }}'",

+ 12 - 6
internal/grpcapi/grpcApi.go

@@ -59,7 +59,9 @@ func (api *oliveTinAPI) StartAction(ctx ctx.Context, req *pb.StartActionRequest)
 		args[arg.Name] = arg.Value
 		args[arg.Name] = arg.Value
 	}
 	}
 
 
-	pair := publicActionIdToActionMap[req.ActionId]
+	api.executor.MapActionIdToBindingLock.RLock()
+	pair := api.executor.MapActionIdToBinding[req.ActionId]
+	api.executor.MapActionIdToBindingLock.RUnlock()
 
 
 	execReq := executor.ExecutionRequest{
 	execReq := executor.ExecutionRequest{
 		Action:            pair.Action,
 		Action:            pair.Action,
@@ -81,7 +83,7 @@ func (api *oliveTinAPI) StartActionAndWait(ctx ctx.Context, req *pb.StartActionA
 	args := make(map[string]string)
 	args := make(map[string]string)
 
 
 	execReq := executor.ExecutionRequest{
 	execReq := executor.ExecutionRequest{
-		Action:            findActionByPublicID(req.ActionId),
+		Action:            api.executor.FindActionBindingByID(req.ActionId),
 		TrackingID:        uuid.NewString(),
 		TrackingID:        uuid.NewString(),
 		Arguments:         args,
 		Arguments:         args,
 		AuthenticatedUser: acl.UserFromContext(ctx, cfg),
 		AuthenticatedUser: acl.UserFromContext(ctx, cfg),
@@ -106,7 +108,7 @@ func (api *oliveTinAPI) StartActionByGet(ctx ctx.Context, req *pb.StartActionByG
 	args := make(map[string]string)
 	args := make(map[string]string)
 
 
 	execReq := executor.ExecutionRequest{
 	execReq := executor.ExecutionRequest{
-		Action:            findActionByPublicID(req.ActionId),
+		Action:            api.executor.FindActionBindingByID(req.ActionId),
 		TrackingID:        uuid.NewString(),
 		TrackingID:        uuid.NewString(),
 		Arguments:         args,
 		Arguments:         args,
 		AuthenticatedUser: acl.UserFromContext(ctx, cfg),
 		AuthenticatedUser: acl.UserFromContext(ctx, cfg),
@@ -124,7 +126,7 @@ func (api *oliveTinAPI) StartActionByGetAndWait(ctx ctx.Context, req *pb.StartAc
 	args := make(map[string]string)
 	args := make(map[string]string)
 
 
 	execReq := executor.ExecutionRequest{
 	execReq := executor.ExecutionRequest{
-		Action:            findActionByPublicID(req.ActionId),
+		Action:            api.executor.FindActionBindingByID(req.ActionId),
 		TrackingID:        uuid.NewString(),
 		TrackingID:        uuid.NewString(),
 		Arguments:         args,
 		Arguments:         args,
 		AuthenticatedUser: acl.UserFromContext(ctx, cfg),
 		AuthenticatedUser: acl.UserFromContext(ctx, cfg),
@@ -233,7 +235,7 @@ func (api *oliveTinAPI) WatchExecution(req *pb.WatchExecutionRequest, srv pb.Oli
 func (api *oliveTinAPI) GetDashboardComponents(ctx ctx.Context, req *pb.GetDashboardComponentsRequest) (*pb.GetDashboardComponentsResponse, error) {
 func (api *oliveTinAPI) GetDashboardComponents(ctx ctx.Context, req *pb.GetDashboardComponentsRequest) (*pb.GetDashboardComponentsResponse, error) {
 	user := acl.UserFromContext(ctx, cfg)
 	user := acl.UserFromContext(ctx, cfg)
 
 
-	res := actionsCfgToPb(cfg.Actions, user)
+	res := buildDashboardResponse(api.executor, cfg, user)
 
 
 	if len(res.Actions) == 0 {
 	if len(res.Actions) == 0 {
 		log.Warn("Zero actions found - check that you have some actions defined, with a view permission")
 		log.Warn("Zero actions found - check that you have some actions defined, with a view permission")
@@ -347,13 +349,17 @@ func (api *oliveTinAPI) DumpPublicIdActionMap(ctx ctx.Context, req *pb.DumpPubli
 		return res, nil
 		return res, nil
 	}
 	}
 
 
-	for k, v := range publicActionIdToActionMap {
+	api.executor.MapActionIdToBindingLock.RLock()
+
+	for k, v := range api.executor.MapActionIdToBinding {
 		res.Contents[k] = &pb.ActionEntityPair{
 		res.Contents[k] = &pb.ActionEntityPair{
 			ActionTitle:  v.Action.Title,
 			ActionTitle:  v.Action.Title,
 			EntityPrefix: v.EntityPrefix,
 			EntityPrefix: v.EntityPrefix,
 		}
 		}
 	}
 	}
 
 
+	api.executor.MapActionIdToBindingLock.RUnlock()
+
 	res.Alert = "Dumping variables has been enabled in the configuration. Please set InsecureAllowDumpActionMap = false again after you don't need it anymore"
 	res.Alert = "Dumping variables has been enabled in the configuration. Please set InsecureAllowDumpActionMap = false again after you don't need it anymore"
 
 
 	return res, nil
 	return res, nil

+ 22 - 84
internal/grpcapi/grpcApiActions.go

@@ -1,90 +1,50 @@
 package grpcapi
 package grpcapi
 
 
 import (
 import (
-	"crypto/sha256"
-	"fmt"
 	pb "github.com/OliveTin/OliveTin/gen/grpc"
 	pb "github.com/OliveTin/OliveTin/gen/grpc"
 	acl "github.com/OliveTin/OliveTin/internal/acl"
 	acl "github.com/OliveTin/OliveTin/internal/acl"
 	config "github.com/OliveTin/OliveTin/internal/config"
 	config "github.com/OliveTin/OliveTin/internal/config"
+	executor "github.com/OliveTin/OliveTin/internal/executor"
 	sv "github.com/OliveTin/OliveTin/internal/stringvariables"
 	sv "github.com/OliveTin/OliveTin/internal/stringvariables"
-	log "github.com/sirupsen/logrus"
-	"strconv"
+	"sort"
 )
 )
 
 
-type ActionWithEntity struct {
-	Action       *config.Action
-	EntityPrefix string
-}
-
-var publicActionIdToActionMap map[string]ActionWithEntity
-
-func init() {
-	publicActionIdToActionMap = make(map[string]ActionWithEntity)
-}
-
-func actionsCfgToPb(cfgActions []*config.Action, user *acl.AuthenticatedUser) *pb.GetDashboardComponentsResponse {
+func buildDashboardResponse(ex *executor.Executor, cfg *config.Config, user *acl.AuthenticatedUser) *pb.GetDashboardComponentsResponse {
 	res := &pb.GetDashboardComponentsResponse{}
 	res := &pb.GetDashboardComponentsResponse{}
 
 
-	for _, action := range cfgActions {
-		if !acl.IsAllowedView(cfg, user, action) {
+	ex.MapActionIdToBindingLock.RLock()
+
+	for actionId, actionBinding := range ex.MapActionIdToBinding {
+		if !acl.IsAllowedView(cfg, user, actionBinding.Action) {
 			continue
 			continue
 		}
 		}
 
 
-		if action.Entity != "" {
-			res.Actions = append(res.Actions, buildActionEntities(action.Entity, action)...)
-		} else {
-			btn := actionCfgToPb(action, user)
-			res.Actions = append(res.Actions, btn)
-		}
+		res.Actions = append(res.Actions, buildAction(actionId, actionBinding, user))
 	}
 	}
 
 
-	return res
-}
-
-func buildActionEntities(entityTitle string, tpl *config.Action) []*pb.Action {
-	ret := make([]*pb.Action, 0)
-
-	entityCount, _ := strconv.Atoi(sv.Get("entities." + entityTitle + ".count"))
-
-	for i := 0; i < entityCount; i++ {
-		ret = append(ret, buildEntityAction(tpl, entityTitle, i))
-	}
-
-	return ret
-}
-
-func buildEntityAction(tpl *config.Action, entityTitle string, entityIndex int) *pb.Action {
-	prefix := sv.GetEntityPrefix(entityTitle, entityIndex)
+	ex.MapActionIdToBindingLock.RUnlock()
 
 
-	virtualActionId := createPublicID(tpl, prefix)
-
-	publicActionIdToActionMap[virtualActionId] = ActionWithEntity{
-		Action:       tpl,
-		EntityPrefix: prefix,
-	}
+	sort.Slice(res.Actions, func(i, j int) bool {
+		if res.Actions[i].Order == res.Actions[j].Order {
+			return res.Actions[i].Title < res.Actions[j].Title
+		} else {
+			return res.Actions[i].Order < res.Actions[j].Order
+		}
+	})
 
 
-	return &pb.Action{
-		Id:           virtualActionId,
-		Title:        sv.ReplaceEntityVars(prefix, tpl.Title),
-		Icon:         tpl.Icon,
-		PopupOnStart: tpl.PopupOnStart,
-	}
+	return res
 }
 }
 
 
-func actionCfgToPb(action *config.Action, user *acl.AuthenticatedUser) *pb.Action {
-	virtualActionId := createPublicID(action, "")
-
-	publicActionIdToActionMap[virtualActionId] = ActionWithEntity{
-		Action:       action,
-		EntityPrefix: "noent",
-	}
+func buildAction(actionId string, actionBinding *executor.ActionBinding, user *acl.AuthenticatedUser) *pb.Action {
+	action := actionBinding.Action
 
 
 	btn := pb.Action{
 	btn := pb.Action{
-		Id:           virtualActionId,
-		Title:        action.Title,
+		Id:           actionId,
+		Title:        sv.ReplaceEntityVars(actionBinding.EntityPrefix, action.Title),
 		Icon:         action.Icon,
 		Icon:         action.Icon,
 		CanExec:      acl.IsAllowedExec(cfg, user, action),
 		CanExec:      acl.IsAllowedExec(cfg, user, action),
 		PopupOnStart: action.PopupOnStart,
 		PopupOnStart: action.PopupOnStart,
+		Order:        int32(actionBinding.ConfigOrder),
 	}
 	}
 
 
 	for _, cfgArg := range action.Arguments {
 	for _, cfgArg := range action.Arguments {
@@ -143,25 +103,3 @@ func buildChoicesSimple(choices []config.ActionArgumentChoice) []*pb.ActionArgum
 
 
 	return ret
 	return ret
 }
 }
-
-func createPublicID(action *config.Action, entityPrefix string) string {
-	if action.Entity == "" {
-		return action.ID
-	} else {
-		h := sha256.New()
-		h.Write([]byte(action.ID + "." + entityPrefix))
-
-		return fmt.Sprintf("%x", h.Sum(nil))
-	}
-}
-
-func findActionByPublicID(id string) *config.Action {
-	pair, found := publicActionIdToActionMap[id]
-
-	if found {
-		log.Infof("findPublic %v, %v", id, pair.Action.ID)
-		return pair.Action
-	}
-
-	return nil
-}

+ 9 - 2
internal/grpcapi/grpcApi_test.go

@@ -21,8 +21,8 @@ const bufSize = 1024 * 1024
 
 
 var lis *bufconn.Listener
 var lis *bufconn.Listener
 
 
-func init() {
-	ex := executor.DefaultExecutor()
+func initServer(cfg *config.Config) *executor.Executor {
+	ex := executor.DefaultExecutor(cfg)
 
 
 	lis = bufconn.Listen(bufSize)
 	lis = bufconn.Listen(bufSize)
 	s := grpc.NewServer()
 	s := grpc.NewServer()
@@ -33,6 +33,8 @@ func init() {
 			log.Fatalf("Server exited with error: %v", err)
 			log.Fatalf("Server exited with error: %v", err)
 		}
 		}
 	}()
 	}()
+
+	return ex
 }
 }
 
 
 func bufDialer(context.Context, string) (net.Conn, error) {
 func bufDialer(context.Context, string) (net.Conn, error) {
@@ -57,12 +59,17 @@ func getNewTestServerAndClient(t *testing.T, injectedConfig *config.Config) (*gr
 
 
 func TestGetActionsAndStart(t *testing.T) {
 func TestGetActionsAndStart(t *testing.T) {
 	cfg = config.DefaultConfig()
 	cfg = config.DefaultConfig()
+
+	ex := initServer(cfg)
+
 	btn1 := &config.Action{}
 	btn1 := &config.Action{}
 	btn1.Title = "blat"
 	btn1.Title = "blat"
 	btn1.ID = "blat"
 	btn1.ID = "blat"
 	btn1.Shell = "echo 'test'"
 	btn1.Shell = "echo 'test'"
 	cfg.Actions = append(cfg.Actions, btn1)
 	cfg.Actions = append(cfg.Actions, btn1)
 
 
+	ex.RebuildActionMap()
+
 	conn, client := getNewTestServerAndClient(t, cfg)
 	conn, client := getNewTestServerAndClient(t, cfg)
 
 
 	respGb, err := client.GetDashboardComponents(context.Background(), &pb.GetDashboardComponentsRequest{})
 	respGb, err := client.GetDashboardComponents(context.Background(), &pb.GetDashboardComponentsRequest{})

+ 8 - 6
internal/websocket/websocket.go

@@ -30,12 +30,6 @@ var marshalOptions = protojson.MarshalOptions{
 	EmitUnpopulated: true,
 	EmitUnpopulated: true,
 }
 }
 
 
-func OnConfigChanged() {
-	evt := &pb.EventConfigChanged{}
-
-	broadcast(evt)
-}
-
 var ExecutionListener WebsocketExecutionListener
 var ExecutionListener WebsocketExecutionListener
 
 
 type WebsocketExecutionListener struct{}
 type WebsocketExecutionListener struct{}
@@ -53,6 +47,10 @@ func OnEntityChanged() {
 	broadcast(&pb.EventEntityChanged{})
 	broadcast(&pb.EventEntityChanged{})
 }
 }
 
 
+func (WebsocketExecutionListener) OnActionMapRebuilt() {
+	broadcast(&pb.EventConfigChanged{})
+}
+
 /*
 /*
 The default checkOrigin function checks that the origin (browser) matches the
 The default checkOrigin function checks that the origin (browser) matches the
 request origin. However in OliveTin we expect many users to deliberately proxy
 request origin. However in OliveTin we expect many users to deliberately proxy
@@ -156,8 +154,12 @@ func HandleWebsocket(w http.ResponseWriter, r *http.Request) bool {
 		conn: c,
 		conn: c,
 	}
 	}
 
 
+	sendmutex.Lock()
+
 	clients = append(clients, wsclient)
 	clients = append(clients, wsclient)
 
 
+	sendmutex.Unlock()
+
 	go wsclient.messageLoop()
 	go wsclient.messageLoop()
 
 
 	return true
 	return true