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: ?
+ ?
+
+
+
+
+
Argument form
+
+
+
+
+
+
+
+
+
+
+
- 💩
+ 💩
Untitled Button
-
+
| ? |
+
?
stdout
-
+
?
+
+
+ stderr
+
+ ?
+
+
+
|
+ ? |
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;
-}