From 30d681690aff923f62be2dc43e8efd3bde6606be Mon Sep 17 00:00:00 2001 From: jamesread Date: Tue, 2 Nov 2021 01:25:07 +0000 Subject: [PATCH] #6 - task arguments, that was a lot of work! --- Makefile | 2 +- OliveTin.proto | 51 ++- cmd/OliveTin/main.go | 7 +- internal/acl/acl.go | 4 +- internal/config/config.go | 21 +- internal/config/config_helpers.go | 11 + internal/{grpcapi => config}/emoji.go | 2 +- internal/{grpcapi => config}/emoji_test.go | 2 +- internal/config/sanitize.go | 44 ++- internal/executor/executor.go | 293 ++++++++++++++---- internal/grpcapi/grpcApi.go | 50 ++- .../{grpcApiButtons.go => grpcApiActions.go} | 18 +- internal/grpcapi/grpcApi_test.go | 10 +- internal/httpservers/webuiServer.go | 19 +- internal/updatecheck/updateCheck.go | 9 +- webui/index.html | 37 ++- webui/js/ActionButton.js | 45 ++- webui/js/ArgumentForm.js | 141 ++++++--- webui/js/marshaller.js | 23 +- webui/main.js | 15 +- webui/style.css | 230 +++++++------- 21 files changed, 736 insertions(+), 298 deletions(-) create mode 100644 internal/config/config_helpers.go rename internal/{grpcapi => config}/emoji.go (93%) rename internal/{grpcapi => config}/emoji_test.go (94%) rename internal/grpcapi/{grpcApiButtons.go => grpcApiActions.go} (70%) diff --git a/Makefile b/Makefile index 469c98f..f1437d1 100644 --- a/Makefile +++ b/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 diff --git a/OliveTin.proto b/OliveTin.proto index 91dab54..4eea920 100644 --- a/OliveTin.proto +++ b/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: "*" + }; + } } diff --git a/cmd/OliveTin/main.go b/cmd/OliveTin/main.go index 8fa80a7..4086dff 100644 --- a/cmd/OliveTin/main.go +++ b/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() { diff --git a/internal/acl/acl.go b/internal/acl/acl.go index f01e909..fade75e 100644 --- a/internal/acl/acl.go +++ b/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{ diff --git a/internal/config/config.go b/internal/config/config.go index 8898be1..6965544 100644 --- a/internal/config/config.go +++ b/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 diff --git a/internal/config/config_helpers.go b/internal/config/config_helpers.go new file mode 100644 index 0000000..581ce65 --- /dev/null +++ b/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 +} diff --git a/internal/grpcapi/emoji.go b/internal/config/emoji.go similarity index 93% rename from internal/grpcapi/emoji.go rename to internal/config/emoji.go index c31b29f..ba33c4a 100644 --- a/internal/grpcapi/emoji.go +++ b/internal/config/emoji.go @@ -1,4 +1,4 @@ -package grpcapi +package config var emojis = map[string]string{ "poop": "💩", diff --git a/internal/grpcapi/emoji_test.go b/internal/config/emoji_test.go similarity index 94% rename from internal/grpcapi/emoji_test.go rename to internal/config/emoji_test.go index 2b5dafc..5737c1c 100644 --- a/internal/grpcapi/emoji_test.go +++ b/internal/config/emoji_test.go @@ -1,4 +1,4 @@ -package grpcapi +package config import ( "github.com/stretchr/testify/assert" diff --git a/internal/config/sanitize.go b/internal/config/sanitize.go index cb6c6db..adfa368 100644 --- a/internal/config/sanitize.go +++ b/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) { - log.Info("Sanitize AA") - arg.Label = "foo" - arg.Name = "blat" +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 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" + } } diff --git a/internal/executor/executor.go b/internal/executor/executor.go index e8b0b2c..25954ee 100644 --- a/internal/executor/executor.go +++ b/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{}, + } - res := execAction(cfg, actualAction) + return &e +} - e.Logs = append(e.Logs, *res) +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{} + +func (s StepAclCheck) Exec(req *ExecutionRequest) bool { + return acl.IsAllowedExec(req.Cfg, req.User, req.action) +} + +// 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": 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) + + if err != nil { + req.logEntry.ExitCode = -1337 + req.logEntry.Stderr = "" + req.logEntry.Stdout = err.Error() + + log.Warnf(err.Error()) + + return false } - log.WithFields(log.Fields{ - "title": actualAction.Title, - "timeout": actualAction.Timeout, - }).Infof("Found action") + return true +} - ctx, cancel := context.WithTimeout(context.Background(), time.Duration(actualAction.Timeout)*time.Second) +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{ + "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) } log.WithFields(log.Fields{ - "stdout": res.Stdout, - "stderr": res.Stderr, - "timedOut": res.TimedOut, - "exit": res.ExitCode, - }).Infof("Finished command.") + "cmd": rawShellCommand, + }).Infof("After Parse Args") - return res + return rawShellCommand, nil } -func sanitizeAction(action *config.ActionButton) { - if action.Timeout < 3 { - action.Timeout = 3 +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 FindAction(cfg *config.Config, actionTitle string) (*config.ActionButton, error) { - for _, action := range cfg.ActionButtons { - if action.Title == actionTitle { - sanitizeAction(&action) - - return &action, nil +func typecheckChoice(value string, arg *config.ActionArgument) error { + for _, choice := range arg.Choices { + if value == choice.Value { + return nil } } - return nil, errors.New("Action not found") + return errors.New("Arg value is not one of the predefined choices") +} + +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 +} + +func findArg(name string, action *config.Action) *config.ActionArgument { + for _, arg := range action.Arguments { + if arg.Name == name { + return &arg + } + } + + return nil } diff --git a/internal/grpcapi/grpcApi.go b/internal/grpcapi/grpcApi.go index ff78e24..c037dcb 100644 --- a/internal/grpcapi/grpcApi.go +++ b/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 diff --git a/internal/grpcapi/grpcApiButtons.go b/internal/grpcapi/grpcApiActions.go similarity index 70% rename from internal/grpcapi/grpcApiButtons.go rename to internal/grpcapi/grpcApiActions.go index 0a3aba3..d534182 100644 --- a/internal/grpcapi/grpcApiButtons.go +++ b/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) diff --git a/internal/grpcapi/grpcApi_test.go b/internal/grpcapi/grpcApi_test.go index fcf0c43..2024ae3 100644 --- a/internal/grpcapi/grpcApi_test.go +++ b/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") diff --git a/internal/httpservers/webuiServer.go b/internal/httpservers/webuiServer.go index 4824660..f6a2985 100644 --- a/internal/httpservers/webuiServer.go +++ b/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)) diff --git a/internal/updatecheck/updateCheck.go b/internal/updatecheck/updateCheck.go index f4915ed..ef3c443 100644 --- a/internal/updatecheck/updateCheck.go +++ b/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") } diff --git a/webui/index.html b/webui/index.html index fab9ab6..20aee26 100644 --- a/webui/index.html +++ b/webui/index.html @@ -23,6 +23,7 @@ Timestamp Log + Exit Code @@ -38,28 +39,56 @@

