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

This commit is contained in:
jamesread
2021-11-02 01:25:07 +00:00
parent 614c3b73fc
commit 30d681690a
21 changed files with 736 additions and 298 deletions

View File

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

View File

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

View File

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

View File

@@ -10,7 +10,7 @@ type User struct {
Username string 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 canExec := cfg.DefaultPermissions.Exec
log.WithFields(log.Fields{ log.WithFields(log.Fields{
@@ -40,7 +40,7 @@ func IsAllowedExec(cfg *config.Config, user *User, action *config.ActionButton)
return canExec 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 canView := cfg.DefaultPermissions.View
log.WithFields(log.Fields{ log.WithFields(log.Fields{

View File

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

View File

@@ -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
}

View File

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

View File

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

View File

@@ -5,28 +5,50 @@ import (
) )
func Sanitize(cfg *Config) { func Sanitize(cfg *Config) {
sanitizeLogLevel(cfg); sanitizeLogLevel(cfg)
for _, action := range cfg.ActionButtons { //log.Infof("cfg %p", cfg)
sanitizeAction(action)
for idx, _ := range cfg.Actions {
sanitizeAction(&cfg.Actions[idx])
} }
} }
func sanitizeLogLevel(cfg *Config) { func sanitizeLogLevel(cfg *Config) {
if logLevel, err := log.ParseLevel(cfg.LogLevel); err == nil { if logLevel, err := log.ParseLevel(cfg.LogLevel); err == nil {
log.Info("lvl", logLevel) log.Info("Setting log level to ", logLevel)
log.SetLevel(logLevel) log.SetLevel(logLevel)
} }
} }
func sanitizeAction(action ActionButton) { func sanitizeAction(action *Action) {
for _, argument := range action.Arguments { if action.Timeout < 3 {
sanitizeActionArgument(argument) action.Timeout = 3
}
action.Icon = lookupHTMLIcon(action.Icon)
for idx, _ := range action.Arguments {
sanitizeActionArgument(&action.Arguments[idx])
} }
} }
func sanitizeActionArgument(arg ActionArgument) { func sanitizeActionArgument(arg *ActionArgument) {
log.Info("Sanitize AA") if arg.Title == "" {
arg.Label = "foo" arg.Title = arg.Name
arg.Name = "blat" }
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"
}
} }

View File

@@ -9,99 +9,286 @@ import (
"context" "context"
"errors" "errors"
"os/exec" "os/exec"
"regexp"
"strings"
"time" "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 { type InternalLogEntry struct {
Datetime string Datetime string
Content string
Stdout string Stdout string
Stderr string Stderr string
TimedOut bool TimedOut bool
ExitCode int32 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 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 { type Executor struct {
Logs []InternalLogEntry Logs []InternalLogEntry
chainOfCommand []ExecutorStep
} }
// ExecAction executes an action. func DefaultExecutor() *Executor {
func (e *Executor) ExecAction(cfg *config.Config, user *acl.User, actualAction *config.ActionButton) *pb.StartActionResponse { 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{ log.WithFields(log.Fields{
"actionName": actualAction.Title, "actionName": req.ActionName,
}).Infof("StartAction") }).Warnf("Action not found")
res := execAction(cfg, actualAction) req.logEntry.Stderr = "Action not found"
req.logEntry.ExitCode = -1337
e.Logs = append(e.Logs, *res) 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{ return &pb.StartActionResponse{
LogEntry: &pb.LogEntry{ LogEntry: &pb.LogEntry{
ActionTitle: actualAction.Title, ActionTitle: req.logEntry.ActionTitle,
TimedOut: res.TimedOut, ActionIcon: req.logEntry.ActionIcon,
Stderr: res.Stderr, Datetime: req.logEntry.Datetime,
Stdout: res.Stdout, Stderr: req.logEntry.Stderr,
ExitCode: res.ExitCode, Stdout: req.logEntry.Stdout,
TimedOut: req.logEntry.TimedOut,
ExitCode: req.logEntry.ExitCode,
}, },
} }
} }
func execAction(cfg *config.Config, actualAction *config.ActionButton) *InternalLogEntry { type StepLogStart struct{}
res := &InternalLogEntry{
Datetime: time.Now().Format("2006-01-02 15:04:05"), func (e StepLogStart) Exec(req *ExecutionRequest) bool {
TimedOut: false, log.WithFields(log.Fields{
ActionTitle: actualAction.Title, "title": req.action.Title,
"timeout": req.action.Timeout,
}).Infof("Action starting")
return true
} }
log.WithFields(log.Fields{ type StepLogFinish struct{}
"title": actualAction.Title,
"timeout": actualAction.Timeout,
}).Infof("Found action")
ctx, cancel := context.WithTimeout(context.Background(), time.Duration(actualAction.Timeout)*time.Second) 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
}
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() defer cancel()
cmd := exec.CommandContext(ctx, "sh", "-c", actualAction.Shell) cmd := exec.CommandContext(ctx, "sh", "-c", req.finalParsedCommand)
stdout, stderr := cmd.Output() stdout, stderr := cmd.Output()
res.ExitCode = int32(cmd.ProcessState.ExitCode()) if stderr != nil {
res.Stdout = string(stdout) req.logEntry.Stderr = stderr.Error()
if stderr == nil {
res.Stderr = ""
} else {
res.Stderr = stderr.Error()
} }
if ctx.Err() == context.DeadlineExceeded { 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{ log.WithFields(log.Fields{
"stdout": res.Stdout, "name": match[1],
"stderr": res.Stderr, "value": argValue,
"timedOut": res.TimedOut, }).Debugf("Arg assigned")
"exit": res.ExitCode,
}).Infof("Finished command.")
return res rawShellCommand = strings.Replace(rawShellCommand, match[0], argValue, -1)
} }
func sanitizeAction(action *config.ActionButton) { log.WithFields(log.Fields{
if action.Timeout < 3 { "cmd": rawShellCommand,
action.Timeout = 3 }).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 typecheckChoice(value string, arg *config.ActionArgument) error {
for _, choice := range arg.Choices {
if value == choice.Value {
return nil
} }
} }
func FindAction(cfg *config.Config, actionTitle string) (*config.ActionButton, error) { return errors.New("Arg value is not one of the predefined choices")
for _, action := range cfg.ActionButtons { }
if action.Title == actionTitle {
sanitizeAction(&action)
return &action, nil 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, errors.New("Action not found") return nil
} }

View File

@@ -14,7 +14,7 @@ import (
var ( var (
cfg *config.Config cfg *config.Config
ex = executor.Executor{} ex = executor.DefaultExecutor()
) )
type oliveTinAPI struct { type oliveTinAPI struct {
@@ -22,36 +22,34 @@ type oliveTinAPI struct {
} }
func (api *oliveTinAPI) StartAction(ctx ctx.Context, req *pb.StartActionRequest) (*pb.StartActionResponse, error) { 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.Debugf("SA %v", req)
log.Errorf("Error finding action %s, %s", err, req.ActionName)
return &pb.StartActionResponse{ for _, arg := range req.Arguments {
LogEntry: nil, args[arg.Name] = arg.Value
}, nil
} }
execReq := executor.ExecutionRequest{
ActionName: req.ActionName,
Arguments: args,
User: acl.UserFromContext(ctx),
Cfg: cfg,
}
return ex.ExecRequest(&execReq), nil
}
func (api *oliveTinAPI) GetDashboardComponents(ctx ctx.Context, req *pb.GetDashboardComponentsRequest) (*pb.GetDashboardComponentsResponse, error) {
user := acl.UserFromContext(ctx) user := acl.UserFromContext(ctx)
if !acl.IsAllowedExec(cfg, user, actualAction) { res := actionsCfgToPb(cfg.Actions, user)
return &pb.StartActionResponse{}, nil
}
return ex.ExecAction(cfg, acl.UserFromContext(ctx), actualAction), nil
}
func (api *oliveTinAPI) GetButtons(ctx ctx.Context, req *pb.GetButtonsRequest) (*pb.GetButtonsResponse, error) {
user := acl.UserFromContext(ctx)
res := actionButtonsCfgToPb(cfg.ActionButtons, user)
if len(res.Actions) == 0 { if len(res.Actions) == 0 {
log.Warn("Zero actions found - check that you have some actions defined, with a view permission") log.Warn("Zero actions found - check that you have some actions defined, with a view permission")
} }
log.Debugf("getButtons: %v", res) log.Debugf("GetDashboardComponents: %v", res)
return res, nil return res, nil
} }
@@ -64,6 +62,7 @@ func (api *oliveTinAPI) GetLogs(ctx ctx.Context, req *pb.GetLogsRequest) (*pb.Ge
for _, logEntry := range ex.Logs { for _, logEntry := range ex.Logs {
ret.Logs = append(ret.Logs, &pb.LogEntry{ ret.Logs = append(ret.Logs, &pb.LogEntry{
ActionTitle: logEntry.ActionTitle, ActionTitle: logEntry.ActionTitle,
ActionIcon: logEntry.ActionIcon,
Datetime: logEntry.Datetime, Datetime: logEntry.Datetime,
Stdout: logEntry.Stdout, Stdout: logEntry.Stdout,
Stderr: logEntry.Stderr, Stderr: logEntry.Stderr,
@@ -75,6 +74,25 @@ func (api *oliveTinAPI) GetLogs(ctx ctx.Context, req *pb.GetLogsRequest) (*pb.Ge
return ret, nil 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. // Start will start the GRPC API.
func Start(globalConfig *config.Config) { func Start(globalConfig *config.Config) {
cfg = globalConfig cfg = globalConfig

View File

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

View File

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

View File

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

View File

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

View File

@@ -23,6 +23,7 @@
<tr> <tr>
<th>Timestamp</th> <th>Timestamp</th>
<th>Log</th> <th>Log</th>
<th>Exit Code</th>
</tr> </tr>
</thead> </thead>
<tbody id = "logTableBody" /> <tbody id = "logTableBody" />
@@ -38,28 +39,56 @@
<p><img title = "application icon" src = "OliveTinLogo.png" height = "1em" class = "logo" /> OliveTin</p> <p><img title = "application icon" src = "OliveTinLogo.png" height = "1em" class = "logo" /> OliveTin</p>
<p> <p>
<a href = "https://docs.olivetin.app" target = "_new">Documentation</a> | <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> </p>
</footer> </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"> <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> <p role = "title" class = "title">Untitled Button</p>
</template> </template>
<template id = "tplLogRow"> <template id = "tplLogRow">
<tr> <tr class = "logRow">
<td class = "timestamp">?</td> <td class = "timestamp">?</td>
<td> <td>
<span class = "icon" role = "icon"></span>
<span class = "content">?</span> <span class = "content">?</span>
<details> <details>
<summary>stdout</summary> <summary>stdout</summary>
<pre> <pre class = "stdout">
? ?
</pre> </pre>
</details> </details>
<details>
<summary>stderr</summary>
<pre class = "stderr">
?
</pre>
</details>
</td> </td>
<td class = "exitCode">?</td>
</tr> </tr>
</template> </template>

View File

@@ -1,5 +1,5 @@
import { marshalLogsJsonToHtml } from './marshaller.js'; import { marshalLogsJsonToHtml } from './marshaller.js'
import "./ArgumentForm.js" import './ArgumentForm.js'
class ActionButton extends window.HTMLButtonElement { class ActionButton extends window.HTMLButtonElement {
constructFromJson (json) { constructFromJson (json) {
@@ -8,16 +8,16 @@ class ActionButton extends window.HTMLButtonElement {
this.title = json.title this.title = json.title
this.temporaryStatusMessage = null this.temporaryStatusMessage = null
this.isWaiting = false this.isWaiting = false
this.actionCallUrl = window.restBaseUrl + 'StartAction?actionName=' + this.title this.actionCallUrl = window.restBaseUrl + 'StartAction'
this.updateFromJson(json) this.updateFromJson(json)
this.onclick = () => { this.onclick = () => {
if (json.arguments.length > 0) { if (json.arguments.length > 0) {
let frm = document.createElement('form', { is: 'argument-form' }) const frm = document.createElement('form', { is: 'argument-form' })
window.frm = frm frm.setup(json, (args) => {
console.log(frm) this.startAction(args)
frm.setup(json, this.startAction) })
document.body.appendChild(frm) document.body.appendChild(frm)
} else { } else {
@@ -46,18 +46,41 @@ class ActionButton extends window.HTMLButtonElement {
} }
} }
startAction () { startAction (actionArgs) {
this.disabled = true this.disabled = true
this.isWaiting = true this.isWaiting = true
this.updateHtml() this.updateHtml()
this.classList = [] // Removes old animation classes 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) => { ).then((json) => {
marshalLogsJsonToHtml({ logs: [json.logEntry] }) marshalLogsJsonToHtml({ logs: [json.logEntry] })
if (json.logEntry.timedOut) { if (json.logEntry.timedOut) {
this.onActionResult('actionTimeout', 'Timed out') this.onActionResult('actionTimeout', 'Timed out')
} else if (json.logEntry.exitCode === -1337) {
this.onActionError('Error')
} else if (json.logEntry.exitCode !== 0) { } else if (json.logEntry.exitCode !== 0) {
this.onActionResult('actionNonZeroExit', 'Exit code ' + json.logEntry.exitCode) this.onActionResult('actionNonZeroExit', 'Exit code ' + json.logEntry.exitCode)
} else { } else {

View File

@@ -3,83 +3,138 @@ class ArgumentForm extends window.HTMLFormElement {
setup (json, callback) { setup (json, callback) {
this.setAttribute('class', 'actionArguments') this.setAttribute('class', 'actionArguments')
console.log(json) this.constructTemplate()
this.domTitle.innerText = json.title
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.domIcon.innerHTML = json.icon 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.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
}
} }
createDomSubmit() { const argvs = this.getArgumentValues()
let el = document.createElement('button')
el.setAttribute('action', 'submit')
el.innerText = "Run"
return el callback(argvs)
this.remove()
}
this.domBtnCancel.onclick = () => {
this.remove()
}
}
getArgumentValues () {
const ret = []
for (const arg of this.argInputs) {
ret.push({
name: arg.name,
value: arg.value
})
}
return ret
}
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) { createDomFormArguments (args) {
for (let arg of args) { this.argInputs = []
let domFieldWrapper = document.createElement('p');
for (const arg of args) {
const domFieldWrapper = document.createElement('p')
domFieldWrapper.appendChild(this.createDomLabel(arg)) domFieldWrapper.appendChild(this.createDomLabel(arg))
domFieldWrapper.appendChild(this.createDomInput(arg)) domFieldWrapper.appendChild(this.createDomInput(arg))
this.domWrapper.appendChild(domFieldWrapper) this.domArgs.appendChild(domFieldWrapper)
} }
} }
createDomLabel (arg) { createDomLabel (arg) {
let domLbl = document.createElement('label') const domLbl = document.createElement('label')
domLbl.innerText = arg.label + ':'; domLbl.innerText = arg.title + ':'
domLbl.setAttribute('for', arg.name) domLbl.setAttribute('for', arg.name)
return domLbl; return domLbl
} }
createDomInput (arg) { createDomInput (arg) {
let domEl = null; let domEl = null
if (arg.choices.length > 0) { if (arg.choices.length > 0) {
domEl = document.createElement('select') 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)) domEl.appendChild(this.createSelectOption(choice))
} }
} else { } else {
domEl = document.createElement('input') domEl = document.createElement('input')
domEl.onchange = () => {
const validateArgumentTypeArgs = {
value: domEl.value,
type: arg.type
} }
domEl.setAttribute('id', arg.name) 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.name = arg.name
domEl.value = arg.defaultValue domEl.value = arg.defaultValue
return domEl; this.argInputs.push(domEl)
return domEl
} }
createSelectOption (choice) { createSelectOption (choice) {
let domEl = document.createElement('option') const domEl = document.createElement('option')
domEl.setAttribute('value', choice.value) domEl.setAttribute('value', choice.value)
domEl.innerText = choice.label domEl.innerText = choice.title
return domEl return domEl
} }

View File

@@ -31,9 +31,30 @@ export function marshalLogsJsonToHtml (json) {
const tpl = document.getElementById('tplLogRow') const tpl = document.getElementById('tplLogRow')
const row = tpl.content.cloneNode(true) 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('.timestamp').innerText = logEntry.datetime
row.querySelector('.content').innerText = logEntry.actionTitle 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) document.querySelector('#logTableBody').prepend(row)
} }

View File

@@ -31,8 +31,8 @@ function setupSections () {
showSection('Actions') showSection('Actions')
} }
function fetchGetButtons () { function fetchGetDashboardComponents () {
window.fetch(window.restBaseUrl + 'GetButtons', { window.fetch(window.restBaseUrl + 'GetDashboardComponents', {
cors: 'cors' cors: 'cors'
}).then(res => { }).then(res => {
return res.json() return res.json()
@@ -67,6 +67,13 @@ function processWebuiSettingsJson (settings) {
document.head.appendChild(themeCss) 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 document.querySelector('#switcher').hidden = settings.HideNavigation
} }
@@ -77,10 +84,10 @@ window.fetch('webUiSettings.json').then(res => {
}).then(res => { }).then(res => {
processWebuiSettingsJson(res) processWebuiSettingsJson(res)
fetchGetButtons() fetchGetDashboardComponents()
fetchGetLogs() fetchGetLogs()
window.buttonInterval = setInterval(fetchGetButtons, 3000) window.buttonInterval = setInterval(fetchGetDashboardComponents, 3000)
}).catch(err => { }).catch(err => {
showBigError('fetch-webui-settings', 'getting webui settings', err) showBigError('fetch-webui-settings', 'getting webui settings', err)
}) })

View File

@@ -52,6 +52,14 @@ legend {
span[role="icon"] { span[role="icon"] {
display: block; 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 { .error {
@@ -73,11 +81,17 @@ div.entity {
grid-template-columns: minmax(min-content, auto); grid-template-columns: minmax(min-content, auto);
} }
h2 {
font-size: 1em;
display: inline-block;
}
div.entity h2 { div.entity h2 {
grid-column: 1 / span all; grid-column: 1 / span all;
} }
button { button,
input[type="submit"] {
padding: 1em; padding: 1em;
color: black; color: black;
display: table-cell; display: table-cell;
@@ -88,16 +102,19 @@ button {
user-select: none; user-select: none;
} }
button:hover { button:hover,
input[type="submit"]:hover {
box-shadow: 0 0 10px 0 #666; box-shadow: 0 0 10px 0 #666;
cursor: pointer; cursor: pointer;
} }
button:focus { button:focus,
input[type="submit"]:focus {
outline: 1px solid black; outline: 1px solid black;
} }
button:disabled { button:disabled,
input[type="submit"]:disabled {
color: gray; color: gray;
background-color: #333; background-color: #333;
cursor: not-allowed; cursor: not-allowed;
@@ -117,10 +134,6 @@ fieldset#switcher button:last-child {
border-radius: 0 1em 1em 0; border-radius: 0 1em 1em 0;
} }
span.icon {
font-size: 3em;
}
.actionFailed { .actionFailed {
animation: kfActionFailed 1s; 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) { @media (prefers-color-scheme: dark) {
body { body {
background-color: #333; background-color: #333;
color: white; color: white;
} }
button { form.actionArguments {
background-color: #333;
}
form div.wrapper {
background-color: #222;
}
button,
input[type="submit"] {
border: 1px solid #666; border: 1px solid #666;
background-color: #222; background-color: #222;
box-shadow: 0 0 6px 0 #444; box-shadow: 0 0 6px 0 #444;
@@ -218,100 +333,3 @@ img.logo {
color: gray; 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;
}