mirror of
https://github.com/OliveTin/OliveTin
synced 2025-12-12 00:55:34 +00:00
#6 - task arguments, that was a lot of work!
This commit is contained in:
2
Makefile
2
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
|
||||
|
||||
@@ -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: "*"
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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,7 +23,7 @@ type ActionArgument struct {
|
||||
|
||||
type ActionArgumentChoice struct {
|
||||
Value string
|
||||
Label string
|
||||
Title string
|
||||
}
|
||||
|
||||
// Entity represents a "thing" that can have multiple actions associated with it.
|
||||
@@ -32,7 +31,7 @@ type ActionArgumentChoice struct {
|
||||
type Entity struct {
|
||||
Title string
|
||||
Icon string
|
||||
ActionButtons []ActionButton `mapstructure:"actions"`
|
||||
Actions []Action `mapstructure:"actions"`
|
||||
CSS map[string]string
|
||||
}
|
||||
|
||||
@@ -63,9 +62,10 @@ type Config struct {
|
||||
ListenAddressGrpcActions string
|
||||
ExternalRestAddress string
|
||||
LogLevel string
|
||||
ActionButtons []ActionButton `mapstructure:"actions"`
|
||||
Actions []Action `mapstructure:"actions"`
|
||||
Entities []Entity `mapstructure:"entities"`
|
||||
CheckForUpdates bool
|
||||
ShowNewVersions bool
|
||||
Usergroups []UserGroup
|
||||
DefaultPermissions DefaultPermissions
|
||||
}
|
||||
@@ -81,6 +81,7 @@ func DefaultConfig() *Config {
|
||||
config.ListenAddressWebUI = "localhost:1340"
|
||||
config.LogLevel = "INFO"
|
||||
config.CheckForUpdates = true
|
||||
config.ShowNewVersions = true
|
||||
config.DefaultPermissions.Exec = true
|
||||
config.DefaultPermissions.View = true
|
||||
|
||||
|
||||
11
internal/config/config_helpers.go
Normal file
11
internal/config/config_helpers.go
Normal 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
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package grpcapi
|
||||
package config
|
||||
|
||||
var emojis = map[string]string{
|
||||
"poop": "💩",
|
||||
@@ -1,4 +1,4 @@
|
||||
package grpcapi
|
||||
package config
|
||||
|
||||
import (
|
||||
"github.com/stretchr/testify/assert"
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
/*
|
||||
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 {
|
||||
func DefaultExecutor() *Executor {
|
||||
e := Executor{}
|
||||
e.chainOfCommand = []ExecutorStep{
|
||||
StepFindAction{},
|
||||
StepAclCheck{},
|
||||
StepParseArgs{},
|
||||
StepLogStart{},
|
||||
StepExec{},
|
||||
StepLogFinish{},
|
||||
}
|
||||
|
||||
return &e
|
||||
}
|
||||
|
||||
type StepFindAction struct{}
|
||||
|
||||
func (s StepFindAction) Exec(req *ExecutionRequest) bool {
|
||||
actualAction := req.Cfg.FindAction(req.ActionName)
|
||||
|
||||
if actualAction == nil {
|
||||
log.WithFields(log.Fields{
|
||||
"actionName": actualAction.Title,
|
||||
}).Infof("StartAction")
|
||||
"actionName": req.ActionName,
|
||||
}).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{
|
||||
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{
|
||||
"stdout": res.Stdout,
|
||||
"stderr": res.Stderr,
|
||||
"timedOut": res.TimedOut,
|
||||
"exit": res.ExitCode,
|
||||
}).Infof("Finished command.")
|
||||
"name": match[1],
|
||||
"value": argValue,
|
||||
}).Debugf("Arg assigned")
|
||||
|
||||
return res
|
||||
}
|
||||
|
||||
func sanitizeAction(action *config.ActionButton) {
|
||||
if action.Timeout < 3 {
|
||||
action.Timeout = 3
|
||||
rawShellCommand = strings.Replace(rawShellCommand, match[0], argValue, -1)
|
||||
}
|
||||
|
||||
log.WithFields(log.Fields{
|
||||
"cmd": rawShellCommand,
|
||||
}).Infof("After Parse Args")
|
||||
|
||||
return rawShellCommand, nil
|
||||
}
|
||||
|
||||
func FindAction(cfg *config.Config, actionTitle string) (*config.ActionButton, error) {
|
||||
for _, action := range cfg.ActionButtons {
|
||||
if action.Title == actionTitle {
|
||||
sanitizeAction(&action)
|
||||
func typecheckActionArgument(name string, value string, action *config.Action) error {
|
||||
arg := findArg(name, action)
|
||||
|
||||
return &action, nil
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
AvailableVersion string
|
||||
CurrentVersion string
|
||||
ShowNewVersions bool
|
||||
}
|
||||
|
||||
func findWebuiDir() string {
|
||||
@@ -46,6 +50,9 @@ func generateWebUISettings(w http.ResponseWriter, r *http.Request) {
|
||||
Rest: restAddress + "/api/",
|
||||
ThemeName: cfg.ThemeName,
|
||||
HideNavigation: cfg.HideNavigation,
|
||||
AvailableVersion: updatecheck.AvailableVersion,
|
||||
CurrentVersion: updatecheck.CurrentVersion,
|
||||
ShowNewVersions: cfg.ShowNewVersions,
|
||||
})
|
||||
|
||||
w.Write([]byte(jsonRet))
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
<tr>
|
||||
<th>Timestamp</th>
|
||||
<th>Log</th>
|
||||
<th>Exit Code</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id = "logTableBody" />
|
||||
@@ -38,28 +39,56 @@
|
||||
<p><img title = "application icon" src = "OliveTinLogo.png" height = "1em" class = "logo" /> OliveTin</p>
|
||||
<p>
|
||||
<a href = "https://docs.olivetin.app" target = "_new">Documentation</a> |
|
||||
<a href = "https://github.com/OliveTin/OliveTin/issues/new/choose" target = "_new">Raise an issue on GitHub</a>
|
||||
<a href = "https://github.com/OliveTin/OliveTin/issues/new/choose" target = "_new">Raise an issue on GitHub</a> |
|
||||
<span id = "currentVersion">Version: ?</p>
|
||||
<a id = "availableVersion" href = "http://olivetin.app" target = "_blank" hidden>?</a>
|
||||
</p>
|
||||
</footer>
|
||||
|
||||
<template id = "tplArgumentForm">
|
||||
<div class = "wrapper">
|
||||
<div>
|
||||
<span class = "icon" role = "icon"></span>
|
||||
<h2>Argument form</h2>
|
||||
</div>
|
||||
|
||||
<div class = "arguments"></div>
|
||||
|
||||
<div class = "buttons">
|
||||
<input name = "start" type = "submit" value = "Start">
|
||||
<button name = "cancel">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template id = "tplActionButton">
|
||||
<span role = "img" title = "button icon" class = "icon">💩</span>
|
||||
<span role = "icon" title = "button icon" class = "icon">💩</span>
|
||||
<p role = "title" class = "title">Untitled Button</p>
|
||||
</template>
|
||||
|
||||
<template id = "tplLogRow">
|
||||
<tr>
|
||||
<tr class = "logRow">
|
||||
<td class = "timestamp">?</td>
|
||||
<td>
|
||||
<span class = "icon" role = "icon"></span>
|
||||
<span class = "content">?</span>
|
||||
|
||||
<details>
|
||||
<summary>stdout</summary>
|
||||
<pre>
|
||||
<pre class = "stdout">
|
||||
?
|
||||
</pre>
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>stderr</summary>
|
||||
<pre class = "stderr">
|
||||
?
|
||||
</pre>
|
||||
</details>
|
||||
|
||||
</td>
|
||||
<td class = "exitCode">?</td>
|
||||
</tr>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -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,16 +8,16 @@ 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 = () => {
|
||||
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 {
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
createDomSubmit() {
|
||||
let el = document.createElement('button')
|
||||
el.setAttribute('action', 'submit')
|
||||
el.innerText = "Run"
|
||||
const argvs = this.getArgumentValues()
|
||||
|
||||
return el
|
||||
callback(argvs)
|
||||
|
||||
this.remove()
|
||||
}
|
||||
|
||||
createDomFormArguments(args) {
|
||||
for (let arg of args) {
|
||||
let domFieldWrapper = document.createElement('p');
|
||||
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) {
|
||||
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
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
230
webui/style.css
230
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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user