#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:
go fmt ./...
go vet ./...
gocyclo -over 3 cmd internal
gocyclo -over 4 cmd internal
daemon-unittests:
mkdir -p reports

View File

@@ -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: "*"
};
}
}

View File

@@ -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() {

View File

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

View File

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

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{
"poop": "💩",

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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")

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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