Răsfoiți Sursa

#6 - task arguments, that was a lot of work!

jamesread 4 ani în urmă
părinte
comite
30d681690a

+ 1 - 1
Makefile

@@ -14,7 +14,7 @@ daemon-compile: daemon-compile-armhf daemon-compile-x64-lin daemon-compile-x64-w
 daemon-codestyle:
 	go fmt ./...
 	go vet ./...
-	gocyclo -over 3 cmd internal 
+	gocyclo -over 4 cmd internal 
 
 daemon-unittests:
 	mkdir -p reports

+ 42 - 9
OliveTin.proto

@@ -4,7 +4,7 @@ option go_package = "gen/grpc";
 
 import "google/api/annotations.proto";
 
-message ActionButton {
+message Action {
 	string id = 1;
 	string title = 2;
 	string icon = 3;
@@ -15,7 +15,7 @@ message ActionButton {
 
 message ActionArgument {
 	string name = 1;
-	string label = 2; 
+	string title = 2; 
 	string type = 3;
 	string defaultValue = 4;
 
@@ -24,18 +24,32 @@ message ActionArgument {
 
 message ActionArgumentChoice {
 	string value = 1;
-	string label = 2;
+	string title = 2;
 }
 
-message GetButtonsResponse {
+message Entity {
 	string title = 1;
-	repeated ActionButton actions = 2;
+	string icon = 2;
+	repeated Action actions = 3;
 }
 
-message GetButtonsRequest {}
+message GetDashboardComponentsResponse {
+	string title = 1;
+	repeated Action actions = 2;
+	repeated Entity entities = 3;
+}
+
+message GetDashboardComponentsRequest {}
 
 message StartActionRequest {
 	string actionName = 1;
+
+	repeated StartActionArgument arguments = 2;
+}
+
+message StartActionArgument {
+	string name = 1;
+	string value = 2;
 }
 
 message StartActionResponse {
@@ -53,22 +67,34 @@ message LogEntry {
 	int32 exitCode = 6;
 	string user = 7;
 	string userClass = 8;
+	string actionIcon = 9;
 }
 
 message GetLogsResponse {
 	repeated LogEntry logs = 1;
 }
 
+message ValidateArgumentTypeRequest {
+	string value = 1;
+	string type = 2;
+}
+
+message ValidateArgumentTypeResponse {
+	bool valid = 1;
+	string description = 2;
+}
+
 service OliveTinApi {
-	rpc GetButtons(GetButtonsRequest) returns (GetButtonsResponse) {
+	rpc GetDashboardComponents(GetDashboardComponentsRequest) returns (GetDashboardComponentsResponse) {
 		option (google.api.http) = {
-			get: "/api/GetButtons"
+			get: "/api/GetDashboardComponents"
 		};
 	}
 
 	rpc StartAction(StartActionRequest) returns (StartActionResponse) {
 		option (google.api.http) = {
-			get: "/api/StartAction"
+			post: "/api/StartAction"
+			body: "*"
 		};
 	}
 
@@ -77,4 +103,11 @@ service OliveTinApi {
 			get: "/api/GetLogs"
 		};
 	}
+
+	rpc ValidateArgumentType(ValidateArgumentTypeRequest) returns (ValidateArgumentTypeResponse) {
+		option (google.api.http) = {
+			post: "/api/ValidateArgumentType"
+			body: "*"
+		};
+	}
 }

+ 4 - 3
cmd/OliveTin/main.go

@@ -44,8 +44,6 @@ func init() {
 
 	cfg = config.DefaultConfig()
 
-	reloadConfig()
-
 	viper.WatchConfig()
 	viper.OnConfigChange(func(e fsnotify.Event) {
 		if e.Op == fsnotify.Write {
@@ -54,6 +52,9 @@ func init() {
 			reloadConfig()
 		}
 	})
+
+	reloadConfig()
+	log.Info("Init complete")
 }
 
 func reloadConfig() {
@@ -62,7 +63,7 @@ func reloadConfig() {
 		os.Exit(1)
 	}
 
-	config.Sanitize(cfg);
+	config.Sanitize(cfg)
 }
 
 func main() {

+ 2 - 2
internal/acl/acl.go

@@ -10,7 +10,7 @@ type User struct {
 	Username string
 }
 
-func IsAllowedExec(cfg *config.Config, user *User, action *config.ActionButton) bool {
+func IsAllowedExec(cfg *config.Config, user *User, action *config.Action) bool {
 	canExec := cfg.DefaultPermissions.Exec
 
 	log.WithFields(log.Fields{
@@ -40,7 +40,7 @@ func IsAllowedExec(cfg *config.Config, user *User, action *config.ActionButton)
 	return canExec
 }
 
-func IsAllowedView(cfg *config.Config, user *User, action *config.ActionButton) bool {
+func IsAllowedView(cfg *config.Config, user *User, action *config.Action) bool {
 	canView := cfg.DefaultPermissions.View
 
 	log.WithFields(log.Fields{

+ 11 - 10
internal/config/config.go

@@ -2,8 +2,7 @@ package config
 
 import ()
 
-// ActionButton represents a button that is shown in the webui.
-type ActionButton struct {
+type Action struct {
 	ID          string
 	Title       string
 	Icon        string
@@ -16,7 +15,7 @@ type ActionButton struct {
 
 type ActionArgument struct {
 	Name    string
-	Label   string
+	Title   string
 	Type    string
 	Default string
 	Choices []ActionArgumentChoice
@@ -24,16 +23,16 @@ type ActionArgument struct {
 
 type ActionArgumentChoice struct {
 	Value string
-	Label string
+	Title string
 }
 
 // Entity represents a "thing" that can have multiple actions associated with it.
 // for example, a media player with a start and stop action.
 type Entity struct {
-	Title         string
-	Icon          string
-	ActionButtons []ActionButton `mapstructure:"actions"`
-	CSS           map[string]string
+	Title   string
+	Icon    string
+	Actions []Action `mapstructure:"actions"`
+	CSS     map[string]string
 }
 
 type PermissionsEntry struct {
@@ -63,9 +62,10 @@ type Config struct {
 	ListenAddressGrpcActions        string
 	ExternalRestAddress             string
 	LogLevel                        string
-	ActionButtons                   []ActionButton `mapstructure:"actions"`
-	Entities                        []Entity       `mapstructure:"entities"`
+	Actions                         []Action `mapstructure:"actions"`
+	Entities                        []Entity `mapstructure:"entities"`
 	CheckForUpdates                 bool
+	ShowNewVersions                 bool
 	Usergroups                      []UserGroup
 	DefaultPermissions              DefaultPermissions
 }
@@ -81,6 +81,7 @@ func DefaultConfig() *Config {
 	config.ListenAddressWebUI = "localhost:1340"
 	config.LogLevel = "INFO"
 	config.CheckForUpdates = true
+	config.ShowNewVersions = true
 	config.DefaultPermissions.Exec = true
 	config.DefaultPermissions.View = true
 

+ 11 - 0
internal/config/config_helpers.go

@@ -0,0 +1,11 @@
+package config
+
+func (cfg *Config) FindAction(actionTitle string) *Action {
+	for _, action := range cfg.Actions {
+		if action.Title == actionTitle {
+			return &action
+		}
+	}
+
+	return nil
+}

+ 1 - 1
internal/grpcapi/emoji.go → internal/config/emoji.go

@@ -1,4 +1,4 @@
-package grpcapi
+package config
 
 var emojis = map[string]string{
 	"poop":  "💩",

+ 1 - 1
internal/grpcapi/emoji_test.go → internal/config/emoji_test.go

@@ -1,4 +1,4 @@
-package grpcapi
+package config
 
 import (
 	"github.com/stretchr/testify/assert"

+ 33 - 11
internal/config/sanitize.go

@@ -5,28 +5,50 @@ import (
 )
 
 func Sanitize(cfg *Config) {
-	sanitizeLogLevel(cfg);
+	sanitizeLogLevel(cfg)
 
-	for _, action := range cfg.ActionButtons {
-		sanitizeAction(action)
+	//log.Infof("cfg %p", cfg)
+
+	for idx, _ := range cfg.Actions {
+		sanitizeAction(&cfg.Actions[idx])
 	}
 }
 
 func sanitizeLogLevel(cfg *Config) {
 	if logLevel, err := log.ParseLevel(cfg.LogLevel); err == nil {
-		log.Info("lvl", logLevel)
+		log.Info("Setting log level to ", logLevel)
 		log.SetLevel(logLevel)
 	}
 }
 
-func sanitizeAction(action ActionButton) {
-	for _, argument := range action.Arguments {
-		sanitizeActionArgument(argument)
+func sanitizeAction(action *Action) {
+	if action.Timeout < 3 {
+		action.Timeout = 3
+	}
+
+	action.Icon = lookupHTMLIcon(action.Icon)
+
+	for idx, _ := range action.Arguments {
+		sanitizeActionArgument(&action.Arguments[idx])
+	}
+}
+
+func sanitizeActionArgument(arg *ActionArgument) {
+	if arg.Title == "" {
+		arg.Title = arg.Name
 	}
+
+	sanitizeActionArgumentNoType(arg)
+
+	// TODO Validate the default against the type checker, but this creates a
+	// import loop
 }
 
-func sanitizeActionArgument(arg ActionArgument) {
-	log.Info("Sanitize AA")
-	arg.Label = "foo"
-	arg.Name = "blat"
+func sanitizeActionArgumentNoType(arg *ActionArgument) {
+	if len(arg.Choices) == 0 && arg.Type == "" {
+		log.WithFields(log.Fields{
+			"arg": arg.Name,
+		}).Warn("Argument type isn't set, will default to 'ascii' but this may not be safe. You should set a type specifically.")
+		arg.Type = "ascii"
+	}
 }

+ 239 - 52
internal/executor/executor.go

@@ -9,99 +9,286 @@ import (
 	"context"
 	"errors"
 	"os/exec"
+	"regexp"
+	"strings"
 	"time"
 )
 
+var (
+	typecheckRegex = map[string]string{
+		"very_dangerous_raw_string": "",
+		"int":                       "^[\\d]+$",
+		"ascii":                     "^[a-zA-Z0-9]+$",
+		"ascii_identifier":          "^[a-zA-Z0-9\\-\\.\\_]+$",
+		"ascii_sentence":            "^[a-zA-Z0-9 \\,\\.]+$",
+	}
+)
+
 type InternalLogEntry struct {
-	Datetime    string
-	Content     string
-	Stdout      string
-	Stderr      string
-	TimedOut    bool
-	ExitCode    int32
+	Datetime string
+	Stdout   string
+	Stderr   string
+	TimedOut bool
+	ExitCode int32
+
+	/*
+		The following two properties are obviously on Action normally, but it's useful
+		that logs are lightweight (so we don't need to have an action associated to
+		logs, etc. Therefore, we duplicate those values here.
+	*/
 	ActionTitle string
+	ActionIcon  string
+}
+
+type ExecutionRequest struct {
+	ActionName         string
+	Arguments          map[string]string
+	action             *config.Action
+	Cfg                *config.Config
+	User               *acl.User
+	logEntry           *InternalLogEntry
+	finalParsedCommand string
+}
+
+type ExecutorStep interface {
+	Exec(*ExecutionRequest) bool
 }
 
 type Executor struct {
 	Logs []InternalLogEntry
+
+	chainOfCommand []ExecutorStep
 }
 
-// ExecAction executes an action.
-func (e *Executor) ExecAction(cfg *config.Config, user *acl.User, actualAction *config.ActionButton) *pb.StartActionResponse {
-	log.WithFields(log.Fields{
-		"actionName": actualAction.Title,
-	}).Infof("StartAction")
+func DefaultExecutor() *Executor {
+	e := Executor{}
+	e.chainOfCommand = []ExecutorStep{
+		StepFindAction{},
+		StepAclCheck{},
+		StepParseArgs{},
+		StepLogStart{},
+		StepExec{},
+		StepLogFinish{},
+	}
+
+	return &e
+}
+
+type StepFindAction struct{}
+
+func (s StepFindAction) Exec(req *ExecutionRequest) bool {
+	actualAction := req.Cfg.FindAction(req.ActionName)
+
+	if actualAction == nil {
+		log.WithFields(log.Fields{
+			"actionName": req.ActionName,
+		}).Warnf("Action not found")
+
+		req.logEntry.Stderr = "Action not found"
+		req.logEntry.ExitCode = -1337
+
+		return false
+	}
+
+	req.action = actualAction
+	req.logEntry.ActionIcon = actualAction.Icon
+
+	return true
+}
+
+type StepAclCheck struct{}
 
-	res := execAction(cfg, actualAction)
+func (s StepAclCheck) Exec(req *ExecutionRequest) bool {
+	return acl.IsAllowedExec(req.Cfg, req.User, req.action)
+}
 
-	e.Logs = append(e.Logs, *res)
+// ExecRequest processes an ExecutionRequest
+func (e *Executor) ExecRequest(req *ExecutionRequest) *pb.StartActionResponse {
+	req.logEntry = &InternalLogEntry{
+		Datetime:    time.Now().Format("2006-01-02 15:04:05"),
+		ActionTitle: req.ActionName,
+	}
+
+	for _, step := range e.chainOfCommand {
+		if !step.Exec(req) {
+			break
+		}
+	}
+
+	e.Logs = append(e.Logs, *req.logEntry)
 
 	return &pb.StartActionResponse{
 		LogEntry: &pb.LogEntry{
-			ActionTitle: actualAction.Title,
-			TimedOut:    res.TimedOut,
-			Stderr:      res.Stderr,
-			Stdout:      res.Stdout,
-			ExitCode:    res.ExitCode,
+			ActionTitle: req.logEntry.ActionTitle,
+			ActionIcon:  req.logEntry.ActionIcon,
+			Datetime:    req.logEntry.Datetime,
+			Stderr:      req.logEntry.Stderr,
+			Stdout:      req.logEntry.Stdout,
+			TimedOut:    req.logEntry.TimedOut,
+			ExitCode:    req.logEntry.ExitCode,
 		},
 	}
 }
 
-func execAction(cfg *config.Config, actualAction *config.ActionButton) *InternalLogEntry {
-	res := &InternalLogEntry{
-		Datetime:    time.Now().Format("2006-01-02 15:04:05"),
-		TimedOut:    false,
-		ActionTitle: actualAction.Title,
-	}
+type StepLogStart struct{}
 
+func (e StepLogStart) Exec(req *ExecutionRequest) bool {
 	log.WithFields(log.Fields{
-		"title":   actualAction.Title,
-		"timeout": actualAction.Timeout,
-	}).Infof("Found action")
+		"title":   req.action.Title,
+		"timeout": req.action.Timeout,
+	}).Infof("Action starting")
+
+	return true
+}
+
+type StepLogFinish struct{}
+
+func (e StepLogFinish) Exec(req *ExecutionRequest) bool {
+	log.WithFields(log.Fields{
+		"title":    req.action.Title,
+		"stdout":   req.logEntry.Stdout,
+		"stderr":   req.logEntry.Stderr,
+		"timedOut": req.logEntry.TimedOut,
+		"exit":     req.logEntry.ExitCode,
+	}).Infof("Action finished")
+
+	return true
+}
+
+type StepParseArgs struct{}
+
+func (e StepParseArgs) Exec(req *ExecutionRequest) bool {
+	var err error
+
+	req.finalParsedCommand, err = parseActionArguments(req.action.Shell, req.Arguments, req.action)
 
-	ctx, cancel := context.WithTimeout(context.Background(), time.Duration(actualAction.Timeout)*time.Second)
+	if err != nil {
+		req.logEntry.ExitCode = -1337
+		req.logEntry.Stderr = ""
+		req.logEntry.Stdout = err.Error()
+
+		log.Warnf(err.Error())
+
+		return false
+	}
+
+	return true
+}
+
+type StepExec struct{}
+
+func (e StepExec) Exec(req *ExecutionRequest) bool {
+	ctx, cancel := context.WithTimeout(context.Background(), time.Duration(req.action.Timeout)*time.Second)
 	defer cancel()
 
-	cmd := exec.CommandContext(ctx, "sh", "-c", actualAction.Shell)
+	cmd := exec.CommandContext(ctx, "sh", "-c", req.finalParsedCommand)
 	stdout, stderr := cmd.Output()
 
-	res.ExitCode = int32(cmd.ProcessState.ExitCode())
-	res.Stdout = string(stdout)
-
-	if stderr == nil {
-		res.Stderr = ""
-	} else {
-		res.Stderr = stderr.Error()
+	if stderr != nil {
+		req.logEntry.Stderr = stderr.Error()
 	}
 
 	if ctx.Err() == context.DeadlineExceeded {
-		res.TimedOut = true
+		req.logEntry.TimedOut = true
 	}
 
+	req.logEntry.ExitCode = int32(cmd.ProcessState.ExitCode())
+	req.logEntry.Stdout = string(stdout)
+
+	return true
+}
+
+func parseActionArguments(rawShellCommand string, values map[string]string, action *config.Action) (string, error) {
 	log.WithFields(log.Fields{
-		"stdout":   res.Stdout,
-		"stderr":   res.Stderr,
-		"timedOut": res.TimedOut,
-		"exit":     res.ExitCode,
-	}).Infof("Finished command.")
+		"cmd": rawShellCommand,
+	}).Infof("Before Parse Args")
+
+	r := regexp.MustCompile("{{ *?([a-z]+?) *?}}")
+	matches := r.FindAllStringSubmatch(rawShellCommand, -1)
+
+	for _, match := range matches {
+		argValue, argProvided := values[match[1]]
+
+		if !argProvided {
+			log.Infof("%v", values)
+			return "", errors.New("Required arg not provided: " + match[1])
+		}
+
+		err := typecheckActionArgument(match[1], argValue, action)
+
+		if err != nil {
+			return "", err
+		}
+
+		log.WithFields(log.Fields{
+			"name":  match[1],
+			"value": argValue,
+		}).Debugf("Arg assigned")
+
+		rawShellCommand = strings.Replace(rawShellCommand, match[0], argValue, -1)
+	}
 
-	return res
+	log.WithFields(log.Fields{
+		"cmd": rawShellCommand,
+	}).Infof("After Parse Args")
+
+	return rawShellCommand, nil
+}
+
+func typecheckActionArgument(name string, value string, action *config.Action) error {
+	arg := findArg(name, action)
+
+	if arg == nil {
+		return errors.New("Action arg not defined: " + name)
+	}
+
+	if len(arg.Choices) > 0 {
+		return typecheckChoice(value, arg)
+	}
+
+	return TypeSafetyCheck(name, value, arg.Type)
 }
 
-func sanitizeAction(action *config.ActionButton) {
-	if action.Timeout < 3 {
-		action.Timeout = 3
+func typecheckChoice(value string, arg *config.ActionArgument) error {
+	for _, choice := range arg.Choices {
+		if value == choice.Value {
+			return nil
+		}
 	}
+
+	return errors.New("Arg value is not one of the predefined choices")
 }
 
-func FindAction(cfg *config.Config, actionTitle string) (*config.ActionButton, error) {
-	for _, action := range cfg.ActionButtons {
-		if action.Title == actionTitle {
-			sanitizeAction(&action)
+func TypeSafetyCheck(name string, value string, typ string) error {
+	pattern, found := typecheckRegex[typ]
+
+	log.Infof("%v %v", pattern, typ)
+
+	if !found {
+		return errors.New("Arg type not implemented " + typ)
+	}
+
+	matches, _ := regexp.MatchString(pattern, value)
+
+	if !matches {
+		log.WithFields(log.Fields{
+			"name":  name,
+			"type":  typ,
+			"value": value,
+		}).Warn("Arg type check safety failure")
+
+		return errors.New("Invalid argument, doesn't match " + typ)
+	}
+
+	return nil
+}
 
-			return &action, nil
+func findArg(name string, action *config.Action) *config.ActionArgument {
+	for _, arg := range action.Arguments {
+		if arg.Name == name {
+			return &arg
 		}
 	}
 
-	return nil, errors.New("Action not found")
+	return nil
 }

+ 34 - 16
internal/grpcapi/grpcApi.go

@@ -14,7 +14,7 @@ import (
 
 var (
 	cfg *config.Config
-	ex  = executor.Executor{}
+	ex  = executor.DefaultExecutor()
 )
 
 type oliveTinAPI struct {
@@ -22,36 +22,34 @@ type oliveTinAPI struct {
 }
 
 func (api *oliveTinAPI) StartAction(ctx ctx.Context, req *pb.StartActionRequest) (*pb.StartActionResponse, error) {
-	actualAction, err := executor.FindAction(cfg, req.ActionName)
+	args := make(map[string]string)
 
-	if err != nil {
-		log.Errorf("Error finding action %s, %s", err, req.ActionName)
+	log.Debugf("SA %v", req)
 
-		return &pb.StartActionResponse{
-			LogEntry: nil,
-		}, nil
+	for _, arg := range req.Arguments {
+		args[arg.Name] = arg.Value
 	}
 
-	user := acl.UserFromContext(ctx)
-
-	if !acl.IsAllowedExec(cfg, user, actualAction) {
-		return &pb.StartActionResponse{}, nil
-
+	execReq := executor.ExecutionRequest{
+		ActionName: req.ActionName,
+		Arguments:  args,
+		User:       acl.UserFromContext(ctx),
+		Cfg:        cfg,
 	}
 
-	return ex.ExecAction(cfg, acl.UserFromContext(ctx), actualAction), nil
+	return ex.ExecRequest(&execReq), nil
 }
 
-func (api *oliveTinAPI) GetButtons(ctx ctx.Context, req *pb.GetButtonsRequest) (*pb.GetButtonsResponse, error) {
+func (api *oliveTinAPI) GetDashboardComponents(ctx ctx.Context, req *pb.GetDashboardComponentsRequest) (*pb.GetDashboardComponentsResponse, error) {
 	user := acl.UserFromContext(ctx)
 
-	res := actionButtonsCfgToPb(cfg.ActionButtons, user)
+	res := actionsCfgToPb(cfg.Actions, user)
 
 	if len(res.Actions) == 0 {
 		log.Warn("Zero actions found - check that you have some actions defined, with a view permission")
 	}
 
-	log.Debugf("getButtons: %v", res)
+	log.Debugf("GetDashboardComponents: %v", res)
 
 	return res, nil
 }
@@ -64,6 +62,7 @@ func (api *oliveTinAPI) GetLogs(ctx ctx.Context, req *pb.GetLogsRequest) (*pb.Ge
 	for _, logEntry := range ex.Logs {
 		ret.Logs = append(ret.Logs, &pb.LogEntry{
 			ActionTitle: logEntry.ActionTitle,
+			ActionIcon:  logEntry.ActionIcon,
 			Datetime:    logEntry.Datetime,
 			Stdout:      logEntry.Stdout,
 			Stderr:      logEntry.Stderr,
@@ -75,6 +74,25 @@ func (api *oliveTinAPI) GetLogs(ctx ctx.Context, req *pb.GetLogsRequest) (*pb.Ge
 	return ret, nil
 }
 
+/*
+This function is ONLY a helper for the UI - the arguments are validated properly
+on the StartAction -> Executor chain. This is here basically to provide helpful
+error messages more quickly before starting the action.
+*/
+func (api *oliveTinAPI) ValidateArgumentType(ctx ctx.Context, req *pb.ValidateArgumentTypeRequest) (*pb.ValidateArgumentTypeResponse, error) {
+	err := executor.TypeSafetyCheck("", req.Value, req.Type)
+	desc := ""
+
+	if err != nil {
+		desc = err.Error()
+	}
+
+	return &pb.ValidateArgumentTypeResponse{
+		Valid:       err == nil,
+		Description: desc,
+	}, nil
+}
+
 // Start will start the GRPC API.
 func Start(globalConfig *config.Config) {
 	cfg = globalConfig

+ 9 - 9
internal/grpcapi/grpcApiButtons.go → internal/grpcapi/grpcApiActions.go

@@ -8,33 +8,33 @@ import (
 	config "github.com/jamesread/OliveTin/internal/config"
 )
 
-func actionButtonsCfgToPb(cfgActionButtons []config.ActionButton, user *acl.User) (*pb.GetButtonsResponse) {
-	res := &pb.GetButtonsResponse{}
+func actionsCfgToPb(cfgActions []config.Action, user *acl.User) *pb.GetDashboardComponentsResponse {
+	res := &pb.GetDashboardComponentsResponse{}
 
-	for _, action := range cfgActionButtons {
+	for _, action := range cfgActions {
 		if !acl.IsAllowedView(cfg, user, &action) {
 			continue
 		}
 
-		btn := buildButton(action, user)
+		btn := actionCfgToPb(action, user)
 		res.Actions = append(res.Actions, btn)
 	}
 
 	return res
 }
 
-func buildButton(action config.ActionButton, user *acl.User) *pb.ActionButton {
-	btn := pb.ActionButton{
+func actionCfgToPb(action config.Action, user *acl.User) *pb.Action {
+	btn := pb.Action{
 		Id:      fmt.Sprintf("%x", md5.Sum([]byte(action.Title))),
 		Title:   action.Title,
-		Icon:    lookupHTMLIcon(action.Icon),
+		Icon:    action.Icon,
 		CanExec: acl.IsAllowedExec(cfg, user, &action),
 	}
 
 	for _, cfgArg := range action.Arguments {
 		pbArg := pb.ActionArgument{
 			Name:         cfgArg.Name,
-			Label:        cfgArg.Label,
+			Title:        cfgArg.Title,
 			Type:         cfgArg.Type,
 			DefaultValue: cfgArg.Default,
 			Choices:      buildChoices(cfgArg.Choices),
@@ -52,7 +52,7 @@ func buildChoices(choices []config.ActionArgumentChoice) []*pb.ActionArgumentCho
 	for _, cfgChoice := range choices {
 		pbChoice := pb.ActionArgumentChoice{
 			Value: cfgChoice.Value,
-			Label: cfgChoice.Label,
+			Title: cfgChoice.Title,
 		}
 
 		ret = append(ret, &pbChoice)

+ 5 - 5
internal/grpcapi/grpcApi_test.go

@@ -52,19 +52,19 @@ func getNewTestServerAndClient(t *testing.T, injectedConfig *config.Config) (*gr
 	return conn, client
 }
 
-func TestGetButtonsAndStart(t *testing.T) {
+func TestGetActionsAndStart(t *testing.T) {
 	cfg = config.DefaultConfig()
-	btn1 := config.ActionButton{}
+	btn1 := config.Action{}
 	btn1.Title = "blat"
 	btn1.Shell = "echo 'test'"
-	cfg.ActionButtons = append(cfg.ActionButtons, btn1)
+	cfg.Actions = append(cfg.Actions, btn1)
 
 	conn, client := getNewTestServerAndClient(t, cfg)
 
-	respGb, err := client.GetButtons(context.Background(), &pb.GetButtonsRequest{})
+	respGb, err := client.GetDashboardComponents(context.Background(), &pb.GetDashboardComponentsRequest{})
 
 	if err != nil {
-		t.Errorf("GetButtons: %v", err)
+		t.Errorf("GetDashboardComponentsRequest: %v", err)
 	}
 
 	assert.Equal(t, true, true, "sayHello Failed")

+ 13 - 6
internal/httpservers/webuiServer.go

@@ -8,12 +8,16 @@ import (
 	"os"
 
 	config "github.com/jamesread/OliveTin/internal/config"
+	updatecheck "github.com/jamesread/OliveTin/internal/updatecheck"
 )
 
 type webUISettings struct {
-	Rest      string
-	ThemeName string
-	HideNavigation bool
+	Rest             string
+	ThemeName        string
+	HideNavigation   bool
+	AvailableVersion string
+	CurrentVersion   string
+	ShowNewVersions  bool
 }
 
 func findWebuiDir() string {
@@ -43,9 +47,12 @@ func generateWebUISettings(w http.ResponseWriter, r *http.Request) {
 	}
 
 	jsonRet, _ := json.Marshal(webUISettings{
-		Rest:      restAddress + "/api/",
-		ThemeName: cfg.ThemeName,
-		HideNavigation: cfg.HideNavigation,
+		Rest:             restAddress + "/api/",
+		ThemeName:        cfg.ThemeName,
+		HideNavigation:   cfg.HideNavigation,
+		AvailableVersion: updatecheck.AvailableVersion,
+		CurrentVersion:   updatecheck.CurrentVersion,
+		ShowNewVersions:  cfg.ShowNewVersions,
 	})
 
 	w.Write([]byte(jsonRet))

+ 7 - 2
internal/updatecheck/updateCheck.go

@@ -21,6 +21,9 @@ type updateRequest struct {
 	MachineID      string
 }
 
+var AvailableVersion = "none"
+var CurrentVersion = "?"
+
 func machineID() string {
 	v, err := machineid.ProtectedID("OliveTin")
 
@@ -40,6 +43,8 @@ func StartUpdateChecker(currentVersion string, currentCommit string, cfg *config
 		return
 	}
 
+	CurrentVersion = currentVersion
+
 	payload := updateRequest{
 		CurrentVersion: currentVersion,
 		CurrentCommit:  currentCommit,
@@ -89,9 +94,9 @@ func actualCheckForUpdate(payload updateRequest) {
 		return
 	}
 
-	newVersion := doRequest(jsonUpdateRequest)
+	AvailableVersion = doRequest(jsonUpdateRequest)
 
 	log.WithFields(log.Fields{
-		"NewVersion": newVersion,
+		"NewVersion": AvailableVersion,
 	}).Infof("Update check complete")
 }

+ 33 - 4
webui/index.html

@@ -23,6 +23,7 @@
 						<tr>
 							<th>Timestamp</th>
 							<th>Log</th>
+							<th>Exit Code</th>
 						</tr>
 					</thead>
 					<tbody id = "logTableBody" />
@@ -38,28 +39,56 @@
 			<p><img title = "application icon" src = "OliveTinLogo.png" height = "1em" class = "logo" /> OliveTin</p>
 			<p>	
 				<a href = "https://docs.olivetin.app" target = "_new">Documentation</a> | 
-				<a href = "https://github.com/OliveTin/OliveTin/issues/new/choose" target = "_new">Raise an issue on GitHub</a>
+				<a href = "https://github.com/OliveTin/OliveTin/issues/new/choose" target = "_new">Raise an issue on GitHub</a> | 
+				<span id = "currentVersion">Version: ?</p>  
+				<a id = "availableVersion" href = "http://olivetin.app" target = "_blank" hidden>?</a>
 			</p>
 		</footer>
 
+		<template id = "tplArgumentForm">
+			<div class = "wrapper">
+				<div>
+					<span class = "icon" role = "icon"></span>
+					<h2>Argument form</h2>
+				</div>
+
+				<div class = "arguments"></div>
+
+				<div class = "buttons">
+					<input name = "start" type = "submit" value = "Start">
+					<button name = "cancel">Cancel</button>
+				</div>
+			</div>
+		</template>
+
 		<template id = "tplActionButton">
-			<span role = "img" title = "button icon" class = "icon">&#x1f4a9;</span>
+			<span role = "icon" title = "button icon" class = "icon">&#x1f4a9;</span>
 			<p role = "title" class = "title">Untitled Button</p>
 		</template>
 
 		<template id = "tplLogRow">
-			<tr>
+			<tr class = "logRow">
 				<td class = "timestamp">?</td> 
 				<td>
+					<span class = "icon" role = "icon"></span>
 					<span class = "content">?</span>
 				
 					<details>
 						<summary>stdout</summary>
-						<pre>
+						<pre class = "stdout">
+							?
+						</pre>
+					</details>
+
+					<details>
+						<summary>stderr</summary>
+						<pre class = "stderr">
 							?
 						</pre>
 					</details>
+
 				</td>
+				<td class = "exitCode">?</td>
 			</tr>
 		</template>
 

+ 34 - 11
webui/js/ActionButton.js

@@ -1,5 +1,5 @@
-import { marshalLogsJsonToHtml } from './marshaller.js';
-import "./ArgumentForm.js"
+import { marshalLogsJsonToHtml } from './marshaller.js'
+import './ArgumentForm.js'
 
 class ActionButton extends window.HTMLButtonElement {
   constructFromJson (json) {
@@ -8,20 +8,20 @@ class ActionButton extends window.HTMLButtonElement {
     this.title = json.title
     this.temporaryStatusMessage = null
     this.isWaiting = false
-    this.actionCallUrl = window.restBaseUrl + 'StartAction?actionName=' + this.title
+    this.actionCallUrl = window.restBaseUrl + 'StartAction'
 
     this.updateFromJson(json)
 
-    this.onclick = () => { 
+    this.onclick = () => {
       if (json.arguments.length > 0) {
-        let frm = document.createElement('form', { is: 'argument-form' })
-        window.frm = frm
-        console.log(frm)
-        frm.setup(json, this.startAction)
+        const frm = document.createElement('form', { is: 'argument-form' })
+        frm.setup(json, (args) => {
+          this.startAction(args)
+        })
 
         document.body.appendChild(frm)
       } else {
-        this.startAction() 
+        this.startAction()
       }
     }
 
@@ -46,18 +46,41 @@ class ActionButton extends window.HTMLButtonElement {
     }
   }
 
-  startAction () {
+  startAction (actionArgs) {
     this.disabled = true
     this.isWaiting = true
     this.updateHtml()
     this.classList = [] // Removes old animation classes
 
-    window.fetch(this.actionCallUrl).then(res => res.json()
+    if (actionArgs === undefined) {
+      actionArgs = []
+    }
+
+    const startActionArgs = {
+      actionName: this.title,
+      arguments: actionArgs
+    }
+
+    window.fetch(this.actionCallUrl, {
+      method: 'POST',
+      headers: {
+        'Content-Type': 'application/json'
+      },
+      body: JSON.stringify(startActionArgs)
+    }).then((res) => {
+      if (res.ok) {
+        return res.json()
+      } else {
+        throw new Error(res.statusText)
+      }
+    }
     ).then((json) => {
       marshalLogsJsonToHtml({ logs: [json.logEntry] })
 
       if (json.logEntry.timedOut) {
         this.onActionResult('actionTimeout', 'Timed out')
+      } else if (json.logEntry.exitCode === -1337) {
+        this.onActionError('Error')
       } else if (json.logEntry.exitCode !== 0) {
         this.onActionResult('actionNonZeroExit', 'Exit code ' + json.logEntry.exitCode)
       } else {

+ 95 - 40
webui/js/ArgumentForm.js

@@ -1,85 +1,140 @@
 
 class ArgumentForm extends window.HTMLFormElement {
-  setup(json, callback) {
+  setup (json, callback) {
     this.setAttribute('class', 'actionArguments')
 
-    console.log(json)
+    this.constructTemplate()
+    this.domTitle.innerText = json.title
+    this.domIcon.innerHTML = json.icon
+    this.createDomFormArguments(json.arguments)
 
-    this.domWrapper = document.createElement('div')
-    this.domWrapper.classList += 'wrapper'
-    this.appendChild(this.domWrapper)
+    this.domBtnStart.onclick = () => {
+      for (const arg of this.argInputs) {
+        if (!arg.validity.valid) {
+          return
+        }
+      }
 
-    this.domTitle = document.createElement('h2')
-    this.domTitle.innerText = json.title + ": Arguments"
-    this.domWrapper.appendChild(this.domTitle);
+      const argvs = this.getArgumentValues()
 
-    this.domIcon = document.createElement('span');
-    this.domIcon.classList += 'icon'
-    this.domIcon.setAttribute('role', 'img')
-    this.domIcon.innerHTML = json.icon
-    this.domTitle.prepend(this.domIcon)
+      callback(argvs)
+
+      this.remove()
+    }
 
-    let a = document.createElement("span")
-    a.innerText = "This is test version of the form."
-    this.domWrapper.appendChild(a)
+    this.domBtnCancel.onclick = () => {
+      this.remove()
+    }
+  }
 
-    this.createDomFormArguments(json.arguments)
-    this.domWrapper.appendChild(this.createDomSubmit())
+  getArgumentValues () {
+    const ret = []
 
-    console.log(json)
+    for (const arg of this.argInputs) {
+      ret.push({
+        name: arg.name,
+        value: arg.value
+      })
+    }
+
+    return ret
   }
 
-  createDomSubmit() {
-    let el = document.createElement('button')
-    el.setAttribute('action', 'submit')
-    el.innerText = "Run"
+  constructTemplate () {
+    const tpl = document.getElementById('tplArgumentForm')
+    const content = tpl.content.cloneNode(true)
+
+    this.appendChild(content)
+
+    this.domTitle = this.querySelector('h2')
+    this.domIcon = this.querySelector('span.icon')
+    this.domWrapper = this.querySelector('.wrapper')
 
-    return el
+    this.domArgs = this.querySelector('.arguments')
+
+    this.domBtnStart = this.querySelector('[name=start]')
+    this.domBtnCancel = this.querySelector('[name=cancel]')
   }
 
-  createDomFormArguments(args) {
-    for (let arg of args) {
-      let domFieldWrapper = document.createElement('p');
+  createDomFormArguments (args) {
+    this.argInputs = []
+
+    for (const arg of args) {
+      const domFieldWrapper = document.createElement('p')
 
       domFieldWrapper.appendChild(this.createDomLabel(arg))
       domFieldWrapper.appendChild(this.createDomInput(arg))
 
-      this.domWrapper.appendChild(domFieldWrapper)
+      this.domArgs.appendChild(domFieldWrapper)
     }
   }
 
-  createDomLabel(arg) {
-    let domLbl = document.createElement('label')
-    domLbl.innerText = arg.label + ':';
+  createDomLabel (arg) {
+    const domLbl = document.createElement('label')
+    domLbl.innerText = arg.title + ':'
     domLbl.setAttribute('for', arg.name)
 
-    return domLbl;
+    return domLbl
   }
 
-  createDomInput(arg) {
-    let domEl = null;
+  createDomInput (arg) {
+    let domEl = null
 
     if (arg.choices.length > 0) {
       domEl = document.createElement('select')
 
-      for (let choice of arg.choices) {
+      // select/choice elements don't get an onchange/validation because theoretically
+      // the user should only select from a dropdown of valid options. The choices are
+      // riggeriously checked on StartAction anyway. ValidateArgumentType is only
+      // meant for showing simple warnings in the UI before running.
+
+      for (const choice of arg.choices) {
         domEl.appendChild(this.createSelectOption(choice))
       }
     } else {
       domEl = document.createElement('input')
+      domEl.onchange = () => {
+        const validateArgumentTypeArgs = {
+          value: domEl.value,
+          type: arg.type
+        }
+
+        window.fetch(window.restBaseUrl + 'ValidateArgumentType', {
+          method: 'POST',
+          headers: {
+            'Content-Type': 'application/json'
+          },
+          body: JSON.stringify(validateArgumentTypeArgs)
+        }).then((res) => {
+          if (res.ok) {
+            return res.json()
+          } else {
+            throw new Error(res.statusText)
+          }
+        }).then((json) => {
+          console.log(json.valid)
+          if (json.valid) {
+            domEl.setCustomValidity('')
+          } else {
+            domEl.setCustomValidity(json.description)
+          }
+        })
+      }
     }
 
-    domEl.setAttribute('id', arg.name)
+    domEl.name = arg.name
     domEl.value = arg.defaultValue
 
-    return domEl;
+    this.argInputs.push(domEl)
+
+    return domEl
   }
 
-  createSelectOption(choice) {
-    let domEl = document.createElement('option')
+  createSelectOption (choice) {
+    const domEl = document.createElement('option')
 
     domEl.setAttribute('value', choice.value)
-    domEl.innerText = choice.label
+    domEl.innerText = choice.title
 
     return domEl
   }

+ 22 - 1
webui/js/marshaller.js

@@ -31,9 +31,30 @@ export function marshalLogsJsonToHtml (json) {
     const tpl = document.getElementById('tplLogRow')
     const row = tpl.content.cloneNode(true)
 
+    if (logEntry.stdout.length === 0) {
+      logEntry.stdout = '(empty)'
+    }
+
+    if (logEntry.stderr.length === 0) {
+      logEntry.stderr = '(empty)'
+    }
+
+    let logTableExitCode = logEntry.exitCode
+
+    if (logEntry.exitCode === 0) {
+      logTableExitCode = 'OK'
+    }
+
+    if (logEntry.timedOut) {
+      logTableExitCode += ' (timed out)'
+    }
+
     row.querySelector('.timestamp').innerText = logEntry.datetime
     row.querySelector('.content').innerText = logEntry.actionTitle
-    row.querySelector('pre').innerText = logEntry.stdout
+    row.querySelector('.icon').innerHTML = logEntry.actionIcon
+    row.querySelector('pre.stdout').innerText = logEntry.stdout
+    row.querySelector('pre.stderr').innerText = logEntry.stderr
+    row.querySelector('.exitCode').innerText = logTableExitCode
 
     document.querySelector('#logTableBody').prepend(row)
   }

+ 11 - 4
webui/main.js

@@ -31,8 +31,8 @@ function setupSections () {
   showSection('Actions')
 }
 
-function fetchGetButtons () {
-  window.fetch(window.restBaseUrl + 'GetButtons', {
+function fetchGetDashboardComponents () {
+  window.fetch(window.restBaseUrl + 'GetDashboardComponents', {
     cors: 'cors'
   }).then(res => {
     return res.json()
@@ -67,6 +67,13 @@ function processWebuiSettingsJson (settings) {
     document.head.appendChild(themeCss)
   }
 
+  document.querySelector('#currentVersion').innerText = 'Version: ' + settings.CurrentVersion
+
+  if (settings.ShowNewVersions && settings.AvailableVersion !== 'none') {
+    document.querySelector('#availableVersion').innerText = 'New Version Available: ' + settings.AvailableVersion
+    document.querySelector('#availableVersion').hidden = false
+  }
+
   document.querySelector('#switcher').hidden = settings.HideNavigation
 }
 
@@ -77,10 +84,10 @@ window.fetch('webUiSettings.json').then(res => {
 }).then(res => {
   processWebuiSettingsJson(res)
 
-  fetchGetButtons()
+  fetchGetDashboardComponents()
   fetchGetLogs()
 
-  window.buttonInterval = setInterval(fetchGetButtons, 3000)
+  window.buttonInterval = setInterval(fetchGetDashboardComponents, 3000)
 }).catch(err => {
   showBigError('fetch-webui-settings', 'getting webui settings', err)
 })

+ 120 - 102
webui/style.css

@@ -52,6 +52,14 @@ legend {
 
 span[role="icon"] {
   display: block;
+  font-size: 3em;
+  vertical-align: middle;
+}
+
+form span[role="icon"],
+tr.logRow span[role="icon"] {
+  display: inline-block;
+  padding-right: 0.2em;
 }
 
 .error {
@@ -73,11 +81,17 @@ div.entity {
   grid-template-columns: minmax(min-content, auto);
 }
 
+h2 {
+  font-size: 1em;
+  display: inline-block;
+}
+
 div.entity h2 {
   grid-column: 1 / span all;
 }
 
-button {
+button,
+input[type="submit"] {
   padding: 1em;
   color: black;
   display: table-cell;
@@ -88,16 +102,19 @@ button {
   user-select: none;
 }
 
-button:hover {
+button:hover,
+input[type="submit"]:hover {
   box-shadow: 0 0 10px 0 #666;
   cursor: pointer;
 }
 
-button:focus {
+button:focus,
+input[type="submit"]:focus {
   outline: 1px solid black;
 }
 
-button:disabled {
+button:disabled,
+input[type="submit"]:disabled {
   color: gray;
   background-color: #333;
   cursor: not-allowed;
@@ -117,10 +134,6 @@ fieldset#switcher button:last-child {
   border-radius: 0 1em 1em 0;
 }
 
-span.icon {
-  font-size: 3em;
-}
-
 .actionFailed {
   animation: kfActionFailed 1s;
 }
@@ -180,45 +193,6 @@ img.logo {
   }
 }
 
-@media (prefers-color-scheme: dark) {
-  body {
-    background-color: #333;
-    color: white;
-  }
-
-  button {
-    border: 1px solid #666;
-    background-color: #222;
-    box-shadow: 0 0 6px 0 #444;
-    color: white;
-  }
-
-  button:disabled {
-    background-color: black;
-  }
-
-  table,
-  td,
-  th {
-    border: 1px solid gray;
-  }
-
-  td,
-  tr {
-    background-color: #222;
-    color: white;
-  }
-
-  tr:hover td {
-    background-color: #666;
-  }
-
-  footer,
-  footer a {
-    color: gray;
-  }
-}
-
 main {
   padding: 1em;
 }
@@ -237,81 +211,125 @@ details[open] {
 }
 
 form.actionArguments {
-	position: absolute;
-	top: 0;
-	bottom: 0;
-	left: 0;
-	right: 0;
-	padding: 1em;
-    box-shadow: 0 0 6px 0 #aaa;
-	background-color: #dee3e7;
+  position: absolute;
+  top: 0;
+  bottom: 0;
+  left: 0;
+  right: 0;
+  padding: 1em;
+  box-shadow: 0 0 6px 0 #aaa;
+  background-color: #dee3e7;
 }
 
-h2 {
-	font-size: 1em;
+form div.wrapper {
+  border-radius: 1em;
+  box-shadow: 0 0 10px 0 #444;
+  background-color: white;
+  border: 1px solid #999;
+  text-align: left;
+  padding: 1em;
 }
 
-h2 span.icon {
-	vertical-align: middle;
-	padding-right: .2em;
+label {
+  width: 20%;
+  text-align: right;
+  display: inline-block;
+  padding-right: 1em;
 }
 
-form div.wrapper {
-	border-radius: 1em;
-    box-shadow: 0 0 10px 0 #444;
-    background-color: white;
-    border: 1px solid #999;
-	text-align: left;
-	padding: 1em;
+input {
+  padding: 0.6em;
 }
 
-label {
-	width: 30%;
-	text-align: right;
-	display: inline-block;
-	padding-right: 1em;
+input:invalid {
+  outline: 2px solid red;
 }
 
-input {
-	padding: .6em;
+form .wrapper span.icon {
+  display: inline-block;
+  vertical-align: middle;
 }
 
-form.actionArguments {
-	position: absolute;
-	top: 0;
-	bottom: 0;
-	left: 0;
-	right: 0;
-	padding: 1em;
-    box-shadow: 0 0 6px 0 #aaa;
-	background-color: #dee3e7;
+form input[type="submit"]:first-child {
+  margin-right: 1em;
 }
 
-h2 {
-	font-size: 1em;
+button[name=cancel]:hover {
+  background-color: salmon;
 }
 
-h2 span.icon {
-	vertical-align: middle;
-	padding-right: .2em;
+input[name=start]:hover {
+  background-color: #aceaac;
 }
 
-form div.wrapper {
-	border-radius: 1em;
-    box-shadow: 0 0 10px 0 #444;
-    background-color: white;
-    border: 1px solid #999;
-	text-align: left;
-	padding: 1em;
+form div.buttons {
+  text-align: right;
 }
 
-label {
-	width: 30%;
-	text-align: right;
-	display: inline-block;
-	padding-right: 1em;
+pre {
+  border: 1px solid gray;
+  padding: 1em;
+  min-height: 1em;
 }
 
-input {
-	padding: .6em;
+td.exitCode {
+  text-align: center;
+}
+
+input.invalid {
+  background-color: salmon;
+}
+
+#availableVersion {
+  background-color: #aceaac;
+  padding: 0.2em;
+  border-radius: 1em;
+}
+
+@media (prefers-color-scheme: dark) {
+  body {
+    background-color: #333;
+    color: white;
+  }
+
+  form.actionArguments {
+    background-color: #333;
+  }
+
+  form div.wrapper {
+    background-color: #222;
+  }
+
+  button,
+  input[type="submit"] {
+    border: 1px solid #666;
+    background-color: #222;
+    box-shadow: 0 0 6px 0 #444;
+    color: white;
+  }
+
+  button:disabled {
+    background-color: black;
+  }
+
+  table,
+  td,
+  th {
+    border: 1px solid gray;
+  }
+
+  td,
+  tr {
+    background-color: #222;
+    color: white;
+  }
+
+  tr:hover td {
+    background-color: #666;
+  }
+
+  footer,
+  footer a {
+    color: gray;
+  }
 }