OliveTin

Documentation | - Raise an issue on GitHub + Raise an issue on GitHub | + Version: ?

+

+ + diff --git a/webui/js/ActionButton.js b/webui/js/ActionButton.js index b141ec1..ef1802e 100644 --- a/webui/js/ActionButton.js +++ b/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 { diff --git a/webui/js/ArgumentForm.js b/webui/js/ArgumentForm.js index bf0f706..84b3468 100644 --- a/webui/js/ArgumentForm.js +++ b/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.domWrapper = document.createElement('div') - this.domWrapper.classList += 'wrapper' - this.appendChild(this.domWrapper) - - this.domTitle = document.createElement('h2') - this.domTitle.innerText = json.title + ": Arguments" - this.domWrapper.appendChild(this.domTitle); - - this.domIcon = document.createElement('span'); - this.domIcon.classList += 'icon' - this.domIcon.setAttribute('role', 'img') + this.constructTemplate() + this.domTitle.innerText = json.title this.domIcon.innerHTML = json.icon - this.domTitle.prepend(this.domIcon) - - let a = document.createElement("span") - a.innerText = "This is test version of the form." - this.domWrapper.appendChild(a) - this.createDomFormArguments(json.arguments) - this.domWrapper.appendChild(this.createDomSubmit()) - console.log(json) + this.domBtnStart.onclick = () => { + for (const arg of this.argInputs) { + if (!arg.validity.valid) { + return + } + } + + const argvs = this.getArgumentValues() + + callback(argvs) + + this.remove() + } + + this.domBtnCancel.onclick = () => { + this.remove() + } } - createDomSubmit() { - let el = document.createElement('button') - el.setAttribute('action', 'submit') - el.innerText = "Run" + getArgumentValues () { + const ret = [] - return el + for (const arg of this.argInputs) { + ret.push({ + name: arg.name, + value: arg.value + }) + } + + return ret } - createDomFormArguments(args) { - for (let arg of args) { - let domFieldWrapper = document.createElement('p'); + 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') + + this.domArgs = this.querySelector('.arguments') + + this.domBtnStart = this.querySelector('[name=start]') + this.domBtnCancel = this.querySelector('[name=cancel]') + } + + 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 } diff --git a/webui/js/marshaller.js b/webui/js/marshaller.js index 87eccbf..7f58fd9 100644 --- a/webui/js/marshaller.js +++ b/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) } diff --git a/webui/main.js b/webui/main.js index 7b39b16..982485d 100644 --- a/webui/main.js +++ b/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) }) diff --git a/webui/style.css b/webui/style.css index cc0e855..0c40f33 100644 --- a/webui/style.css +++ b/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,13 +193,115 @@ img.logo { } } +main { + padding: 1em; +} + +summary { + cursor: pointer; +} + +details { + display: inline-block; +} + +details[open] { + margin-top: 1em; + display: block; +} + +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 div.wrapper { + border-radius: 1em; + box-shadow: 0 0 10px 0 #444; + background-color: white; + border: 1px solid #999; + text-align: left; + padding: 1em; +} + +label { + width: 20%; + text-align: right; + display: inline-block; + padding-right: 1em; +} + +input { + padding: 0.6em; +} + +input:invalid { + outline: 2px solid red; +} + +form .wrapper span.icon { + display: inline-block; + vertical-align: middle; +} + +form input[type="submit"]:first-child { + margin-right: 1em; +} + +button[name=cancel]:hover { + background-color: salmon; +} + +input[name=start]:hover { + background-color: #aceaac; +} + +form div.buttons { + text-align: right; +} + +pre { + border: 1px solid gray; + padding: 1em; + min-height: 1em; +} + +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; } - button { + 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; @@ -218,100 +333,3 @@ img.logo { color: gray; } } - -main { - padding: 1em; -} - -summary { - cursor: pointer; -} - -details { - display: inline-block; -} - -details[open] { - margin-top: 1em; - display: block; -} - -form.actionArguments { - 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; -} - -h2 span.icon { - vertical-align: middle; - padding-right: .2em; -} - -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; -} - -label { - width: 30%; - text-align: right; - display: inline-block; - padding-right: 1em; -} - -input { - padding: .6em; -} - -form.actionArguments { - 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; -} - -h2 span.icon { - vertical-align: middle; - padding-right: .2em; -} - -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; -} - -label { - width: 30%; - text-align: right; - display: inline-block; - padding-right: 1em; -} - -input { - padding: .6em; -}