From d68dba13bd2672a028ced57c0f9c0d1379369635 Mon Sep 17 00:00:00 2001 From: jamesread Date: Sun, 17 Oct 2021 20:58:02 +0100 Subject: [PATCH 1/8] Started work on argument form --- OliveTin.proto | 15 +++++++++++++++ internal/config/config.go | 13 +++++++++++++ internal/grpcapi/grpcApi.go | 8 ++++++++ webui/js/ActionButton.js | 14 +++++++++++++- webui/js/ArgumentForm.js | 19 +++++++++++++++++++ webui/style.css | 10 ++++++++++ 6 files changed, 78 insertions(+), 1 deletion(-) create mode 100644 webui/js/ArgumentForm.js diff --git a/OliveTin.proto b/OliveTin.proto index 4c59617..f348b45 100644 --- a/OliveTin.proto +++ b/OliveTin.proto @@ -9,6 +9,21 @@ message ActionButton { string title = 2; string icon = 3; bool canExec = 4; + + repeated ActionArgument arguments = 5; +} + +message ActionArgument { + string variable = 1; + string label = 2; + string type = 3; + + repeated ActionArgumentValue values = 4; +} + +message ActionArgumentValue { + string value = 1; + string label = 2; } message GetButtonsResponse { diff --git a/internal/config/config.go b/internal/config/config.go index 12e5afe..12efe2f 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -11,6 +11,19 @@ type ActionButton struct { CSS map[string]string `mapstructure:"omitempty"` Timeout int Permissions []PermissionsEntry + Arguments []ActionArgument +} + +type ActionArgument struct { + Variable string + Label string + Type string + Values []ActionArgumentValue +} + +type ActionArgumentValue struct { + Value string + Label string } // Entity represents a "thing" that can have multiple actions associated with it. diff --git a/internal/grpcapi/grpcApi.go b/internal/grpcapi/grpcApi.go index 22b5096..fa03211 100644 --- a/internal/grpcapi/grpcApi.go +++ b/internal/grpcapi/grpcApi.go @@ -61,6 +61,14 @@ func (api *oliveTinAPI) GetButtons(ctx ctx.Context, req *pb.GetButtonsRequest) ( CanExec: acl.IsAllowedExec(cfg, user, &action), } + for _, cfgArg := range action.Arguments { + pbArg := pb.ActionArgument { + Label: cfgArg.Label, + } + + btn.Arguments = append(btn.Arguments, &pbArg) + } + res.Actions = append(res.Actions, &btn) } diff --git a/webui/js/ActionButton.js b/webui/js/ActionButton.js index b94002e..503b02e 100644 --- a/webui/js/ActionButton.js +++ b/webui/js/ActionButton.js @@ -1,4 +1,5 @@ import { marshalLogsJsonToHtml } from './marshaller.js'; +import "./ArgumentForm.js" class ActionButton extends window.HTMLButtonElement { constructFromJson (json) { @@ -11,7 +12,18 @@ class ActionButton extends window.HTMLButtonElement { this.updateFromJson(json) - this.onclick = () => { this.startAction() } + 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) + + document.body.appendChild(frm) + } else { + this.startAction() + } + } this.constructTemplate() diff --git a/webui/js/ArgumentForm.js b/webui/js/ArgumentForm.js new file mode 100644 index 0000000..f47af1b --- /dev/null +++ b/webui/js/ArgumentForm.js @@ -0,0 +1,19 @@ + +class ArgumentForm extends window.HTMLFormElement { + setup(json, callback) { + this.setAttribute('class', 'actionArguments') + this.title = document.createElement("h1") + this.title.innerHTML = "Action Arguments" + + this.appendChild(this.title); + + let a = document.createElement("span") + a.innerText = "Hi" + frm.appendChild(a) + + + console.log(json) + } +} + +window.customElements.define('argument-form', ArgumentForm, { extends: 'form' }) diff --git a/webui/style.css b/webui/style.css index d9e3e8d..3f15482 100644 --- a/webui/style.css +++ b/webui/style.css @@ -247,3 +247,13 @@ details[open] { margin-top: 1em; display: block; } + +form.actionArguments { + border-radius: 1em; + position: absolute; + top: 1em; + left: 1em; + right: 1em; + padding: 1em; + background-color: #fff; +} From 48fe7b7dd4a0e59ed9e9dfd9e11e7a755ab9706a Mon Sep 17 00:00:00 2001 From: jamesread Date: Sun, 17 Oct 2021 23:32:05 +0100 Subject: [PATCH 2/8] Ongoing UI work --- OliveTin.proto | 7 +-- internal/config/config.go | 7 +-- internal/grpcapi/grpcApi.go | 22 ++------ internal/grpcapi/grpcApiButtons.go | 47 ++++++++++++++++ webui/js/ArgumentForm.js | 87 ++++++++++++++++++++++++++---- webui/style.css | 40 ++++++++++++-- 6 files changed, 171 insertions(+), 39 deletions(-) create mode 100644 internal/grpcapi/grpcApiButtons.go diff --git a/OliveTin.proto b/OliveTin.proto index f348b45..91dab54 100644 --- a/OliveTin.proto +++ b/OliveTin.proto @@ -14,14 +14,15 @@ message ActionButton { } message ActionArgument { - string variable = 1; + string name = 1; string label = 2; string type = 3; + string defaultValue = 4; - repeated ActionArgumentValue values = 4; + repeated ActionArgumentChoice choices = 5; } -message ActionArgumentValue { +message ActionArgumentChoice { string value = 1; string label = 2; } diff --git a/internal/config/config.go b/internal/config/config.go index 12efe2f..d93d71c 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -15,13 +15,14 @@ type ActionButton struct { } type ActionArgument struct { - Variable string + Name string Label string Type string - Values []ActionArgumentValue + Default string + Choices []ActionArgumentChoice } -type ActionArgumentValue struct { +type ActionArgumentChoice struct { Value string Label string } diff --git a/internal/grpcapi/grpcApi.go b/internal/grpcapi/grpcApi.go index fa03211..1605868 100644 --- a/internal/grpcapi/grpcApi.go +++ b/internal/grpcapi/grpcApi.go @@ -2,16 +2,14 @@ package grpcapi import ( ctx "context" - "crypto/md5" - "fmt" pb "github.com/jamesread/OliveTin/gen/grpc" log "github.com/sirupsen/logrus" "google.golang.org/grpc" "net" - acl "github.com/jamesread/OliveTin/internal/acl" config "github.com/jamesread/OliveTin/internal/config" executor "github.com/jamesread/OliveTin/internal/executor" + acl "github.com/jamesread/OliveTin/internal/acl" ) var ( @@ -54,22 +52,8 @@ func (api *oliveTinAPI) GetButtons(ctx ctx.Context, req *pb.GetButtonsRequest) ( continue } - btn := pb.ActionButton{ - Id: fmt.Sprintf("%x", md5.Sum([]byte(action.Title))), - Title: action.Title, - Icon: lookupHTMLIcon(action.Icon), - CanExec: acl.IsAllowedExec(cfg, user, &action), - } - - for _, cfgArg := range action.Arguments { - pbArg := pb.ActionArgument { - Label: cfgArg.Label, - } - - btn.Arguments = append(btn.Arguments, &pbArg) - } - - res.Actions = append(res.Actions, &btn) + btn := buildButton(action, user) + res.Actions = append(res.Actions, btn) } if len(res.Actions) == 0 { diff --git a/internal/grpcapi/grpcApiButtons.go b/internal/grpcapi/grpcApiButtons.go new file mode 100644 index 0000000..e9141a5 --- /dev/null +++ b/internal/grpcapi/grpcApiButtons.go @@ -0,0 +1,47 @@ +package grpcapi + +import ( + "fmt" + "crypto/md5" + pb "github.com/jamesread/OliveTin/gen/grpc" + config "github.com/jamesread/OliveTin/internal/config" + acl "github.com/jamesread/OliveTin/internal/acl" +) + +func buildButton(action config.ActionButton, user *acl.User) (*pb.ActionButton) { + btn := pb.ActionButton{ + Id: fmt.Sprintf("%x", md5.Sum([]byte(action.Title))), + Title: action.Title, + Icon: lookupHTMLIcon(action.Icon), + CanExec: acl.IsAllowedExec(cfg, user, &action), + } + + for _, cfgArg := range action.Arguments { + pbArg := pb.ActionArgument { + Name: cfgArg.Name, + Label: cfgArg.Label, + Type: cfgArg.Type, + DefaultValue: cfgArg.Default, + Choices: buildChoices(cfgArg.Choices), + } + + btn.Arguments = append(btn.Arguments, &pbArg) + } + + return &btn +} + +func buildChoices(choices []config.ActionArgumentChoice) ([]*pb.ActionArgumentChoice) { + ret := []*pb.ActionArgumentChoice{} + + for _, cfgChoice := range choices { + pbChoice := pb.ActionArgumentChoice { + Value: cfgChoice.Value, + Label: cfgChoice.Label, + } + + ret = append(ret, &pbChoice); + } + + return ret; +} diff --git a/webui/js/ArgumentForm.js b/webui/js/ArgumentForm.js index f47af1b..bf0f706 100644 --- a/webui/js/ArgumentForm.js +++ b/webui/js/ArgumentForm.js @@ -2,17 +2,86 @@ class ArgumentForm extends window.HTMLFormElement { setup(json, callback) { this.setAttribute('class', 'actionArguments') - this.title = document.createElement("h1") - this.title.innerHTML = "Action Arguments" - - this.appendChild(this.title); - - let a = document.createElement("span") - a.innerText = "Hi" - frm.appendChild(a) - 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.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) + } + + createDomSubmit() { + let el = document.createElement('button') + el.setAttribute('action', 'submit') + el.innerText = "Run" + + return el + } + + createDomFormArguments(args) { + for (let arg of args) { + let domFieldWrapper = document.createElement('p'); + + domFieldWrapper.appendChild(this.createDomLabel(arg)) + domFieldWrapper.appendChild(this.createDomInput(arg)) + + this.domWrapper.appendChild(domFieldWrapper) + } + } + + createDomLabel(arg) { + let domLbl = document.createElement('label') + domLbl.innerText = arg.label + ':'; + domLbl.setAttribute('for', arg.name) + + return domLbl; + } + + createDomInput(arg) { + let domEl = null; + + if (arg.choices.length > 0) { + domEl = document.createElement('select') + + for (let choice of arg.choices) { + domEl.appendChild(this.createSelectOption(choice)) + } + } else { + domEl = document.createElement('input') + } + + domEl.setAttribute('id', arg.name) + domEl.value = arg.defaultValue + + return domEl; + } + + createSelectOption(choice) { + let domEl = document.createElement('option') + + domEl.setAttribute('value', choice.value) + domEl.innerText = choice.label + + return domEl } } diff --git a/webui/style.css b/webui/style.css index 3f15482..93b2ded 100644 --- a/webui/style.css +++ b/webui/style.css @@ -249,11 +249,41 @@ details[open] { } form.actionArguments { - border-radius: 1em; position: absolute; - top: 1em; - left: 1em; - right: 1em; + top: 0; + bottom: 0; + left: 0; + right: 0; padding: 1em; - background-color: #fff; + 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; } From c43d4ffd34f3515218e9480517655aa3e8bd4363 Mon Sep 17 00:00:00 2001 From: jamesread Date: Sun, 17 Oct 2021 23:36:55 +0100 Subject: [PATCH 3/8] gofmt --- internal/config/config.go | 16 ++++++++-------- internal/grpcapi/grpcApi.go | 2 +- internal/grpcapi/grpcApiButtons.go | 24 ++++++++++++------------ 3 files changed, 21 insertions(+), 21 deletions(-) diff --git a/internal/config/config.go b/internal/config/config.go index d93d71c..2de2755 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -11,20 +11,20 @@ type ActionButton struct { CSS map[string]string `mapstructure:"omitempty"` Timeout int Permissions []PermissionsEntry - Arguments []ActionArgument + Arguments []ActionArgument } type ActionArgument struct { - Name string - Label string - Type string - Default string - Choices []ActionArgumentChoice + Name string + Label string + Type string + Default string + Choices []ActionArgumentChoice } type ActionArgumentChoice struct { - Value string - Label string + Value string + Label string } // Entity represents a "thing" that can have multiple actions associated with it. diff --git a/internal/grpcapi/grpcApi.go b/internal/grpcapi/grpcApi.go index 1605868..0b4eb71 100644 --- a/internal/grpcapi/grpcApi.go +++ b/internal/grpcapi/grpcApi.go @@ -7,9 +7,9 @@ import ( "google.golang.org/grpc" "net" + acl "github.com/jamesread/OliveTin/internal/acl" config "github.com/jamesread/OliveTin/internal/config" executor "github.com/jamesread/OliveTin/internal/executor" - acl "github.com/jamesread/OliveTin/internal/acl" ) var ( diff --git a/internal/grpcapi/grpcApiButtons.go b/internal/grpcapi/grpcApiButtons.go index e9141a5..87e0fe0 100644 --- a/internal/grpcapi/grpcApiButtons.go +++ b/internal/grpcapi/grpcApiButtons.go @@ -1,14 +1,14 @@ package grpcapi import ( - "fmt" "crypto/md5" + "fmt" pb "github.com/jamesread/OliveTin/gen/grpc" - config "github.com/jamesread/OliveTin/internal/config" acl "github.com/jamesread/OliveTin/internal/acl" + config "github.com/jamesread/OliveTin/internal/config" ) -func buildButton(action config.ActionButton, user *acl.User) (*pb.ActionButton) { +func buildButton(action config.ActionButton, user *acl.User) *pb.ActionButton { btn := pb.ActionButton{ Id: fmt.Sprintf("%x", md5.Sum([]byte(action.Title))), Title: action.Title, @@ -17,12 +17,12 @@ func buildButton(action config.ActionButton, user *acl.User) (*pb.ActionButton) } for _, cfgArg := range action.Arguments { - pbArg := pb.ActionArgument { - Name: cfgArg.Name, - Label: cfgArg.Label, - Type: cfgArg.Type, + pbArg := pb.ActionArgument{ + Name: cfgArg.Name, + Label: cfgArg.Label, + Type: cfgArg.Type, DefaultValue: cfgArg.Default, - Choices: buildChoices(cfgArg.Choices), + Choices: buildChoices(cfgArg.Choices), } btn.Arguments = append(btn.Arguments, &pbArg) @@ -31,17 +31,17 @@ func buildButton(action config.ActionButton, user *acl.User) (*pb.ActionButton) return &btn } -func buildChoices(choices []config.ActionArgumentChoice) ([]*pb.ActionArgumentChoice) { +func buildChoices(choices []config.ActionArgumentChoice) []*pb.ActionArgumentChoice { ret := []*pb.ActionArgumentChoice{} for _, cfgChoice := range choices { - pbChoice := pb.ActionArgumentChoice { + pbChoice := pb.ActionArgumentChoice{ Value: cfgChoice.Value, Label: cfgChoice.Label, } - ret = append(ret, &pbChoice); + ret = append(ret, &pbChoice) } - return ret; + return ret } From 39647c6d2d72fc92ce5411e6432e1c1842119a51 Mon Sep 17 00:00:00 2001 From: jamesread Date: Mon, 18 Oct 2021 13:38:53 +0100 Subject: [PATCH 4/8] Rebase past all the recent CI work --- OliveTin.proto | 15 +++++++++++++++ internal/config/config.go | 13 +++++++++++++ internal/grpcapi/grpcApi.go | 8 ++++++++ webui/js/ActionButton.js | 14 +++++++++++++- webui/js/ArgumentForm.js | 19 +++++++++++++++++++ webui/style.css | 10 ++++++++++ 6 files changed, 78 insertions(+), 1 deletion(-) create mode 100644 webui/js/ArgumentForm.js diff --git a/OliveTin.proto b/OliveTin.proto index 4c59617..f348b45 100644 --- a/OliveTin.proto +++ b/OliveTin.proto @@ -9,6 +9,21 @@ message ActionButton { string title = 2; string icon = 3; bool canExec = 4; + + repeated ActionArgument arguments = 5; +} + +message ActionArgument { + string variable = 1; + string label = 2; + string type = 3; + + repeated ActionArgumentValue values = 4; +} + +message ActionArgumentValue { + string value = 1; + string label = 2; } message GetButtonsResponse { diff --git a/internal/config/config.go b/internal/config/config.go index 01cd8a3..661551e 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -11,6 +11,19 @@ type ActionButton struct { CSS map[string]string `mapstructure:"omitempty"` Timeout int Permissions []PermissionsEntry + Arguments []ActionArgument +} + +type ActionArgument struct { + Variable string + Label string + Type string + Values []ActionArgumentValue +} + +type ActionArgumentValue struct { + Value string + Label string } // Entity represents a "thing" that can have multiple actions associated with it. diff --git a/internal/grpcapi/grpcApi.go b/internal/grpcapi/grpcApi.go index 51270db..6309e47 100644 --- a/internal/grpcapi/grpcApi.go +++ b/internal/grpcapi/grpcApi.go @@ -73,6 +73,14 @@ func actionButtonsCfgToPb(cfgActionButtons []config.ActionButton, user *acl.User CanExec: acl.IsAllowedExec(cfg, user, &action), } + for _, cfgArg := range action.Arguments { + pbArg := pb.ActionArgument { + Label: cfgArg.Label, + } + + btn.Arguments = append(btn.Arguments, &pbArg) + } + res.Actions = append(res.Actions, &btn) } diff --git a/webui/js/ActionButton.js b/webui/js/ActionButton.js index 7da4b85..2cae6b1 100644 --- a/webui/js/ActionButton.js +++ b/webui/js/ActionButton.js @@ -1,4 +1,5 @@ import { marshalLogsJsonToHtml } from './marshaller.js' +import "./ArgumentForm.js" class ActionButton extends window.HTMLButtonElement { constructFromJson (json) { @@ -11,7 +12,18 @@ class ActionButton extends window.HTMLButtonElement { this.updateFromJson(json) - this.onclick = () => { this.startAction() } + 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) + + document.body.appendChild(frm) + } else { + this.startAction() + } + } this.constructTemplate() diff --git a/webui/js/ArgumentForm.js b/webui/js/ArgumentForm.js new file mode 100644 index 0000000..f47af1b --- /dev/null +++ b/webui/js/ArgumentForm.js @@ -0,0 +1,19 @@ + +class ArgumentForm extends window.HTMLFormElement { + setup(json, callback) { + this.setAttribute('class', 'actionArguments') + this.title = document.createElement("h1") + this.title.innerHTML = "Action Arguments" + + this.appendChild(this.title); + + let a = document.createElement("span") + a.innerText = "Hi" + frm.appendChild(a) + + + console.log(json) + } +} + +window.customElements.define('argument-form', ArgumentForm, { extends: 'form' }) diff --git a/webui/style.css b/webui/style.css index a98f35c..216e637 100644 --- a/webui/style.css +++ b/webui/style.css @@ -235,3 +235,13 @@ details[open] { margin-top: 1em; display: block; } + +form.actionArguments { + border-radius: 1em; + position: absolute; + top: 1em; + left: 1em; + right: 1em; + padding: 1em; + background-color: #fff; +} From afd8353b6f0f549eedca239ae479144c0041bada Mon Sep 17 00:00:00 2001 From: jamesread Date: Sun, 17 Oct 2021 23:32:05 +0100 Subject: [PATCH 5/8] Ongoing UI work --- OliveTin.proto | 7 +-- internal/config/config.go | 7 +-- internal/grpcapi/grpcApi.go | 22 ++------ internal/grpcapi/grpcApiButtons.go | 47 ++++++++++++++++ webui/js/ArgumentForm.js | 87 ++++++++++++++++++++++++++---- webui/style.css | 40 ++++++++++++-- 6 files changed, 171 insertions(+), 39 deletions(-) create mode 100644 internal/grpcapi/grpcApiButtons.go diff --git a/OliveTin.proto b/OliveTin.proto index f348b45..91dab54 100644 --- a/OliveTin.proto +++ b/OliveTin.proto @@ -14,14 +14,15 @@ message ActionButton { } message ActionArgument { - string variable = 1; + string name = 1; string label = 2; string type = 3; + string defaultValue = 4; - repeated ActionArgumentValue values = 4; + repeated ActionArgumentChoice choices = 5; } -message ActionArgumentValue { +message ActionArgumentChoice { string value = 1; string label = 2; } diff --git a/internal/config/config.go b/internal/config/config.go index 661551e..8f4e22d 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -15,13 +15,14 @@ type ActionButton struct { } type ActionArgument struct { - Variable string + Name string Label string Type string - Values []ActionArgumentValue + Default string + Choices []ActionArgumentChoice } -type ActionArgumentValue struct { +type ActionArgumentChoice struct { Value string Label string } diff --git a/internal/grpcapi/grpcApi.go b/internal/grpcapi/grpcApi.go index 6309e47..393f6cd 100644 --- a/internal/grpcapi/grpcApi.go +++ b/internal/grpcapi/grpcApi.go @@ -2,16 +2,14 @@ package grpcapi import ( ctx "context" - "crypto/md5" - "fmt" pb "github.com/jamesread/OliveTin/gen/grpc" log "github.com/sirupsen/logrus" "google.golang.org/grpc" "net" - acl "github.com/jamesread/OliveTin/internal/acl" config "github.com/jamesread/OliveTin/internal/config" executor "github.com/jamesread/OliveTin/internal/executor" + acl "github.com/jamesread/OliveTin/internal/acl" ) var ( @@ -66,22 +64,8 @@ func actionButtonsCfgToPb(cfgActionButtons []config.ActionButton, user *acl.User continue } - btn := pb.ActionButton{ - Id: fmt.Sprintf("%x", md5.Sum([]byte(action.Title))), - Title: action.Title, - Icon: lookupHTMLIcon(action.Icon), - CanExec: acl.IsAllowedExec(cfg, user, &action), - } - - for _, cfgArg := range action.Arguments { - pbArg := pb.ActionArgument { - Label: cfgArg.Label, - } - - btn.Arguments = append(btn.Arguments, &pbArg) - } - - res.Actions = append(res.Actions, &btn) + btn := buildButton(action, user) + res.Actions = append(res.Actions, btn) } return res diff --git a/internal/grpcapi/grpcApiButtons.go b/internal/grpcapi/grpcApiButtons.go new file mode 100644 index 0000000..e9141a5 --- /dev/null +++ b/internal/grpcapi/grpcApiButtons.go @@ -0,0 +1,47 @@ +package grpcapi + +import ( + "fmt" + "crypto/md5" + pb "github.com/jamesread/OliveTin/gen/grpc" + config "github.com/jamesread/OliveTin/internal/config" + acl "github.com/jamesread/OliveTin/internal/acl" +) + +func buildButton(action config.ActionButton, user *acl.User) (*pb.ActionButton) { + btn := pb.ActionButton{ + Id: fmt.Sprintf("%x", md5.Sum([]byte(action.Title))), + Title: action.Title, + Icon: lookupHTMLIcon(action.Icon), + CanExec: acl.IsAllowedExec(cfg, user, &action), + } + + for _, cfgArg := range action.Arguments { + pbArg := pb.ActionArgument { + Name: cfgArg.Name, + Label: cfgArg.Label, + Type: cfgArg.Type, + DefaultValue: cfgArg.Default, + Choices: buildChoices(cfgArg.Choices), + } + + btn.Arguments = append(btn.Arguments, &pbArg) + } + + return &btn +} + +func buildChoices(choices []config.ActionArgumentChoice) ([]*pb.ActionArgumentChoice) { + ret := []*pb.ActionArgumentChoice{} + + for _, cfgChoice := range choices { + pbChoice := pb.ActionArgumentChoice { + Value: cfgChoice.Value, + Label: cfgChoice.Label, + } + + ret = append(ret, &pbChoice); + } + + return ret; +} diff --git a/webui/js/ArgumentForm.js b/webui/js/ArgumentForm.js index f47af1b..bf0f706 100644 --- a/webui/js/ArgumentForm.js +++ b/webui/js/ArgumentForm.js @@ -2,17 +2,86 @@ class ArgumentForm extends window.HTMLFormElement { setup(json, callback) { this.setAttribute('class', 'actionArguments') - this.title = document.createElement("h1") - this.title.innerHTML = "Action Arguments" - - this.appendChild(this.title); - - let a = document.createElement("span") - a.innerText = "Hi" - frm.appendChild(a) - 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.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) + } + + createDomSubmit() { + let el = document.createElement('button') + el.setAttribute('action', 'submit') + el.innerText = "Run" + + return el + } + + createDomFormArguments(args) { + for (let arg of args) { + let domFieldWrapper = document.createElement('p'); + + domFieldWrapper.appendChild(this.createDomLabel(arg)) + domFieldWrapper.appendChild(this.createDomInput(arg)) + + this.domWrapper.appendChild(domFieldWrapper) + } + } + + createDomLabel(arg) { + let domLbl = document.createElement('label') + domLbl.innerText = arg.label + ':'; + domLbl.setAttribute('for', arg.name) + + return domLbl; + } + + createDomInput(arg) { + let domEl = null; + + if (arg.choices.length > 0) { + domEl = document.createElement('select') + + for (let choice of arg.choices) { + domEl.appendChild(this.createSelectOption(choice)) + } + } else { + domEl = document.createElement('input') + } + + domEl.setAttribute('id', arg.name) + domEl.value = arg.defaultValue + + return domEl; + } + + createSelectOption(choice) { + let domEl = document.createElement('option') + + domEl.setAttribute('value', choice.value) + domEl.innerText = choice.label + + return domEl } } diff --git a/webui/style.css b/webui/style.css index 216e637..c750539 100644 --- a/webui/style.css +++ b/webui/style.css @@ -237,11 +237,41 @@ details[open] { } form.actionArguments { - border-radius: 1em; position: absolute; - top: 1em; - left: 1em; - right: 1em; + top: 0; + bottom: 0; + left: 0; + right: 0; padding: 1em; - background-color: #fff; + 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; } From ca36434a48b05926a5a5199bd705664321fe22c9 Mon Sep 17 00:00:00 2001 From: jamesread Date: Sun, 17 Oct 2021 23:36:55 +0100 Subject: [PATCH 6/8] gofmt --- internal/config/config.go | 16 ++++++++-------- internal/grpcapi/grpcApi.go | 2 +- internal/grpcapi/grpcApiButtons.go | 24 ++++++++++++------------ 3 files changed, 21 insertions(+), 21 deletions(-) diff --git a/internal/config/config.go b/internal/config/config.go index 8f4e22d..8898be1 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -11,20 +11,20 @@ type ActionButton struct { CSS map[string]string `mapstructure:"omitempty"` Timeout int Permissions []PermissionsEntry - Arguments []ActionArgument + Arguments []ActionArgument } type ActionArgument struct { - Name string - Label string - Type string - Default string - Choices []ActionArgumentChoice + Name string + Label string + Type string + Default string + Choices []ActionArgumentChoice } type ActionArgumentChoice struct { - Value string - Label string + Value string + Label string } // Entity represents a "thing" that can have multiple actions associated with it. diff --git a/internal/grpcapi/grpcApi.go b/internal/grpcapi/grpcApi.go index 393f6cd..97b5d5c 100644 --- a/internal/grpcapi/grpcApi.go +++ b/internal/grpcapi/grpcApi.go @@ -7,9 +7,9 @@ import ( "google.golang.org/grpc" "net" + acl "github.com/jamesread/OliveTin/internal/acl" config "github.com/jamesread/OliveTin/internal/config" executor "github.com/jamesread/OliveTin/internal/executor" - acl "github.com/jamesread/OliveTin/internal/acl" ) var ( diff --git a/internal/grpcapi/grpcApiButtons.go b/internal/grpcapi/grpcApiButtons.go index e9141a5..87e0fe0 100644 --- a/internal/grpcapi/grpcApiButtons.go +++ b/internal/grpcapi/grpcApiButtons.go @@ -1,14 +1,14 @@ package grpcapi import ( - "fmt" "crypto/md5" + "fmt" pb "github.com/jamesread/OliveTin/gen/grpc" - config "github.com/jamesread/OliveTin/internal/config" acl "github.com/jamesread/OliveTin/internal/acl" + config "github.com/jamesread/OliveTin/internal/config" ) -func buildButton(action config.ActionButton, user *acl.User) (*pb.ActionButton) { +func buildButton(action config.ActionButton, user *acl.User) *pb.ActionButton { btn := pb.ActionButton{ Id: fmt.Sprintf("%x", md5.Sum([]byte(action.Title))), Title: action.Title, @@ -17,12 +17,12 @@ func buildButton(action config.ActionButton, user *acl.User) (*pb.ActionButton) } for _, cfgArg := range action.Arguments { - pbArg := pb.ActionArgument { - Name: cfgArg.Name, - Label: cfgArg.Label, - Type: cfgArg.Type, + pbArg := pb.ActionArgument{ + Name: cfgArg.Name, + Label: cfgArg.Label, + Type: cfgArg.Type, DefaultValue: cfgArg.Default, - Choices: buildChoices(cfgArg.Choices), + Choices: buildChoices(cfgArg.Choices), } btn.Arguments = append(btn.Arguments, &pbArg) @@ -31,17 +31,17 @@ func buildButton(action config.ActionButton, user *acl.User) (*pb.ActionButton) return &btn } -func buildChoices(choices []config.ActionArgumentChoice) ([]*pb.ActionArgumentChoice) { +func buildChoices(choices []config.ActionArgumentChoice) []*pb.ActionArgumentChoice { ret := []*pb.ActionArgumentChoice{} for _, cfgChoice := range choices { - pbChoice := pb.ActionArgumentChoice { + pbChoice := pb.ActionArgumentChoice{ Value: cfgChoice.Value, Label: cfgChoice.Label, } - ret = append(ret, &pbChoice); + ret = append(ret, &pbChoice) } - return ret; + return ret } From 614c3b73fc520712faa0066580ead47c071c3512 Mon Sep 17 00:00:00 2001 From: jamesread Date: Mon, 18 Oct 2021 20:14:38 +0100 Subject: [PATCH 7/8] work on sanitizing configs --- cmd/OliveTin/main.go | 4 +--- internal/config/sanitize.go | 32 ++++++++++++++++++++++++++++++ internal/grpcapi/grpcApi.go | 15 -------------- internal/grpcapi/grpcApiButtons.go | 15 ++++++++++++++ 4 files changed, 48 insertions(+), 18 deletions(-) create mode 100644 internal/config/sanitize.go diff --git a/cmd/OliveTin/main.go b/cmd/OliveTin/main.go index e7c92fd..8fa80a7 100644 --- a/cmd/OliveTin/main.go +++ b/cmd/OliveTin/main.go @@ -62,9 +62,7 @@ func reloadConfig() { os.Exit(1) } - if logLevel, err := log.ParseLevel(cfg.LogLevel); err == nil { - log.SetLevel(logLevel) - } + config.Sanitize(cfg); } func main() { diff --git a/internal/config/sanitize.go b/internal/config/sanitize.go new file mode 100644 index 0000000..cb6c6db --- /dev/null +++ b/internal/config/sanitize.go @@ -0,0 +1,32 @@ +package config + +import ( + log "github.com/sirupsen/logrus" +) + +func Sanitize(cfg *Config) { + sanitizeLogLevel(cfg); + + for _, action := range cfg.ActionButtons { + sanitizeAction(action) + } +} + +func sanitizeLogLevel(cfg *Config) { + if logLevel, err := log.ParseLevel(cfg.LogLevel); err == nil { + log.Info("lvl", logLevel) + log.SetLevel(logLevel) + } +} + +func sanitizeAction(action ActionButton) { + for _, argument := range action.Arguments { + sanitizeActionArgument(argument) + } +} + +func sanitizeActionArgument(arg ActionArgument) { + log.Info("Sanitize AA") + arg.Label = "foo" + arg.Name = "blat" +} diff --git a/internal/grpcapi/grpcApi.go b/internal/grpcapi/grpcApi.go index 97b5d5c..ff78e24 100644 --- a/internal/grpcapi/grpcApi.go +++ b/internal/grpcapi/grpcApi.go @@ -56,21 +56,6 @@ func (api *oliveTinAPI) GetButtons(ctx ctx.Context, req *pb.GetButtonsRequest) ( return res, nil } -func actionButtonsCfgToPb(cfgActionButtons []config.ActionButton, user *acl.User) (*pb.GetButtonsResponse) { - res := &pb.GetButtonsResponse{} - - for _, action := range cfgActionButtons { - if !acl.IsAllowedView(cfg, user, &action) { - continue - } - - btn := buildButton(action, user) - res.Actions = append(res.Actions, btn) - } - - return res -} - func (api *oliveTinAPI) GetLogs(ctx ctx.Context, req *pb.GetLogsRequest) (*pb.GetLogsResponse, error) { ret := &pb.GetLogsResponse{} diff --git a/internal/grpcapi/grpcApiButtons.go b/internal/grpcapi/grpcApiButtons.go index 87e0fe0..0a3aba3 100644 --- a/internal/grpcapi/grpcApiButtons.go +++ b/internal/grpcapi/grpcApiButtons.go @@ -8,6 +8,21 @@ import ( config "github.com/jamesread/OliveTin/internal/config" ) +func actionButtonsCfgToPb(cfgActionButtons []config.ActionButton, user *acl.User) (*pb.GetButtonsResponse) { + res := &pb.GetButtonsResponse{} + + for _, action := range cfgActionButtons { + if !acl.IsAllowedView(cfg, user, &action) { + continue + } + + btn := buildButton(action, user) + res.Actions = append(res.Actions, btn) + } + + return res +} + func buildButton(action config.ActionButton, user *acl.User) *pb.ActionButton { btn := pb.ActionButton{ Id: fmt.Sprintf("%x", md5.Sum([]byte(action.Title))), From 30d681690aff923f62be2dc43e8efd3bde6606be Mon Sep 17 00:00:00 2001 From: jamesread Date: Tue, 2 Nov 2021 01:25:07 +0000 Subject: [PATCH 8/8] #6 - task arguments, that was a lot of work! --- Makefile | 2 +- OliveTin.proto | 51 ++- cmd/OliveTin/main.go | 7 +- internal/acl/acl.go | 4 +- internal/config/config.go | 21 +- internal/config/config_helpers.go | 11 + internal/{grpcapi => config}/emoji.go | 2 +- internal/{grpcapi => config}/emoji_test.go | 2 +- internal/config/sanitize.go | 44 ++- internal/executor/executor.go | 293 ++++++++++++++---- internal/grpcapi/grpcApi.go | 50 ++- .../{grpcApiButtons.go => grpcApiActions.go} | 18 +- internal/grpcapi/grpcApi_test.go | 10 +- internal/httpservers/webuiServer.go | 19 +- internal/updatecheck/updateCheck.go | 9 +- webui/index.html | 37 ++- webui/js/ActionButton.js | 45 ++- webui/js/ArgumentForm.js | 141 ++++++--- webui/js/marshaller.js | 23 +- webui/main.js | 15 +- webui/style.css | 230 +++++++------- 21 files changed, 736 insertions(+), 298 deletions(-) create mode 100644 internal/config/config_helpers.go rename internal/{grpcapi => config}/emoji.go (93%) rename internal/{grpcapi => config}/emoji_test.go (94%) rename internal/grpcapi/{grpcApiButtons.go => grpcApiActions.go} (70%) diff --git a/Makefile b/Makefile index 469c98f..f1437d1 100644 --- a/Makefile +++ b/Makefile @@ -14,7 +14,7 @@ daemon-compile: daemon-compile-armhf daemon-compile-x64-lin daemon-compile-x64-w daemon-codestyle: go fmt ./... go vet ./... - gocyclo -over 3 cmd internal + gocyclo -over 4 cmd internal daemon-unittests: mkdir -p reports diff --git a/OliveTin.proto b/OliveTin.proto index 91dab54..4eea920 100644 --- a/OliveTin.proto +++ b/OliveTin.proto @@ -4,7 +4,7 @@ option go_package = "gen/grpc"; import "google/api/annotations.proto"; -message ActionButton { +message Action { string id = 1; string title = 2; string icon = 3; @@ -15,7 +15,7 @@ message ActionButton { message ActionArgument { string name = 1; - string label = 2; + string title = 2; string type = 3; string defaultValue = 4; @@ -24,18 +24,32 @@ message ActionArgument { message ActionArgumentChoice { string value = 1; - string label = 2; + string title = 2; } -message GetButtonsResponse { +message Entity { string title = 1; - repeated ActionButton actions = 2; + string icon = 2; + repeated Action actions = 3; } -message GetButtonsRequest {} +message GetDashboardComponentsResponse { + string title = 1; + repeated Action actions = 2; + repeated Entity entities = 3; +} + +message GetDashboardComponentsRequest {} message StartActionRequest { string actionName = 1; + + repeated StartActionArgument arguments = 2; +} + +message StartActionArgument { + string name = 1; + string value = 2; } message StartActionResponse { @@ -53,22 +67,34 @@ message LogEntry { int32 exitCode = 6; string user = 7; string userClass = 8; + string actionIcon = 9; } message GetLogsResponse { repeated LogEntry logs = 1; } +message ValidateArgumentTypeRequest { + string value = 1; + string type = 2; +} + +message ValidateArgumentTypeResponse { + bool valid = 1; + string description = 2; +} + service OliveTinApi { - rpc GetButtons(GetButtonsRequest) returns (GetButtonsResponse) { + rpc GetDashboardComponents(GetDashboardComponentsRequest) returns (GetDashboardComponentsResponse) { option (google.api.http) = { - get: "/api/GetButtons" + get: "/api/GetDashboardComponents" }; } rpc StartAction(StartActionRequest) returns (StartActionResponse) { option (google.api.http) = { - get: "/api/StartAction" + post: "/api/StartAction" + body: "*" }; } @@ -77,4 +103,11 @@ service OliveTinApi { get: "/api/GetLogs" }; } + + rpc ValidateArgumentType(ValidateArgumentTypeRequest) returns (ValidateArgumentTypeResponse) { + option (google.api.http) = { + post: "/api/ValidateArgumentType" + body: "*" + }; + } } diff --git a/cmd/OliveTin/main.go b/cmd/OliveTin/main.go index 8fa80a7..4086dff 100644 --- a/cmd/OliveTin/main.go +++ b/cmd/OliveTin/main.go @@ -44,8 +44,6 @@ func init() { cfg = config.DefaultConfig() - reloadConfig() - viper.WatchConfig() viper.OnConfigChange(func(e fsnotify.Event) { if e.Op == fsnotify.Write { @@ -54,6 +52,9 @@ func init() { reloadConfig() } }) + + reloadConfig() + log.Info("Init complete") } func reloadConfig() { @@ -62,7 +63,7 @@ func reloadConfig() { os.Exit(1) } - config.Sanitize(cfg); + config.Sanitize(cfg) } func main() { diff --git a/internal/acl/acl.go b/internal/acl/acl.go index f01e909..fade75e 100644 --- a/internal/acl/acl.go +++ b/internal/acl/acl.go @@ -10,7 +10,7 @@ type User struct { Username string } -func IsAllowedExec(cfg *config.Config, user *User, action *config.ActionButton) bool { +func IsAllowedExec(cfg *config.Config, user *User, action *config.Action) bool { canExec := cfg.DefaultPermissions.Exec log.WithFields(log.Fields{ @@ -40,7 +40,7 @@ func IsAllowedExec(cfg *config.Config, user *User, action *config.ActionButton) return canExec } -func IsAllowedView(cfg *config.Config, user *User, action *config.ActionButton) bool { +func IsAllowedView(cfg *config.Config, user *User, action *config.Action) bool { canView := cfg.DefaultPermissions.View log.WithFields(log.Fields{ diff --git a/internal/config/config.go b/internal/config/config.go index 8898be1..6965544 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -2,8 +2,7 @@ package config import () -// ActionButton represents a button that is shown in the webui. -type ActionButton struct { +type Action struct { ID string Title string Icon string @@ -16,7 +15,7 @@ type ActionButton struct { type ActionArgument struct { Name string - Label string + Title string Type string Default string Choices []ActionArgumentChoice @@ -24,16 +23,16 @@ type ActionArgument struct { type ActionArgumentChoice struct { Value string - Label string + Title string } // Entity represents a "thing" that can have multiple actions associated with it. // for example, a media player with a start and stop action. type Entity struct { - Title string - Icon string - ActionButtons []ActionButton `mapstructure:"actions"` - CSS map[string]string + Title string + Icon string + Actions []Action `mapstructure:"actions"` + CSS map[string]string } type PermissionsEntry struct { @@ -63,9 +62,10 @@ type Config struct { ListenAddressGrpcActions string ExternalRestAddress string LogLevel string - ActionButtons []ActionButton `mapstructure:"actions"` - Entities []Entity `mapstructure:"entities"` + Actions []Action `mapstructure:"actions"` + Entities []Entity `mapstructure:"entities"` CheckForUpdates bool + ShowNewVersions bool Usergroups []UserGroup DefaultPermissions DefaultPermissions } @@ -81,6 +81,7 @@ func DefaultConfig() *Config { config.ListenAddressWebUI = "localhost:1340" config.LogLevel = "INFO" config.CheckForUpdates = true + config.ShowNewVersions = true config.DefaultPermissions.Exec = true config.DefaultPermissions.View = true diff --git a/internal/config/config_helpers.go b/internal/config/config_helpers.go new file mode 100644 index 0000000..581ce65 --- /dev/null +++ b/internal/config/config_helpers.go @@ -0,0 +1,11 @@ +package config + +func (cfg *Config) FindAction(actionTitle string) *Action { + for _, action := range cfg.Actions { + if action.Title == actionTitle { + return &action + } + } + + return nil +} diff --git a/internal/grpcapi/emoji.go b/internal/config/emoji.go similarity index 93% rename from internal/grpcapi/emoji.go rename to internal/config/emoji.go index c31b29f..ba33c4a 100644 --- a/internal/grpcapi/emoji.go +++ b/internal/config/emoji.go @@ -1,4 +1,4 @@ -package grpcapi +package config var emojis = map[string]string{ "poop": "💩", diff --git a/internal/grpcapi/emoji_test.go b/internal/config/emoji_test.go similarity index 94% rename from internal/grpcapi/emoji_test.go rename to internal/config/emoji_test.go index 2b5dafc..5737c1c 100644 --- a/internal/grpcapi/emoji_test.go +++ b/internal/config/emoji_test.go @@ -1,4 +1,4 @@ -package grpcapi +package config import ( "github.com/stretchr/testify/assert" diff --git a/internal/config/sanitize.go b/internal/config/sanitize.go index cb6c6db..adfa368 100644 --- a/internal/config/sanitize.go +++ b/internal/config/sanitize.go @@ -5,28 +5,50 @@ import ( ) func Sanitize(cfg *Config) { - sanitizeLogLevel(cfg); + sanitizeLogLevel(cfg) - for _, action := range cfg.ActionButtons { - sanitizeAction(action) + //log.Infof("cfg %p", cfg) + + for idx, _ := range cfg.Actions { + sanitizeAction(&cfg.Actions[idx]) } } func sanitizeLogLevel(cfg *Config) { if logLevel, err := log.ParseLevel(cfg.LogLevel); err == nil { - log.Info("lvl", logLevel) + log.Info("Setting log level to ", logLevel) log.SetLevel(logLevel) } } -func sanitizeAction(action ActionButton) { - for _, argument := range action.Arguments { - sanitizeActionArgument(argument) +func sanitizeAction(action *Action) { + if action.Timeout < 3 { + action.Timeout = 3 + } + + action.Icon = lookupHTMLIcon(action.Icon) + + for idx, _ := range action.Arguments { + sanitizeActionArgument(&action.Arguments[idx]) } } -func sanitizeActionArgument(arg ActionArgument) { - log.Info("Sanitize AA") - arg.Label = "foo" - arg.Name = "blat" +func sanitizeActionArgument(arg *ActionArgument) { + if arg.Title == "" { + arg.Title = arg.Name + } + + sanitizeActionArgumentNoType(arg) + + // TODO Validate the default against the type checker, but this creates a + // import loop +} + +func sanitizeActionArgumentNoType(arg *ActionArgument) { + if len(arg.Choices) == 0 && arg.Type == "" { + log.WithFields(log.Fields{ + "arg": arg.Name, + }).Warn("Argument type isn't set, will default to 'ascii' but this may not be safe. You should set a type specifically.") + arg.Type = "ascii" + } } diff --git a/internal/executor/executor.go b/internal/executor/executor.go index e8b0b2c..25954ee 100644 --- a/internal/executor/executor.go +++ b/internal/executor/executor.go @@ -9,99 +9,286 @@ import ( "context" "errors" "os/exec" + "regexp" + "strings" "time" ) +var ( + typecheckRegex = map[string]string{ + "very_dangerous_raw_string": "", + "int": "^[\\d]+$", + "ascii": "^[a-zA-Z0-9]+$", + "ascii_identifier": "^[a-zA-Z0-9\\-\\.\\_]+$", + "ascii_sentence": "^[a-zA-Z0-9 \\,\\.]+$", + } +) + type InternalLogEntry struct { - Datetime string - Content string - Stdout string - Stderr string - TimedOut bool - ExitCode int32 + Datetime string + Stdout string + Stderr string + TimedOut bool + ExitCode int32 + + /* + The following two properties are obviously on Action normally, but it's useful + that logs are lightweight (so we don't need to have an action associated to + logs, etc. Therefore, we duplicate those values here. + */ ActionTitle string + ActionIcon string +} + +type ExecutionRequest struct { + ActionName string + Arguments map[string]string + action *config.Action + Cfg *config.Config + User *acl.User + logEntry *InternalLogEntry + finalParsedCommand string +} + +type ExecutorStep interface { + Exec(*ExecutionRequest) bool } type Executor struct { Logs []InternalLogEntry + + chainOfCommand []ExecutorStep } -// ExecAction executes an action. -func (e *Executor) ExecAction(cfg *config.Config, user *acl.User, actualAction *config.ActionButton) *pb.StartActionResponse { - log.WithFields(log.Fields{ - "actionName": actualAction.Title, - }).Infof("StartAction") +func DefaultExecutor() *Executor { + e := Executor{} + e.chainOfCommand = []ExecutorStep{ + StepFindAction{}, + StepAclCheck{}, + StepParseArgs{}, + StepLogStart{}, + StepExec{}, + StepLogFinish{}, + } - res := execAction(cfg, actualAction) + return &e +} - e.Logs = append(e.Logs, *res) +type StepFindAction struct{} + +func (s StepFindAction) Exec(req *ExecutionRequest) bool { + actualAction := req.Cfg.FindAction(req.ActionName) + + if actualAction == nil { + log.WithFields(log.Fields{ + "actionName": req.ActionName, + }).Warnf("Action not found") + + req.logEntry.Stderr = "Action not found" + req.logEntry.ExitCode = -1337 + + return false + } + + req.action = actualAction + req.logEntry.ActionIcon = actualAction.Icon + + return true +} + +type StepAclCheck struct{} + +func (s StepAclCheck) Exec(req *ExecutionRequest) bool { + return acl.IsAllowedExec(req.Cfg, req.User, req.action) +} + +// ExecRequest processes an ExecutionRequest +func (e *Executor) ExecRequest(req *ExecutionRequest) *pb.StartActionResponse { + req.logEntry = &InternalLogEntry{ + Datetime: time.Now().Format("2006-01-02 15:04:05"), + ActionTitle: req.ActionName, + } + + for _, step := range e.chainOfCommand { + if !step.Exec(req) { + break + } + } + + e.Logs = append(e.Logs, *req.logEntry) return &pb.StartActionResponse{ LogEntry: &pb.LogEntry{ - ActionTitle: actualAction.Title, - TimedOut: res.TimedOut, - Stderr: res.Stderr, - Stdout: res.Stdout, - ExitCode: res.ExitCode, + ActionTitle: req.logEntry.ActionTitle, + ActionIcon: req.logEntry.ActionIcon, + Datetime: req.logEntry.Datetime, + Stderr: req.logEntry.Stderr, + Stdout: req.logEntry.Stdout, + TimedOut: req.logEntry.TimedOut, + ExitCode: req.logEntry.ExitCode, }, } } -func execAction(cfg *config.Config, actualAction *config.ActionButton) *InternalLogEntry { - res := &InternalLogEntry{ - Datetime: time.Now().Format("2006-01-02 15:04:05"), - TimedOut: false, - ActionTitle: actualAction.Title, +type StepLogStart struct{} + +func (e StepLogStart) Exec(req *ExecutionRequest) bool { + log.WithFields(log.Fields{ + "title": req.action.Title, + "timeout": req.action.Timeout, + }).Infof("Action starting") + + return true +} + +type StepLogFinish struct{} + +func (e StepLogFinish) Exec(req *ExecutionRequest) bool { + log.WithFields(log.Fields{ + "title": req.action.Title, + "stdout": req.logEntry.Stdout, + "stderr": req.logEntry.Stderr, + "timedOut": req.logEntry.TimedOut, + "exit": req.logEntry.ExitCode, + }).Infof("Action finished") + + return true +} + +type StepParseArgs struct{} + +func (e StepParseArgs) Exec(req *ExecutionRequest) bool { + var err error + + req.finalParsedCommand, err = parseActionArguments(req.action.Shell, req.Arguments, req.action) + + if err != nil { + req.logEntry.ExitCode = -1337 + req.logEntry.Stderr = "" + req.logEntry.Stdout = err.Error() + + log.Warnf(err.Error()) + + return false } - log.WithFields(log.Fields{ - "title": actualAction.Title, - "timeout": actualAction.Timeout, - }).Infof("Found action") + return true +} - ctx, cancel := context.WithTimeout(context.Background(), time.Duration(actualAction.Timeout)*time.Second) +type StepExec struct{} + +func (e StepExec) Exec(req *ExecutionRequest) bool { + ctx, cancel := context.WithTimeout(context.Background(), time.Duration(req.action.Timeout)*time.Second) defer cancel() - cmd := exec.CommandContext(ctx, "sh", "-c", actualAction.Shell) + cmd := exec.CommandContext(ctx, "sh", "-c", req.finalParsedCommand) stdout, stderr := cmd.Output() - res.ExitCode = int32(cmd.ProcessState.ExitCode()) - res.Stdout = string(stdout) - - if stderr == nil { - res.Stderr = "" - } else { - res.Stderr = stderr.Error() + if stderr != nil { + req.logEntry.Stderr = stderr.Error() } if ctx.Err() == context.DeadlineExceeded { - res.TimedOut = true + req.logEntry.TimedOut = true + } + + req.logEntry.ExitCode = int32(cmd.ProcessState.ExitCode()) + req.logEntry.Stdout = string(stdout) + + return true +} + +func parseActionArguments(rawShellCommand string, values map[string]string, action *config.Action) (string, error) { + log.WithFields(log.Fields{ + "cmd": rawShellCommand, + }).Infof("Before Parse Args") + + r := regexp.MustCompile("{{ *?([a-z]+?) *?}}") + matches := r.FindAllStringSubmatch(rawShellCommand, -1) + + for _, match := range matches { + argValue, argProvided := values[match[1]] + + if !argProvided { + log.Infof("%v", values) + return "", errors.New("Required arg not provided: " + match[1]) + } + + err := typecheckActionArgument(match[1], argValue, action) + + if err != nil { + return "", err + } + + log.WithFields(log.Fields{ + "name": match[1], + "value": argValue, + }).Debugf("Arg assigned") + + rawShellCommand = strings.Replace(rawShellCommand, match[0], argValue, -1) } log.WithFields(log.Fields{ - "stdout": res.Stdout, - "stderr": res.Stderr, - "timedOut": res.TimedOut, - "exit": res.ExitCode, - }).Infof("Finished command.") + "cmd": rawShellCommand, + }).Infof("After Parse Args") - return res + return rawShellCommand, nil } -func sanitizeAction(action *config.ActionButton) { - if action.Timeout < 3 { - action.Timeout = 3 +func typecheckActionArgument(name string, value string, action *config.Action) error { + arg := findArg(name, action) + + if arg == nil { + return errors.New("Action arg not defined: " + name) } + + if len(arg.Choices) > 0 { + return typecheckChoice(value, arg) + } + + return TypeSafetyCheck(name, value, arg.Type) } -func FindAction(cfg *config.Config, actionTitle string) (*config.ActionButton, error) { - for _, action := range cfg.ActionButtons { - if action.Title == actionTitle { - sanitizeAction(&action) - - return &action, nil +func typecheckChoice(value string, arg *config.ActionArgument) error { + for _, choice := range arg.Choices { + if value == choice.Value { + return nil } } - return nil, errors.New("Action not found") + return errors.New("Arg value is not one of the predefined choices") +} + +func TypeSafetyCheck(name string, value string, typ string) error { + pattern, found := typecheckRegex[typ] + + log.Infof("%v %v", pattern, typ) + + if !found { + return errors.New("Arg type not implemented " + typ) + } + + matches, _ := regexp.MatchString(pattern, value) + + if !matches { + log.WithFields(log.Fields{ + "name": name, + "type": typ, + "value": value, + }).Warn("Arg type check safety failure") + + return errors.New("Invalid argument, doesn't match " + typ) + } + + return nil +} + +func findArg(name string, action *config.Action) *config.ActionArgument { + for _, arg := range action.Arguments { + if arg.Name == name { + return &arg + } + } + + return nil } diff --git a/internal/grpcapi/grpcApi.go b/internal/grpcapi/grpcApi.go index ff78e24..c037dcb 100644 --- a/internal/grpcapi/grpcApi.go +++ b/internal/grpcapi/grpcApi.go @@ -14,7 +14,7 @@ import ( var ( cfg *config.Config - ex = executor.Executor{} + ex = executor.DefaultExecutor() ) type oliveTinAPI struct { @@ -22,36 +22,34 @@ type oliveTinAPI struct { } func (api *oliveTinAPI) StartAction(ctx ctx.Context, req *pb.StartActionRequest) (*pb.StartActionResponse, error) { - actualAction, err := executor.FindAction(cfg, req.ActionName) + args := make(map[string]string) - if err != nil { - log.Errorf("Error finding action %s, %s", err, req.ActionName) + log.Debugf("SA %v", req) - return &pb.StartActionResponse{ - LogEntry: nil, - }, nil + for _, arg := range req.Arguments { + args[arg.Name] = arg.Value } - user := acl.UserFromContext(ctx) - - if !acl.IsAllowedExec(cfg, user, actualAction) { - return &pb.StartActionResponse{}, nil - + execReq := executor.ExecutionRequest{ + ActionName: req.ActionName, + Arguments: args, + User: acl.UserFromContext(ctx), + Cfg: cfg, } - return ex.ExecAction(cfg, acl.UserFromContext(ctx), actualAction), nil + return ex.ExecRequest(&execReq), nil } -func (api *oliveTinAPI) GetButtons(ctx ctx.Context, req *pb.GetButtonsRequest) (*pb.GetButtonsResponse, error) { +func (api *oliveTinAPI) GetDashboardComponents(ctx ctx.Context, req *pb.GetDashboardComponentsRequest) (*pb.GetDashboardComponentsResponse, error) { user := acl.UserFromContext(ctx) - res := actionButtonsCfgToPb(cfg.ActionButtons, user) + res := actionsCfgToPb(cfg.Actions, user) if len(res.Actions) == 0 { log.Warn("Zero actions found - check that you have some actions defined, with a view permission") } - log.Debugf("getButtons: %v", res) + log.Debugf("GetDashboardComponents: %v", res) return res, nil } @@ -64,6 +62,7 @@ func (api *oliveTinAPI) GetLogs(ctx ctx.Context, req *pb.GetLogsRequest) (*pb.Ge for _, logEntry := range ex.Logs { ret.Logs = append(ret.Logs, &pb.LogEntry{ ActionTitle: logEntry.ActionTitle, + ActionIcon: logEntry.ActionIcon, Datetime: logEntry.Datetime, Stdout: logEntry.Stdout, Stderr: logEntry.Stderr, @@ -75,6 +74,25 @@ func (api *oliveTinAPI) GetLogs(ctx ctx.Context, req *pb.GetLogsRequest) (*pb.Ge return ret, nil } +/* +This function is ONLY a helper for the UI - the arguments are validated properly +on the StartAction -> Executor chain. This is here basically to provide helpful +error messages more quickly before starting the action. +*/ +func (api *oliveTinAPI) ValidateArgumentType(ctx ctx.Context, req *pb.ValidateArgumentTypeRequest) (*pb.ValidateArgumentTypeResponse, error) { + err := executor.TypeSafetyCheck("", req.Value, req.Type) + desc := "" + + if err != nil { + desc = err.Error() + } + + return &pb.ValidateArgumentTypeResponse{ + Valid: err == nil, + Description: desc, + }, nil +} + // Start will start the GRPC API. func Start(globalConfig *config.Config) { cfg = globalConfig diff --git a/internal/grpcapi/grpcApiButtons.go b/internal/grpcapi/grpcApiActions.go similarity index 70% rename from internal/grpcapi/grpcApiButtons.go rename to internal/grpcapi/grpcApiActions.go index 0a3aba3..d534182 100644 --- a/internal/grpcapi/grpcApiButtons.go +++ b/internal/grpcapi/grpcApiActions.go @@ -8,33 +8,33 @@ import ( config "github.com/jamesread/OliveTin/internal/config" ) -func actionButtonsCfgToPb(cfgActionButtons []config.ActionButton, user *acl.User) (*pb.GetButtonsResponse) { - res := &pb.GetButtonsResponse{} +func actionsCfgToPb(cfgActions []config.Action, user *acl.User) *pb.GetDashboardComponentsResponse { + res := &pb.GetDashboardComponentsResponse{} - for _, action := range cfgActionButtons { + for _, action := range cfgActions { if !acl.IsAllowedView(cfg, user, &action) { continue } - btn := buildButton(action, user) + btn := actionCfgToPb(action, user) res.Actions = append(res.Actions, btn) } return res } -func buildButton(action config.ActionButton, user *acl.User) *pb.ActionButton { - btn := pb.ActionButton{ +func actionCfgToPb(action config.Action, user *acl.User) *pb.Action { + btn := pb.Action{ Id: fmt.Sprintf("%x", md5.Sum([]byte(action.Title))), Title: action.Title, - Icon: lookupHTMLIcon(action.Icon), + Icon: action.Icon, CanExec: acl.IsAllowedExec(cfg, user, &action), } for _, cfgArg := range action.Arguments { pbArg := pb.ActionArgument{ Name: cfgArg.Name, - Label: cfgArg.Label, + Title: cfgArg.Title, Type: cfgArg.Type, DefaultValue: cfgArg.Default, Choices: buildChoices(cfgArg.Choices), @@ -52,7 +52,7 @@ func buildChoices(choices []config.ActionArgumentChoice) []*pb.ActionArgumentCho for _, cfgChoice := range choices { pbChoice := pb.ActionArgumentChoice{ Value: cfgChoice.Value, - Label: cfgChoice.Label, + Title: cfgChoice.Title, } ret = append(ret, &pbChoice) diff --git a/internal/grpcapi/grpcApi_test.go b/internal/grpcapi/grpcApi_test.go index fcf0c43..2024ae3 100644 --- a/internal/grpcapi/grpcApi_test.go +++ b/internal/grpcapi/grpcApi_test.go @@ -52,19 +52,19 @@ func getNewTestServerAndClient(t *testing.T, injectedConfig *config.Config) (*gr return conn, client } -func TestGetButtonsAndStart(t *testing.T) { +func TestGetActionsAndStart(t *testing.T) { cfg = config.DefaultConfig() - btn1 := config.ActionButton{} + btn1 := config.Action{} btn1.Title = "blat" btn1.Shell = "echo 'test'" - cfg.ActionButtons = append(cfg.ActionButtons, btn1) + cfg.Actions = append(cfg.Actions, btn1) conn, client := getNewTestServerAndClient(t, cfg) - respGb, err := client.GetButtons(context.Background(), &pb.GetButtonsRequest{}) + respGb, err := client.GetDashboardComponents(context.Background(), &pb.GetDashboardComponentsRequest{}) if err != nil { - t.Errorf("GetButtons: %v", err) + t.Errorf("GetDashboardComponentsRequest: %v", err) } assert.Equal(t, true, true, "sayHello Failed") diff --git a/internal/httpservers/webuiServer.go b/internal/httpservers/webuiServer.go index 4824660..f6a2985 100644 --- a/internal/httpservers/webuiServer.go +++ b/internal/httpservers/webuiServer.go @@ -8,12 +8,16 @@ import ( "os" config "github.com/jamesread/OliveTin/internal/config" + updatecheck "github.com/jamesread/OliveTin/internal/updatecheck" ) type webUISettings struct { - Rest string - ThemeName string - HideNavigation bool + Rest string + ThemeName string + HideNavigation bool + AvailableVersion string + CurrentVersion string + ShowNewVersions bool } func findWebuiDir() string { @@ -43,9 +47,12 @@ func generateWebUISettings(w http.ResponseWriter, r *http.Request) { } jsonRet, _ := json.Marshal(webUISettings{ - Rest: restAddress + "/api/", - ThemeName: cfg.ThemeName, - HideNavigation: cfg.HideNavigation, + Rest: restAddress + "/api/", + ThemeName: cfg.ThemeName, + HideNavigation: cfg.HideNavigation, + AvailableVersion: updatecheck.AvailableVersion, + CurrentVersion: updatecheck.CurrentVersion, + ShowNewVersions: cfg.ShowNewVersions, }) w.Write([]byte(jsonRet)) diff --git a/internal/updatecheck/updateCheck.go b/internal/updatecheck/updateCheck.go index f4915ed..ef3c443 100644 --- a/internal/updatecheck/updateCheck.go +++ b/internal/updatecheck/updateCheck.go @@ -21,6 +21,9 @@ type updateRequest struct { MachineID string } +var AvailableVersion = "none" +var CurrentVersion = "?" + func machineID() string { v, err := machineid.ProtectedID("OliveTin") @@ -40,6 +43,8 @@ func StartUpdateChecker(currentVersion string, currentCommit string, cfg *config return } + CurrentVersion = currentVersion + payload := updateRequest{ CurrentVersion: currentVersion, CurrentCommit: currentCommit, @@ -89,9 +94,9 @@ func actualCheckForUpdate(payload updateRequest) { return } - newVersion := doRequest(jsonUpdateRequest) + AvailableVersion = doRequest(jsonUpdateRequest) log.WithFields(log.Fields{ - "NewVersion": newVersion, + "NewVersion": AvailableVersion, }).Infof("Update check complete") } diff --git a/webui/index.html b/webui/index.html index fab9ab6..20aee26 100644 --- a/webui/index.html +++ b/webui/index.html @@ -23,6 +23,7 @@ Timestamp Log + Exit Code @@ -38,28 +39,56 @@

OliveTin

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

+

+ + diff --git a/webui/js/ActionButton.js b/webui/js/ActionButton.js index b141ec1..ef1802e 100644 --- a/webui/js/ActionButton.js +++ b/webui/js/ActionButton.js @@ -1,5 +1,5 @@ -import { marshalLogsJsonToHtml } from './marshaller.js'; -import "./ArgumentForm.js" +import { marshalLogsJsonToHtml } from './marshaller.js' +import './ArgumentForm.js' class ActionButton extends window.HTMLButtonElement { constructFromJson (json) { @@ -8,20 +8,20 @@ class ActionButton extends window.HTMLButtonElement { this.title = json.title this.temporaryStatusMessage = null this.isWaiting = false - this.actionCallUrl = window.restBaseUrl + 'StartAction?actionName=' + this.title + this.actionCallUrl = window.restBaseUrl + 'StartAction' this.updateFromJson(json) - this.onclick = () => { + this.onclick = () => { if (json.arguments.length > 0) { - let frm = document.createElement('form', { is: 'argument-form' }) - window.frm = frm - console.log(frm) - frm.setup(json, this.startAction) + const frm = document.createElement('form', { is: 'argument-form' }) + frm.setup(json, (args) => { + this.startAction(args) + }) document.body.appendChild(frm) } else { - this.startAction() + this.startAction() } } @@ -46,18 +46,41 @@ class ActionButton extends window.HTMLButtonElement { } } - startAction () { + startAction (actionArgs) { this.disabled = true this.isWaiting = true this.updateHtml() this.classList = [] // Removes old animation classes - window.fetch(this.actionCallUrl).then(res => res.json() + if (actionArgs === undefined) { + actionArgs = [] + } + + const startActionArgs = { + actionName: this.title, + arguments: actionArgs + } + + window.fetch(this.actionCallUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify(startActionArgs) + }).then((res) => { + if (res.ok) { + return res.json() + } else { + throw new Error(res.statusText) + } + } ).then((json) => { marshalLogsJsonToHtml({ logs: [json.logEntry] }) if (json.logEntry.timedOut) { this.onActionResult('actionTimeout', 'Timed out') + } else if (json.logEntry.exitCode === -1337) { + this.onActionError('Error') } else if (json.logEntry.exitCode !== 0) { this.onActionResult('actionNonZeroExit', 'Exit code ' + json.logEntry.exitCode) } else { diff --git a/webui/js/ArgumentForm.js b/webui/js/ArgumentForm.js index bf0f706..84b3468 100644 --- a/webui/js/ArgumentForm.js +++ b/webui/js/ArgumentForm.js @@ -1,85 +1,140 @@ class ArgumentForm extends window.HTMLFormElement { - setup(json, callback) { + setup (json, callback) { this.setAttribute('class', 'actionArguments') - console.log(json) - - this.domWrapper = document.createElement('div') - this.domWrapper.classList += 'wrapper' - this.appendChild(this.domWrapper) - - this.domTitle = document.createElement('h2') - this.domTitle.innerText = json.title + ": Arguments" - this.domWrapper.appendChild(this.domTitle); - - this.domIcon = document.createElement('span'); - this.domIcon.classList += 'icon' - this.domIcon.setAttribute('role', 'img') + this.constructTemplate() + this.domTitle.innerText = json.title this.domIcon.innerHTML = json.icon - this.domTitle.prepend(this.domIcon) - - let a = document.createElement("span") - a.innerText = "This is test version of the form." - this.domWrapper.appendChild(a) - this.createDomFormArguments(json.arguments) - this.domWrapper.appendChild(this.createDomSubmit()) - console.log(json) + this.domBtnStart.onclick = () => { + for (const arg of this.argInputs) { + if (!arg.validity.valid) { + return + } + } + + const argvs = this.getArgumentValues() + + callback(argvs) + + this.remove() + } + + this.domBtnCancel.onclick = () => { + this.remove() + } } - createDomSubmit() { - let el = document.createElement('button') - el.setAttribute('action', 'submit') - el.innerText = "Run" + getArgumentValues () { + const ret = [] - return el + for (const arg of this.argInputs) { + ret.push({ + name: arg.name, + value: arg.value + }) + } + + return ret } - createDomFormArguments(args) { - for (let arg of args) { - let domFieldWrapper = document.createElement('p'); + constructTemplate () { + const tpl = document.getElementById('tplArgumentForm') + const content = tpl.content.cloneNode(true) + + this.appendChild(content) + + this.domTitle = this.querySelector('h2') + this.domIcon = this.querySelector('span.icon') + this.domWrapper = this.querySelector('.wrapper') + + this.domArgs = this.querySelector('.arguments') + + this.domBtnStart = this.querySelector('[name=start]') + this.domBtnCancel = this.querySelector('[name=cancel]') + } + + createDomFormArguments (args) { + this.argInputs = [] + + for (const arg of args) { + const domFieldWrapper = document.createElement('p') domFieldWrapper.appendChild(this.createDomLabel(arg)) domFieldWrapper.appendChild(this.createDomInput(arg)) - this.domWrapper.appendChild(domFieldWrapper) + this.domArgs.appendChild(domFieldWrapper) } } - createDomLabel(arg) { - let domLbl = document.createElement('label') - domLbl.innerText = arg.label + ':'; + createDomLabel (arg) { + const domLbl = document.createElement('label') + domLbl.innerText = arg.title + ':' domLbl.setAttribute('for', arg.name) - return domLbl; + return domLbl } - createDomInput(arg) { - let domEl = null; + createDomInput (arg) { + let domEl = null if (arg.choices.length > 0) { domEl = document.createElement('select') - for (let choice of arg.choices) { + // select/choice elements don't get an onchange/validation because theoretically + // the user should only select from a dropdown of valid options. The choices are + // riggeriously checked on StartAction anyway. ValidateArgumentType is only + // meant for showing simple warnings in the UI before running. + + for (const choice of arg.choices) { domEl.appendChild(this.createSelectOption(choice)) } } else { domEl = document.createElement('input') + domEl.onchange = () => { + const validateArgumentTypeArgs = { + value: domEl.value, + type: arg.type + } + + window.fetch(window.restBaseUrl + 'ValidateArgumentType', { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify(validateArgumentTypeArgs) + }).then((res) => { + if (res.ok) { + return res.json() + } else { + throw new Error(res.statusText) + } + }).then((json) => { + console.log(json.valid) + if (json.valid) { + domEl.setCustomValidity('') + } else { + domEl.setCustomValidity(json.description) + } + }) + } } - domEl.setAttribute('id', arg.name) + domEl.name = arg.name domEl.value = arg.defaultValue - return domEl; + this.argInputs.push(domEl) + + return domEl } - createSelectOption(choice) { - let domEl = document.createElement('option') + createSelectOption (choice) { + const domEl = document.createElement('option') domEl.setAttribute('value', choice.value) - domEl.innerText = choice.label + domEl.innerText = choice.title return domEl } diff --git a/webui/js/marshaller.js b/webui/js/marshaller.js index 87eccbf..7f58fd9 100644 --- a/webui/js/marshaller.js +++ b/webui/js/marshaller.js @@ -31,9 +31,30 @@ export function marshalLogsJsonToHtml (json) { const tpl = document.getElementById('tplLogRow') const row = tpl.content.cloneNode(true) + if (logEntry.stdout.length === 0) { + logEntry.stdout = '(empty)' + } + + if (logEntry.stderr.length === 0) { + logEntry.stderr = '(empty)' + } + + let logTableExitCode = logEntry.exitCode + + if (logEntry.exitCode === 0) { + logTableExitCode = 'OK' + } + + if (logEntry.timedOut) { + logTableExitCode += ' (timed out)' + } + row.querySelector('.timestamp').innerText = logEntry.datetime row.querySelector('.content').innerText = logEntry.actionTitle - row.querySelector('pre').innerText = logEntry.stdout + row.querySelector('.icon').innerHTML = logEntry.actionIcon + row.querySelector('pre.stdout').innerText = logEntry.stdout + row.querySelector('pre.stderr').innerText = logEntry.stderr + row.querySelector('.exitCode').innerText = logTableExitCode document.querySelector('#logTableBody').prepend(row) } diff --git a/webui/main.js b/webui/main.js index 7b39b16..982485d 100644 --- a/webui/main.js +++ b/webui/main.js @@ -31,8 +31,8 @@ function setupSections () { showSection('Actions') } -function fetchGetButtons () { - window.fetch(window.restBaseUrl + 'GetButtons', { +function fetchGetDashboardComponents () { + window.fetch(window.restBaseUrl + 'GetDashboardComponents', { cors: 'cors' }).then(res => { return res.json() @@ -67,6 +67,13 @@ function processWebuiSettingsJson (settings) { document.head.appendChild(themeCss) } + document.querySelector('#currentVersion').innerText = 'Version: ' + settings.CurrentVersion + + if (settings.ShowNewVersions && settings.AvailableVersion !== 'none') { + document.querySelector('#availableVersion').innerText = 'New Version Available: ' + settings.AvailableVersion + document.querySelector('#availableVersion').hidden = false + } + document.querySelector('#switcher').hidden = settings.HideNavigation } @@ -77,10 +84,10 @@ window.fetch('webUiSettings.json').then(res => { }).then(res => { processWebuiSettingsJson(res) - fetchGetButtons() + fetchGetDashboardComponents() fetchGetLogs() - window.buttonInterval = setInterval(fetchGetButtons, 3000) + window.buttonInterval = setInterval(fetchGetDashboardComponents, 3000) }).catch(err => { showBigError('fetch-webui-settings', 'getting webui settings', err) }) diff --git a/webui/style.css b/webui/style.css index cc0e855..0c40f33 100644 --- a/webui/style.css +++ b/webui/style.css @@ -52,6 +52,14 @@ legend { span[role="icon"] { display: block; + font-size: 3em; + vertical-align: middle; +} + +form span[role="icon"], +tr.logRow span[role="icon"] { + display: inline-block; + padding-right: 0.2em; } .error { @@ -73,11 +81,17 @@ div.entity { grid-template-columns: minmax(min-content, auto); } +h2 { + font-size: 1em; + display: inline-block; +} + div.entity h2 { grid-column: 1 / span all; } -button { +button, +input[type="submit"] { padding: 1em; color: black; display: table-cell; @@ -88,16 +102,19 @@ button { user-select: none; } -button:hover { +button:hover, +input[type="submit"]:hover { box-shadow: 0 0 10px 0 #666; cursor: pointer; } -button:focus { +button:focus, +input[type="submit"]:focus { outline: 1px solid black; } -button:disabled { +button:disabled, +input[type="submit"]:disabled { color: gray; background-color: #333; cursor: not-allowed; @@ -117,10 +134,6 @@ fieldset#switcher button:last-child { border-radius: 0 1em 1em 0; } -span.icon { - font-size: 3em; -} - .actionFailed { animation: kfActionFailed 1s; } @@ -180,13 +193,115 @@ img.logo { } } +main { + padding: 1em; +} + +summary { + cursor: pointer; +} + +details { + display: inline-block; +} + +details[open] { + margin-top: 1em; + display: block; +} + +form.actionArguments { + position: absolute; + top: 0; + bottom: 0; + left: 0; + right: 0; + padding: 1em; + box-shadow: 0 0 6px 0 #aaa; + background-color: #dee3e7; +} + +form div.wrapper { + border-radius: 1em; + box-shadow: 0 0 10px 0 #444; + background-color: white; + border: 1px solid #999; + text-align: left; + padding: 1em; +} + +label { + width: 20%; + text-align: right; + display: inline-block; + padding-right: 1em; +} + +input { + padding: 0.6em; +} + +input:invalid { + outline: 2px solid red; +} + +form .wrapper span.icon { + display: inline-block; + vertical-align: middle; +} + +form input[type="submit"]:first-child { + margin-right: 1em; +} + +button[name=cancel]:hover { + background-color: salmon; +} + +input[name=start]:hover { + background-color: #aceaac; +} + +form div.buttons { + text-align: right; +} + +pre { + border: 1px solid gray; + padding: 1em; + min-height: 1em; +} + +td.exitCode { + text-align: center; +} + +input.invalid { + background-color: salmon; +} + +#availableVersion { + background-color: #aceaac; + padding: 0.2em; + border-radius: 1em; +} + @media (prefers-color-scheme: dark) { body { background-color: #333; color: white; } - button { + form.actionArguments { + background-color: #333; + } + + form div.wrapper { + background-color: #222; + } + + button, + input[type="submit"] { border: 1px solid #666; background-color: #222; box-shadow: 0 0 6px 0 #444; @@ -218,100 +333,3 @@ img.logo { color: gray; } } - -main { - padding: 1em; -} - -summary { - cursor: pointer; -} - -details { - display: inline-block; -} - -details[open] { - margin-top: 1em; - display: block; -} - -form.actionArguments { - position: absolute; - top: 0; - bottom: 0; - left: 0; - right: 0; - padding: 1em; - box-shadow: 0 0 6px 0 #aaa; - background-color: #dee3e7; -} - -h2 { - font-size: 1em; -} - -h2 span.icon { - vertical-align: middle; - padding-right: .2em; -} - -form div.wrapper { - border-radius: 1em; - box-shadow: 0 0 10px 0 #444; - background-color: white; - border: 1px solid #999; - text-align: left; - padding: 1em; -} - -label { - width: 30%; - text-align: right; - display: inline-block; - padding-right: 1em; -} - -input { - padding: .6em; -} - -form.actionArguments { - position: absolute; - top: 0; - bottom: 0; - left: 0; - right: 0; - padding: 1em; - box-shadow: 0 0 6px 0 #aaa; - background-color: #dee3e7; -} - -h2 { - font-size: 1em; -} - -h2 span.icon { - vertical-align: middle; - padding-right: .2em; -} - -form div.wrapper { - border-radius: 1em; - box-shadow: 0 0 10px 0 #444; - background-color: white; - border: 1px solid #999; - text-align: left; - padding: 1em; -} - -label { - width: 30%; - text-align: right; - display: inline-block; - padding-right: 1em; -} - -input { - padding: .6em; -}