mirror of
https://github.com/OliveTin/OliveTin
synced 2025-12-12 09:05:39 +00:00
#6 - task arguments, that was a lot of work!
This commit is contained in:
2
Makefile
2
Makefile
@@ -14,7 +14,7 @@ daemon-compile: daemon-compile-armhf daemon-compile-x64-lin daemon-compile-x64-w
|
|||||||
daemon-codestyle:
|
daemon-codestyle:
|
||||||
go fmt ./...
|
go fmt ./...
|
||||||
go vet ./...
|
go vet ./...
|
||||||
gocyclo -over 3 cmd internal
|
gocyclo -over 4 cmd internal
|
||||||
|
|
||||||
daemon-unittests:
|
daemon-unittests:
|
||||||
mkdir -p reports
|
mkdir -p reports
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ option go_package = "gen/grpc";
|
|||||||
|
|
||||||
import "google/api/annotations.proto";
|
import "google/api/annotations.proto";
|
||||||
|
|
||||||
message ActionButton {
|
message Action {
|
||||||
string id = 1;
|
string id = 1;
|
||||||
string title = 2;
|
string title = 2;
|
||||||
string icon = 3;
|
string icon = 3;
|
||||||
@@ -15,7 +15,7 @@ message ActionButton {
|
|||||||
|
|
||||||
message ActionArgument {
|
message ActionArgument {
|
||||||
string name = 1;
|
string name = 1;
|
||||||
string label = 2;
|
string title = 2;
|
||||||
string type = 3;
|
string type = 3;
|
||||||
string defaultValue = 4;
|
string defaultValue = 4;
|
||||||
|
|
||||||
@@ -24,18 +24,32 @@ message ActionArgument {
|
|||||||
|
|
||||||
message ActionArgumentChoice {
|
message ActionArgumentChoice {
|
||||||
string value = 1;
|
string value = 1;
|
||||||
string label = 2;
|
string title = 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
message GetButtonsResponse {
|
message Entity {
|
||||||
string title = 1;
|
string title = 1;
|
||||||
repeated ActionButton actions = 2;
|
string icon = 2;
|
||||||
|
repeated Action actions = 3;
|
||||||
}
|
}
|
||||||
|
|
||||||
message GetButtonsRequest {}
|
message GetDashboardComponentsResponse {
|
||||||
|
string title = 1;
|
||||||
|
repeated Action actions = 2;
|
||||||
|
repeated Entity entities = 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
message GetDashboardComponentsRequest {}
|
||||||
|
|
||||||
message StartActionRequest {
|
message StartActionRequest {
|
||||||
string actionName = 1;
|
string actionName = 1;
|
||||||
|
|
||||||
|
repeated StartActionArgument arguments = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
message StartActionArgument {
|
||||||
|
string name = 1;
|
||||||
|
string value = 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
message StartActionResponse {
|
message StartActionResponse {
|
||||||
@@ -53,22 +67,34 @@ message LogEntry {
|
|||||||
int32 exitCode = 6;
|
int32 exitCode = 6;
|
||||||
string user = 7;
|
string user = 7;
|
||||||
string userClass = 8;
|
string userClass = 8;
|
||||||
|
string actionIcon = 9;
|
||||||
}
|
}
|
||||||
|
|
||||||
message GetLogsResponse {
|
message GetLogsResponse {
|
||||||
repeated LogEntry logs = 1;
|
repeated LogEntry logs = 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
message ValidateArgumentTypeRequest {
|
||||||
|
string value = 1;
|
||||||
|
string type = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
message ValidateArgumentTypeResponse {
|
||||||
|
bool valid = 1;
|
||||||
|
string description = 2;
|
||||||
|
}
|
||||||
|
|
||||||
service OliveTinApi {
|
service OliveTinApi {
|
||||||
rpc GetButtons(GetButtonsRequest) returns (GetButtonsResponse) {
|
rpc GetDashboardComponents(GetDashboardComponentsRequest) returns (GetDashboardComponentsResponse) {
|
||||||
option (google.api.http) = {
|
option (google.api.http) = {
|
||||||
get: "/api/GetButtons"
|
get: "/api/GetDashboardComponents"
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
rpc StartAction(StartActionRequest) returns (StartActionResponse) {
|
rpc StartAction(StartActionRequest) returns (StartActionResponse) {
|
||||||
option (google.api.http) = {
|
option (google.api.http) = {
|
||||||
get: "/api/StartAction"
|
post: "/api/StartAction"
|
||||||
|
body: "*"
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -77,4 +103,11 @@ service OliveTinApi {
|
|||||||
get: "/api/GetLogs"
|
get: "/api/GetLogs"
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
rpc ValidateArgumentType(ValidateArgumentTypeRequest) returns (ValidateArgumentTypeResponse) {
|
||||||
|
option (google.api.http) = {
|
||||||
|
post: "/api/ValidateArgumentType"
|
||||||
|
body: "*"
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,8 +44,6 @@ func init() {
|
|||||||
|
|
||||||
cfg = config.DefaultConfig()
|
cfg = config.DefaultConfig()
|
||||||
|
|
||||||
reloadConfig()
|
|
||||||
|
|
||||||
viper.WatchConfig()
|
viper.WatchConfig()
|
||||||
viper.OnConfigChange(func(e fsnotify.Event) {
|
viper.OnConfigChange(func(e fsnotify.Event) {
|
||||||
if e.Op == fsnotify.Write {
|
if e.Op == fsnotify.Write {
|
||||||
@@ -54,6 +52,9 @@ func init() {
|
|||||||
reloadConfig()
|
reloadConfig()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
reloadConfig()
|
||||||
|
log.Info("Init complete")
|
||||||
}
|
}
|
||||||
|
|
||||||
func reloadConfig() {
|
func reloadConfig() {
|
||||||
@@ -62,7 +63,7 @@ func reloadConfig() {
|
|||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
config.Sanitize(cfg);
|
config.Sanitize(cfg)
|
||||||
}
|
}
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ type User struct {
|
|||||||
Username string
|
Username string
|
||||||
}
|
}
|
||||||
|
|
||||||
func IsAllowedExec(cfg *config.Config, user *User, action *config.ActionButton) bool {
|
func IsAllowedExec(cfg *config.Config, user *User, action *config.Action) bool {
|
||||||
canExec := cfg.DefaultPermissions.Exec
|
canExec := cfg.DefaultPermissions.Exec
|
||||||
|
|
||||||
log.WithFields(log.Fields{
|
log.WithFields(log.Fields{
|
||||||
@@ -40,7 +40,7 @@ func IsAllowedExec(cfg *config.Config, user *User, action *config.ActionButton)
|
|||||||
return canExec
|
return canExec
|
||||||
}
|
}
|
||||||
|
|
||||||
func IsAllowedView(cfg *config.Config, user *User, action *config.ActionButton) bool {
|
func IsAllowedView(cfg *config.Config, user *User, action *config.Action) bool {
|
||||||
canView := cfg.DefaultPermissions.View
|
canView := cfg.DefaultPermissions.View
|
||||||
|
|
||||||
log.WithFields(log.Fields{
|
log.WithFields(log.Fields{
|
||||||
|
|||||||
@@ -2,8 +2,7 @@ package config
|
|||||||
|
|
||||||
import ()
|
import ()
|
||||||
|
|
||||||
// ActionButton represents a button that is shown in the webui.
|
type Action struct {
|
||||||
type ActionButton struct {
|
|
||||||
ID string
|
ID string
|
||||||
Title string
|
Title string
|
||||||
Icon string
|
Icon string
|
||||||
@@ -16,7 +15,7 @@ type ActionButton struct {
|
|||||||
|
|
||||||
type ActionArgument struct {
|
type ActionArgument struct {
|
||||||
Name string
|
Name string
|
||||||
Label string
|
Title string
|
||||||
Type string
|
Type string
|
||||||
Default string
|
Default string
|
||||||
Choices []ActionArgumentChoice
|
Choices []ActionArgumentChoice
|
||||||
@@ -24,16 +23,16 @@ type ActionArgument struct {
|
|||||||
|
|
||||||
type ActionArgumentChoice struct {
|
type ActionArgumentChoice struct {
|
||||||
Value string
|
Value string
|
||||||
Label string
|
Title string
|
||||||
}
|
}
|
||||||
|
|
||||||
// Entity represents a "thing" that can have multiple actions associated with it.
|
// Entity represents a "thing" that can have multiple actions associated with it.
|
||||||
// for example, a media player with a start and stop action.
|
// for example, a media player with a start and stop action.
|
||||||
type Entity struct {
|
type Entity struct {
|
||||||
Title string
|
Title string
|
||||||
Icon string
|
Icon string
|
||||||
ActionButtons []ActionButton `mapstructure:"actions"`
|
Actions []Action `mapstructure:"actions"`
|
||||||
CSS map[string]string
|
CSS map[string]string
|
||||||
}
|
}
|
||||||
|
|
||||||
type PermissionsEntry struct {
|
type PermissionsEntry struct {
|
||||||
@@ -63,9 +62,10 @@ type Config struct {
|
|||||||
ListenAddressGrpcActions string
|
ListenAddressGrpcActions string
|
||||||
ExternalRestAddress string
|
ExternalRestAddress string
|
||||||
LogLevel string
|
LogLevel string
|
||||||
ActionButtons []ActionButton `mapstructure:"actions"`
|
Actions []Action `mapstructure:"actions"`
|
||||||
Entities []Entity `mapstructure:"entities"`
|
Entities []Entity `mapstructure:"entities"`
|
||||||
CheckForUpdates bool
|
CheckForUpdates bool
|
||||||
|
ShowNewVersions bool
|
||||||
Usergroups []UserGroup
|
Usergroups []UserGroup
|
||||||
DefaultPermissions DefaultPermissions
|
DefaultPermissions DefaultPermissions
|
||||||
}
|
}
|
||||||
@@ -81,6 +81,7 @@ func DefaultConfig() *Config {
|
|||||||
config.ListenAddressWebUI = "localhost:1340"
|
config.ListenAddressWebUI = "localhost:1340"
|
||||||
config.LogLevel = "INFO"
|
config.LogLevel = "INFO"
|
||||||
config.CheckForUpdates = true
|
config.CheckForUpdates = true
|
||||||
|
config.ShowNewVersions = true
|
||||||
config.DefaultPermissions.Exec = true
|
config.DefaultPermissions.Exec = true
|
||||||
config.DefaultPermissions.View = true
|
config.DefaultPermissions.View = true
|
||||||
|
|
||||||
|
|||||||
11
internal/config/config_helpers.go
Normal file
11
internal/config/config_helpers.go
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
func (cfg *Config) FindAction(actionTitle string) *Action {
|
||||||
|
for _, action := range cfg.Actions {
|
||||||
|
if action.Title == actionTitle {
|
||||||
|
return &action
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package grpcapi
|
package config
|
||||||
|
|
||||||
var emojis = map[string]string{
|
var emojis = map[string]string{
|
||||||
"poop": "💩",
|
"poop": "💩",
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package grpcapi
|
package config
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
@@ -5,28 +5,50 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func Sanitize(cfg *Config) {
|
func Sanitize(cfg *Config) {
|
||||||
sanitizeLogLevel(cfg);
|
sanitizeLogLevel(cfg)
|
||||||
|
|
||||||
for _, action := range cfg.ActionButtons {
|
//log.Infof("cfg %p", cfg)
|
||||||
sanitizeAction(action)
|
|
||||||
|
for idx, _ := range cfg.Actions {
|
||||||
|
sanitizeAction(&cfg.Actions[idx])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func sanitizeLogLevel(cfg *Config) {
|
func sanitizeLogLevel(cfg *Config) {
|
||||||
if logLevel, err := log.ParseLevel(cfg.LogLevel); err == nil {
|
if logLevel, err := log.ParseLevel(cfg.LogLevel); err == nil {
|
||||||
log.Info("lvl", logLevel)
|
log.Info("Setting log level to ", logLevel)
|
||||||
log.SetLevel(logLevel)
|
log.SetLevel(logLevel)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func sanitizeAction(action ActionButton) {
|
func sanitizeAction(action *Action) {
|
||||||
for _, argument := range action.Arguments {
|
if action.Timeout < 3 {
|
||||||
sanitizeActionArgument(argument)
|
action.Timeout = 3
|
||||||
|
}
|
||||||
|
|
||||||
|
action.Icon = lookupHTMLIcon(action.Icon)
|
||||||
|
|
||||||
|
for idx, _ := range action.Arguments {
|
||||||
|
sanitizeActionArgument(&action.Arguments[idx])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func sanitizeActionArgument(arg ActionArgument) {
|
func sanitizeActionArgument(arg *ActionArgument) {
|
||||||
log.Info("Sanitize AA")
|
if arg.Title == "" {
|
||||||
arg.Label = "foo"
|
arg.Title = arg.Name
|
||||||
arg.Name = "blat"
|
}
|
||||||
|
|
||||||
|
sanitizeActionArgumentNoType(arg)
|
||||||
|
|
||||||
|
// TODO Validate the default against the type checker, but this creates a
|
||||||
|
// import loop
|
||||||
|
}
|
||||||
|
|
||||||
|
func sanitizeActionArgumentNoType(arg *ActionArgument) {
|
||||||
|
if len(arg.Choices) == 0 && arg.Type == "" {
|
||||||
|
log.WithFields(log.Fields{
|
||||||
|
"arg": arg.Name,
|
||||||
|
}).Warn("Argument type isn't set, will default to 'ascii' but this may not be safe. You should set a type specifically.")
|
||||||
|
arg.Type = "ascii"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,99 +9,286 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
typecheckRegex = map[string]string{
|
||||||
|
"very_dangerous_raw_string": "",
|
||||||
|
"int": "^[\\d]+$",
|
||||||
|
"ascii": "^[a-zA-Z0-9]+$",
|
||||||
|
"ascii_identifier": "^[a-zA-Z0-9\\-\\.\\_]+$",
|
||||||
|
"ascii_sentence": "^[a-zA-Z0-9 \\,\\.]+$",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
type InternalLogEntry struct {
|
type InternalLogEntry struct {
|
||||||
Datetime string
|
Datetime string
|
||||||
Content string
|
Stdout string
|
||||||
Stdout string
|
Stderr string
|
||||||
Stderr string
|
TimedOut bool
|
||||||
TimedOut bool
|
ExitCode int32
|
||||||
ExitCode int32
|
|
||||||
|
/*
|
||||||
|
The following two properties are obviously on Action normally, but it's useful
|
||||||
|
that logs are lightweight (so we don't need to have an action associated to
|
||||||
|
logs, etc. Therefore, we duplicate those values here.
|
||||||
|
*/
|
||||||
ActionTitle string
|
ActionTitle string
|
||||||
|
ActionIcon string
|
||||||
|
}
|
||||||
|
|
||||||
|
type ExecutionRequest struct {
|
||||||
|
ActionName string
|
||||||
|
Arguments map[string]string
|
||||||
|
action *config.Action
|
||||||
|
Cfg *config.Config
|
||||||
|
User *acl.User
|
||||||
|
logEntry *InternalLogEntry
|
||||||
|
finalParsedCommand string
|
||||||
|
}
|
||||||
|
|
||||||
|
type ExecutorStep interface {
|
||||||
|
Exec(*ExecutionRequest) bool
|
||||||
}
|
}
|
||||||
|
|
||||||
type Executor struct {
|
type Executor struct {
|
||||||
Logs []InternalLogEntry
|
Logs []InternalLogEntry
|
||||||
|
|
||||||
|
chainOfCommand []ExecutorStep
|
||||||
}
|
}
|
||||||
|
|
||||||
// ExecAction executes an action.
|
func DefaultExecutor() *Executor {
|
||||||
func (e *Executor) ExecAction(cfg *config.Config, user *acl.User, actualAction *config.ActionButton) *pb.StartActionResponse {
|
e := Executor{}
|
||||||
log.WithFields(log.Fields{
|
e.chainOfCommand = []ExecutorStep{
|
||||||
"actionName": actualAction.Title,
|
StepFindAction{},
|
||||||
}).Infof("StartAction")
|
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{
|
return &pb.StartActionResponse{
|
||||||
LogEntry: &pb.LogEntry{
|
LogEntry: &pb.LogEntry{
|
||||||
ActionTitle: actualAction.Title,
|
ActionTitle: req.logEntry.ActionTitle,
|
||||||
TimedOut: res.TimedOut,
|
ActionIcon: req.logEntry.ActionIcon,
|
||||||
Stderr: res.Stderr,
|
Datetime: req.logEntry.Datetime,
|
||||||
Stdout: res.Stdout,
|
Stderr: req.logEntry.Stderr,
|
||||||
ExitCode: res.ExitCode,
|
Stdout: req.logEntry.Stdout,
|
||||||
|
TimedOut: req.logEntry.TimedOut,
|
||||||
|
ExitCode: req.logEntry.ExitCode,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func execAction(cfg *config.Config, actualAction *config.ActionButton) *InternalLogEntry {
|
type StepLogStart struct{}
|
||||||
res := &InternalLogEntry{
|
|
||||||
Datetime: time.Now().Format("2006-01-02 15:04:05"),
|
func (e StepLogStart) Exec(req *ExecutionRequest) bool {
|
||||||
TimedOut: false,
|
log.WithFields(log.Fields{
|
||||||
ActionTitle: actualAction.Title,
|
"title": req.action.Title,
|
||||||
|
"timeout": req.action.Timeout,
|
||||||
|
}).Infof("Action starting")
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
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{
|
return true
|
||||||
"title": actualAction.Title,
|
}
|
||||||
"timeout": actualAction.Timeout,
|
|
||||||
}).Infof("Found action")
|
|
||||||
|
|
||||||
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()
|
defer cancel()
|
||||||
|
|
||||||
cmd := exec.CommandContext(ctx, "sh", "-c", actualAction.Shell)
|
cmd := exec.CommandContext(ctx, "sh", "-c", req.finalParsedCommand)
|
||||||
stdout, stderr := cmd.Output()
|
stdout, stderr := cmd.Output()
|
||||||
|
|
||||||
res.ExitCode = int32(cmd.ProcessState.ExitCode())
|
if stderr != nil {
|
||||||
res.Stdout = string(stdout)
|
req.logEntry.Stderr = stderr.Error()
|
||||||
|
|
||||||
if stderr == nil {
|
|
||||||
res.Stderr = ""
|
|
||||||
} else {
|
|
||||||
res.Stderr = stderr.Error()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if ctx.Err() == context.DeadlineExceeded {
|
if ctx.Err() == context.DeadlineExceeded {
|
||||||
res.TimedOut = true
|
req.logEntry.TimedOut = true
|
||||||
|
}
|
||||||
|
|
||||||
|
req.logEntry.ExitCode = int32(cmd.ProcessState.ExitCode())
|
||||||
|
req.logEntry.Stdout = string(stdout)
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseActionArguments(rawShellCommand string, values map[string]string, action *config.Action) (string, error) {
|
||||||
|
log.WithFields(log.Fields{
|
||||||
|
"cmd": rawShellCommand,
|
||||||
|
}).Infof("Before Parse Args")
|
||||||
|
|
||||||
|
r := regexp.MustCompile("{{ *?([a-z]+?) *?}}")
|
||||||
|
matches := r.FindAllStringSubmatch(rawShellCommand, -1)
|
||||||
|
|
||||||
|
for _, match := range matches {
|
||||||
|
argValue, argProvided := values[match[1]]
|
||||||
|
|
||||||
|
if !argProvided {
|
||||||
|
log.Infof("%v", values)
|
||||||
|
return "", errors.New("Required arg not provided: " + match[1])
|
||||||
|
}
|
||||||
|
|
||||||
|
err := typecheckActionArgument(match[1], argValue, action)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
log.WithFields(log.Fields{
|
||||||
|
"name": match[1],
|
||||||
|
"value": argValue,
|
||||||
|
}).Debugf("Arg assigned")
|
||||||
|
|
||||||
|
rawShellCommand = strings.Replace(rawShellCommand, match[0], argValue, -1)
|
||||||
}
|
}
|
||||||
|
|
||||||
log.WithFields(log.Fields{
|
log.WithFields(log.Fields{
|
||||||
"stdout": res.Stdout,
|
"cmd": rawShellCommand,
|
||||||
"stderr": res.Stderr,
|
}).Infof("After Parse Args")
|
||||||
"timedOut": res.TimedOut,
|
|
||||||
"exit": res.ExitCode,
|
|
||||||
}).Infof("Finished command.")
|
|
||||||
|
|
||||||
return res
|
return rawShellCommand, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func sanitizeAction(action *config.ActionButton) {
|
func typecheckActionArgument(name string, value string, action *config.Action) error {
|
||||||
if action.Timeout < 3 {
|
arg := findArg(name, action)
|
||||||
action.Timeout = 3
|
|
||||||
|
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) {
|
func typecheckChoice(value string, arg *config.ActionArgument) error {
|
||||||
for _, action := range cfg.ActionButtons {
|
for _, choice := range arg.Choices {
|
||||||
if action.Title == actionTitle {
|
if value == choice.Value {
|
||||||
sanitizeAction(&action)
|
return nil
|
||||||
|
|
||||||
return &action, nil
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil, errors.New("Action not found")
|
return errors.New("Arg value is not one of the predefined choices")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TypeSafetyCheck(name string, value string, typ string) error {
|
||||||
|
pattern, found := typecheckRegex[typ]
|
||||||
|
|
||||||
|
log.Infof("%v %v", pattern, typ)
|
||||||
|
|
||||||
|
if !found {
|
||||||
|
return errors.New("Arg type not implemented " + typ)
|
||||||
|
}
|
||||||
|
|
||||||
|
matches, _ := regexp.MatchString(pattern, value)
|
||||||
|
|
||||||
|
if !matches {
|
||||||
|
log.WithFields(log.Fields{
|
||||||
|
"name": name,
|
||||||
|
"type": typ,
|
||||||
|
"value": value,
|
||||||
|
}).Warn("Arg type check safety failure")
|
||||||
|
|
||||||
|
return errors.New("Invalid argument, doesn't match " + typ)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func findArg(name string, action *config.Action) *config.ActionArgument {
|
||||||
|
for _, arg := range action.Arguments {
|
||||||
|
if arg.Name == name {
|
||||||
|
return &arg
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import (
|
|||||||
|
|
||||||
var (
|
var (
|
||||||
cfg *config.Config
|
cfg *config.Config
|
||||||
ex = executor.Executor{}
|
ex = executor.DefaultExecutor()
|
||||||
)
|
)
|
||||||
|
|
||||||
type oliveTinAPI struct {
|
type oliveTinAPI struct {
|
||||||
@@ -22,36 +22,34 @@ type oliveTinAPI struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (api *oliveTinAPI) StartAction(ctx ctx.Context, req *pb.StartActionRequest) (*pb.StartActionResponse, error) {
|
func (api *oliveTinAPI) StartAction(ctx ctx.Context, req *pb.StartActionRequest) (*pb.StartActionResponse, error) {
|
||||||
actualAction, err := executor.FindAction(cfg, req.ActionName)
|
args := make(map[string]string)
|
||||||
|
|
||||||
if err != nil {
|
log.Debugf("SA %v", req)
|
||||||
log.Errorf("Error finding action %s, %s", err, req.ActionName)
|
|
||||||
|
|
||||||
return &pb.StartActionResponse{
|
for _, arg := range req.Arguments {
|
||||||
LogEntry: nil,
|
args[arg.Name] = arg.Value
|
||||||
}, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
user := acl.UserFromContext(ctx)
|
execReq := executor.ExecutionRequest{
|
||||||
|
ActionName: req.ActionName,
|
||||||
if !acl.IsAllowedExec(cfg, user, actualAction) {
|
Arguments: args,
|
||||||
return &pb.StartActionResponse{}, nil
|
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)
|
user := acl.UserFromContext(ctx)
|
||||||
|
|
||||||
res := actionButtonsCfgToPb(cfg.ActionButtons, user)
|
res := actionsCfgToPb(cfg.Actions, user)
|
||||||
|
|
||||||
if len(res.Actions) == 0 {
|
if len(res.Actions) == 0 {
|
||||||
log.Warn("Zero actions found - check that you have some actions defined, with a view permission")
|
log.Warn("Zero actions found - check that you have some actions defined, with a view permission")
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Debugf("getButtons: %v", res)
|
log.Debugf("GetDashboardComponents: %v", res)
|
||||||
|
|
||||||
return res, nil
|
return res, nil
|
||||||
}
|
}
|
||||||
@@ -64,6 +62,7 @@ func (api *oliveTinAPI) GetLogs(ctx ctx.Context, req *pb.GetLogsRequest) (*pb.Ge
|
|||||||
for _, logEntry := range ex.Logs {
|
for _, logEntry := range ex.Logs {
|
||||||
ret.Logs = append(ret.Logs, &pb.LogEntry{
|
ret.Logs = append(ret.Logs, &pb.LogEntry{
|
||||||
ActionTitle: logEntry.ActionTitle,
|
ActionTitle: logEntry.ActionTitle,
|
||||||
|
ActionIcon: logEntry.ActionIcon,
|
||||||
Datetime: logEntry.Datetime,
|
Datetime: logEntry.Datetime,
|
||||||
Stdout: logEntry.Stdout,
|
Stdout: logEntry.Stdout,
|
||||||
Stderr: logEntry.Stderr,
|
Stderr: logEntry.Stderr,
|
||||||
@@ -75,6 +74,25 @@ func (api *oliveTinAPI) GetLogs(ctx ctx.Context, req *pb.GetLogsRequest) (*pb.Ge
|
|||||||
return ret, nil
|
return ret, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
This function is ONLY a helper for the UI - the arguments are validated properly
|
||||||
|
on the StartAction -> Executor chain. This is here basically to provide helpful
|
||||||
|
error messages more quickly before starting the action.
|
||||||
|
*/
|
||||||
|
func (api *oliveTinAPI) ValidateArgumentType(ctx ctx.Context, req *pb.ValidateArgumentTypeRequest) (*pb.ValidateArgumentTypeResponse, error) {
|
||||||
|
err := executor.TypeSafetyCheck("", req.Value, req.Type)
|
||||||
|
desc := ""
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
desc = err.Error()
|
||||||
|
}
|
||||||
|
|
||||||
|
return &pb.ValidateArgumentTypeResponse{
|
||||||
|
Valid: err == nil,
|
||||||
|
Description: desc,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
// Start will start the GRPC API.
|
// Start will start the GRPC API.
|
||||||
func Start(globalConfig *config.Config) {
|
func Start(globalConfig *config.Config) {
|
||||||
cfg = globalConfig
|
cfg = globalConfig
|
||||||
|
|||||||
@@ -8,33 +8,33 @@ import (
|
|||||||
config "github.com/jamesread/OliveTin/internal/config"
|
config "github.com/jamesread/OliveTin/internal/config"
|
||||||
)
|
)
|
||||||
|
|
||||||
func actionButtonsCfgToPb(cfgActionButtons []config.ActionButton, user *acl.User) (*pb.GetButtonsResponse) {
|
func actionsCfgToPb(cfgActions []config.Action, user *acl.User) *pb.GetDashboardComponentsResponse {
|
||||||
res := &pb.GetButtonsResponse{}
|
res := &pb.GetDashboardComponentsResponse{}
|
||||||
|
|
||||||
for _, action := range cfgActionButtons {
|
for _, action := range cfgActions {
|
||||||
if !acl.IsAllowedView(cfg, user, &action) {
|
if !acl.IsAllowedView(cfg, user, &action) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
btn := buildButton(action, user)
|
btn := actionCfgToPb(action, user)
|
||||||
res.Actions = append(res.Actions, btn)
|
res.Actions = append(res.Actions, btn)
|
||||||
}
|
}
|
||||||
|
|
||||||
return res
|
return res
|
||||||
}
|
}
|
||||||
|
|
||||||
func buildButton(action config.ActionButton, user *acl.User) *pb.ActionButton {
|
func actionCfgToPb(action config.Action, user *acl.User) *pb.Action {
|
||||||
btn := pb.ActionButton{
|
btn := pb.Action{
|
||||||
Id: fmt.Sprintf("%x", md5.Sum([]byte(action.Title))),
|
Id: fmt.Sprintf("%x", md5.Sum([]byte(action.Title))),
|
||||||
Title: action.Title,
|
Title: action.Title,
|
||||||
Icon: lookupHTMLIcon(action.Icon),
|
Icon: action.Icon,
|
||||||
CanExec: acl.IsAllowedExec(cfg, user, &action),
|
CanExec: acl.IsAllowedExec(cfg, user, &action),
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, cfgArg := range action.Arguments {
|
for _, cfgArg := range action.Arguments {
|
||||||
pbArg := pb.ActionArgument{
|
pbArg := pb.ActionArgument{
|
||||||
Name: cfgArg.Name,
|
Name: cfgArg.Name,
|
||||||
Label: cfgArg.Label,
|
Title: cfgArg.Title,
|
||||||
Type: cfgArg.Type,
|
Type: cfgArg.Type,
|
||||||
DefaultValue: cfgArg.Default,
|
DefaultValue: cfgArg.Default,
|
||||||
Choices: buildChoices(cfgArg.Choices),
|
Choices: buildChoices(cfgArg.Choices),
|
||||||
@@ -52,7 +52,7 @@ func buildChoices(choices []config.ActionArgumentChoice) []*pb.ActionArgumentCho
|
|||||||
for _, cfgChoice := range choices {
|
for _, cfgChoice := range choices {
|
||||||
pbChoice := pb.ActionArgumentChoice{
|
pbChoice := pb.ActionArgumentChoice{
|
||||||
Value: cfgChoice.Value,
|
Value: cfgChoice.Value,
|
||||||
Label: cfgChoice.Label,
|
Title: cfgChoice.Title,
|
||||||
}
|
}
|
||||||
|
|
||||||
ret = append(ret, &pbChoice)
|
ret = append(ret, &pbChoice)
|
||||||
@@ -52,19 +52,19 @@ func getNewTestServerAndClient(t *testing.T, injectedConfig *config.Config) (*gr
|
|||||||
return conn, client
|
return conn, client
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestGetButtonsAndStart(t *testing.T) {
|
func TestGetActionsAndStart(t *testing.T) {
|
||||||
cfg = config.DefaultConfig()
|
cfg = config.DefaultConfig()
|
||||||
btn1 := config.ActionButton{}
|
btn1 := config.Action{}
|
||||||
btn1.Title = "blat"
|
btn1.Title = "blat"
|
||||||
btn1.Shell = "echo 'test'"
|
btn1.Shell = "echo 'test'"
|
||||||
cfg.ActionButtons = append(cfg.ActionButtons, btn1)
|
cfg.Actions = append(cfg.Actions, btn1)
|
||||||
|
|
||||||
conn, client := getNewTestServerAndClient(t, cfg)
|
conn, client := getNewTestServerAndClient(t, cfg)
|
||||||
|
|
||||||
respGb, err := client.GetButtons(context.Background(), &pb.GetButtonsRequest{})
|
respGb, err := client.GetDashboardComponents(context.Background(), &pb.GetDashboardComponentsRequest{})
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Errorf("GetButtons: %v", err)
|
t.Errorf("GetDashboardComponentsRequest: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
assert.Equal(t, true, true, "sayHello Failed")
|
assert.Equal(t, true, true, "sayHello Failed")
|
||||||
|
|||||||
@@ -8,12 +8,16 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
|
|
||||||
config "github.com/jamesread/OliveTin/internal/config"
|
config "github.com/jamesread/OliveTin/internal/config"
|
||||||
|
updatecheck "github.com/jamesread/OliveTin/internal/updatecheck"
|
||||||
)
|
)
|
||||||
|
|
||||||
type webUISettings struct {
|
type webUISettings struct {
|
||||||
Rest string
|
Rest string
|
||||||
ThemeName string
|
ThemeName string
|
||||||
HideNavigation bool
|
HideNavigation bool
|
||||||
|
AvailableVersion string
|
||||||
|
CurrentVersion string
|
||||||
|
ShowNewVersions bool
|
||||||
}
|
}
|
||||||
|
|
||||||
func findWebuiDir() string {
|
func findWebuiDir() string {
|
||||||
@@ -43,9 +47,12 @@ func generateWebUISettings(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
jsonRet, _ := json.Marshal(webUISettings{
|
jsonRet, _ := json.Marshal(webUISettings{
|
||||||
Rest: restAddress + "/api/",
|
Rest: restAddress + "/api/",
|
||||||
ThemeName: cfg.ThemeName,
|
ThemeName: cfg.ThemeName,
|
||||||
HideNavigation: cfg.HideNavigation,
|
HideNavigation: cfg.HideNavigation,
|
||||||
|
AvailableVersion: updatecheck.AvailableVersion,
|
||||||
|
CurrentVersion: updatecheck.CurrentVersion,
|
||||||
|
ShowNewVersions: cfg.ShowNewVersions,
|
||||||
})
|
})
|
||||||
|
|
||||||
w.Write([]byte(jsonRet))
|
w.Write([]byte(jsonRet))
|
||||||
|
|||||||
@@ -21,6 +21,9 @@ type updateRequest struct {
|
|||||||
MachineID string
|
MachineID string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var AvailableVersion = "none"
|
||||||
|
var CurrentVersion = "?"
|
||||||
|
|
||||||
func machineID() string {
|
func machineID() string {
|
||||||
v, err := machineid.ProtectedID("OliveTin")
|
v, err := machineid.ProtectedID("OliveTin")
|
||||||
|
|
||||||
@@ -40,6 +43,8 @@ func StartUpdateChecker(currentVersion string, currentCommit string, cfg *config
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
CurrentVersion = currentVersion
|
||||||
|
|
||||||
payload := updateRequest{
|
payload := updateRequest{
|
||||||
CurrentVersion: currentVersion,
|
CurrentVersion: currentVersion,
|
||||||
CurrentCommit: currentCommit,
|
CurrentCommit: currentCommit,
|
||||||
@@ -89,9 +94,9 @@ func actualCheckForUpdate(payload updateRequest) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
newVersion := doRequest(jsonUpdateRequest)
|
AvailableVersion = doRequest(jsonUpdateRequest)
|
||||||
|
|
||||||
log.WithFields(log.Fields{
|
log.WithFields(log.Fields{
|
||||||
"NewVersion": newVersion,
|
"NewVersion": AvailableVersion,
|
||||||
}).Infof("Update check complete")
|
}).Infof("Update check complete")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,6 +23,7 @@
|
|||||||
<tr>
|
<tr>
|
||||||
<th>Timestamp</th>
|
<th>Timestamp</th>
|
||||||
<th>Log</th>
|
<th>Log</th>
|
||||||
|
<th>Exit Code</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody id = "logTableBody" />
|
<tbody id = "logTableBody" />
|
||||||
@@ -38,28 +39,56 @@
|
|||||||
<p><img title = "application icon" src = "OliveTinLogo.png" height = "1em" class = "logo" /> OliveTin</p>
|
<p><img title = "application icon" src = "OliveTinLogo.png" height = "1em" class = "logo" /> OliveTin</p>
|
||||||
<p>
|
<p>
|
||||||
<a href = "https://docs.olivetin.app" target = "_new">Documentation</a> |
|
<a href = "https://docs.olivetin.app" target = "_new">Documentation</a> |
|
||||||
<a href = "https://github.com/OliveTin/OliveTin/issues/new/choose" target = "_new">Raise an issue on GitHub</a>
|
<a href = "https://github.com/OliveTin/OliveTin/issues/new/choose" target = "_new">Raise an issue on GitHub</a> |
|
||||||
|
<span id = "currentVersion">Version: ?</p>
|
||||||
|
<a id = "availableVersion" href = "http://olivetin.app" target = "_blank" hidden>?</a>
|
||||||
</p>
|
</p>
|
||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
|
<template id = "tplArgumentForm">
|
||||||
|
<div class = "wrapper">
|
||||||
|
<div>
|
||||||
|
<span class = "icon" role = "icon"></span>
|
||||||
|
<h2>Argument form</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class = "arguments"></div>
|
||||||
|
|
||||||
|
<div class = "buttons">
|
||||||
|
<input name = "start" type = "submit" value = "Start">
|
||||||
|
<button name = "cancel">Cancel</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
<template id = "tplActionButton">
|
<template id = "tplActionButton">
|
||||||
<span role = "img" title = "button icon" class = "icon">💩</span>
|
<span role = "icon" title = "button icon" class = "icon">💩</span>
|
||||||
<p role = "title" class = "title">Untitled Button</p>
|
<p role = "title" class = "title">Untitled Button</p>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template id = "tplLogRow">
|
<template id = "tplLogRow">
|
||||||
<tr>
|
<tr class = "logRow">
|
||||||
<td class = "timestamp">?</td>
|
<td class = "timestamp">?</td>
|
||||||
<td>
|
<td>
|
||||||
|
<span class = "icon" role = "icon"></span>
|
||||||
<span class = "content">?</span>
|
<span class = "content">?</span>
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
<summary>stdout</summary>
|
<summary>stdout</summary>
|
||||||
<pre>
|
<pre class = "stdout">
|
||||||
?
|
?
|
||||||
</pre>
|
</pre>
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>stderr</summary>
|
||||||
|
<pre class = "stderr">
|
||||||
|
?
|
||||||
|
</pre>
|
||||||
|
</details>
|
||||||
|
|
||||||
</td>
|
</td>
|
||||||
|
<td class = "exitCode">?</td>
|
||||||
</tr>
|
</tr>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { marshalLogsJsonToHtml } from './marshaller.js';
|
import { marshalLogsJsonToHtml } from './marshaller.js'
|
||||||
import "./ArgumentForm.js"
|
import './ArgumentForm.js'
|
||||||
|
|
||||||
class ActionButton extends window.HTMLButtonElement {
|
class ActionButton extends window.HTMLButtonElement {
|
||||||
constructFromJson (json) {
|
constructFromJson (json) {
|
||||||
@@ -8,20 +8,20 @@ class ActionButton extends window.HTMLButtonElement {
|
|||||||
this.title = json.title
|
this.title = json.title
|
||||||
this.temporaryStatusMessage = null
|
this.temporaryStatusMessage = null
|
||||||
this.isWaiting = false
|
this.isWaiting = false
|
||||||
this.actionCallUrl = window.restBaseUrl + 'StartAction?actionName=' + this.title
|
this.actionCallUrl = window.restBaseUrl + 'StartAction'
|
||||||
|
|
||||||
this.updateFromJson(json)
|
this.updateFromJson(json)
|
||||||
|
|
||||||
this.onclick = () => {
|
this.onclick = () => {
|
||||||
if (json.arguments.length > 0) {
|
if (json.arguments.length > 0) {
|
||||||
let frm = document.createElement('form', { is: 'argument-form' })
|
const frm = document.createElement('form', { is: 'argument-form' })
|
||||||
window.frm = frm
|
frm.setup(json, (args) => {
|
||||||
console.log(frm)
|
this.startAction(args)
|
||||||
frm.setup(json, this.startAction)
|
})
|
||||||
|
|
||||||
document.body.appendChild(frm)
|
document.body.appendChild(frm)
|
||||||
} else {
|
} else {
|
||||||
this.startAction()
|
this.startAction()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -46,18 +46,41 @@ class ActionButton extends window.HTMLButtonElement {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
startAction () {
|
startAction (actionArgs) {
|
||||||
this.disabled = true
|
this.disabled = true
|
||||||
this.isWaiting = true
|
this.isWaiting = true
|
||||||
this.updateHtml()
|
this.updateHtml()
|
||||||
this.classList = [] // Removes old animation classes
|
this.classList = [] // Removes old animation classes
|
||||||
|
|
||||||
window.fetch(this.actionCallUrl).then(res => res.json()
|
if (actionArgs === undefined) {
|
||||||
|
actionArgs = []
|
||||||
|
}
|
||||||
|
|
||||||
|
const startActionArgs = {
|
||||||
|
actionName: this.title,
|
||||||
|
arguments: actionArgs
|
||||||
|
}
|
||||||
|
|
||||||
|
window.fetch(this.actionCallUrl, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify(startActionArgs)
|
||||||
|
}).then((res) => {
|
||||||
|
if (res.ok) {
|
||||||
|
return res.json()
|
||||||
|
} else {
|
||||||
|
throw new Error(res.statusText)
|
||||||
|
}
|
||||||
|
}
|
||||||
).then((json) => {
|
).then((json) => {
|
||||||
marshalLogsJsonToHtml({ logs: [json.logEntry] })
|
marshalLogsJsonToHtml({ logs: [json.logEntry] })
|
||||||
|
|
||||||
if (json.logEntry.timedOut) {
|
if (json.logEntry.timedOut) {
|
||||||
this.onActionResult('actionTimeout', 'Timed out')
|
this.onActionResult('actionTimeout', 'Timed out')
|
||||||
|
} else if (json.logEntry.exitCode === -1337) {
|
||||||
|
this.onActionError('Error')
|
||||||
} else if (json.logEntry.exitCode !== 0) {
|
} else if (json.logEntry.exitCode !== 0) {
|
||||||
this.onActionResult('actionNonZeroExit', 'Exit code ' + json.logEntry.exitCode)
|
this.onActionResult('actionNonZeroExit', 'Exit code ' + json.logEntry.exitCode)
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -1,85 +1,140 @@
|
|||||||
|
|
||||||
class ArgumentForm extends window.HTMLFormElement {
|
class ArgumentForm extends window.HTMLFormElement {
|
||||||
setup(json, callback) {
|
setup (json, callback) {
|
||||||
this.setAttribute('class', 'actionArguments')
|
this.setAttribute('class', 'actionArguments')
|
||||||
|
|
||||||
console.log(json)
|
this.constructTemplate()
|
||||||
|
this.domTitle.innerText = json.title
|
||||||
this.domWrapper = document.createElement('div')
|
|
||||||
this.domWrapper.classList += 'wrapper'
|
|
||||||
this.appendChild(this.domWrapper)
|
|
||||||
|
|
||||||
this.domTitle = document.createElement('h2')
|
|
||||||
this.domTitle.innerText = json.title + ": Arguments"
|
|
||||||
this.domWrapper.appendChild(this.domTitle);
|
|
||||||
|
|
||||||
this.domIcon = document.createElement('span');
|
|
||||||
this.domIcon.classList += 'icon'
|
|
||||||
this.domIcon.setAttribute('role', 'img')
|
|
||||||
this.domIcon.innerHTML = json.icon
|
this.domIcon.innerHTML = json.icon
|
||||||
this.domTitle.prepend(this.domIcon)
|
|
||||||
|
|
||||||
let a = document.createElement("span")
|
|
||||||
a.innerText = "This is test version of the form."
|
|
||||||
this.domWrapper.appendChild(a)
|
|
||||||
|
|
||||||
this.createDomFormArguments(json.arguments)
|
this.createDomFormArguments(json.arguments)
|
||||||
this.domWrapper.appendChild(this.createDomSubmit())
|
|
||||||
|
|
||||||
console.log(json)
|
this.domBtnStart.onclick = () => {
|
||||||
|
for (const arg of this.argInputs) {
|
||||||
|
if (!arg.validity.valid) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const argvs = this.getArgumentValues()
|
||||||
|
|
||||||
|
callback(argvs)
|
||||||
|
|
||||||
|
this.remove()
|
||||||
|
}
|
||||||
|
|
||||||
|
this.domBtnCancel.onclick = () => {
|
||||||
|
this.remove()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
createDomSubmit() {
|
getArgumentValues () {
|
||||||
let el = document.createElement('button')
|
const ret = []
|
||||||
el.setAttribute('action', 'submit')
|
|
||||||
el.innerText = "Run"
|
|
||||||
|
|
||||||
return el
|
for (const arg of this.argInputs) {
|
||||||
|
ret.push({
|
||||||
|
name: arg.name,
|
||||||
|
value: arg.value
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return ret
|
||||||
}
|
}
|
||||||
|
|
||||||
createDomFormArguments(args) {
|
constructTemplate () {
|
||||||
for (let arg of args) {
|
const tpl = document.getElementById('tplArgumentForm')
|
||||||
let domFieldWrapper = document.createElement('p');
|
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.createDomLabel(arg))
|
||||||
domFieldWrapper.appendChild(this.createDomInput(arg))
|
domFieldWrapper.appendChild(this.createDomInput(arg))
|
||||||
|
|
||||||
this.domWrapper.appendChild(domFieldWrapper)
|
this.domArgs.appendChild(domFieldWrapper)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
createDomLabel(arg) {
|
createDomLabel (arg) {
|
||||||
let domLbl = document.createElement('label')
|
const domLbl = document.createElement('label')
|
||||||
domLbl.innerText = arg.label + ':';
|
domLbl.innerText = arg.title + ':'
|
||||||
domLbl.setAttribute('for', arg.name)
|
domLbl.setAttribute('for', arg.name)
|
||||||
|
|
||||||
return domLbl;
|
return domLbl
|
||||||
}
|
}
|
||||||
|
|
||||||
createDomInput(arg) {
|
createDomInput (arg) {
|
||||||
let domEl = null;
|
let domEl = null
|
||||||
|
|
||||||
if (arg.choices.length > 0) {
|
if (arg.choices.length > 0) {
|
||||||
domEl = document.createElement('select')
|
domEl = document.createElement('select')
|
||||||
|
|
||||||
for (let choice of arg.choices) {
|
// select/choice elements don't get an onchange/validation because theoretically
|
||||||
|
// the user should only select from a dropdown of valid options. The choices are
|
||||||
|
// riggeriously checked on StartAction anyway. ValidateArgumentType is only
|
||||||
|
// meant for showing simple warnings in the UI before running.
|
||||||
|
|
||||||
|
for (const choice of arg.choices) {
|
||||||
domEl.appendChild(this.createSelectOption(choice))
|
domEl.appendChild(this.createSelectOption(choice))
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
domEl = document.createElement('input')
|
domEl = document.createElement('input')
|
||||||
|
domEl.onchange = () => {
|
||||||
|
const validateArgumentTypeArgs = {
|
||||||
|
value: domEl.value,
|
||||||
|
type: arg.type
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
domEl.value = arg.defaultValue
|
||||||
|
|
||||||
return domEl;
|
this.argInputs.push(domEl)
|
||||||
|
|
||||||
|
return domEl
|
||||||
}
|
}
|
||||||
|
|
||||||
createSelectOption(choice) {
|
createSelectOption (choice) {
|
||||||
let domEl = document.createElement('option')
|
const domEl = document.createElement('option')
|
||||||
|
|
||||||
domEl.setAttribute('value', choice.value)
|
domEl.setAttribute('value', choice.value)
|
||||||
domEl.innerText = choice.label
|
domEl.innerText = choice.title
|
||||||
|
|
||||||
return domEl
|
return domEl
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,9 +31,30 @@ export function marshalLogsJsonToHtml (json) {
|
|||||||
const tpl = document.getElementById('tplLogRow')
|
const tpl = document.getElementById('tplLogRow')
|
||||||
const row = tpl.content.cloneNode(true)
|
const row = tpl.content.cloneNode(true)
|
||||||
|
|
||||||
|
if (logEntry.stdout.length === 0) {
|
||||||
|
logEntry.stdout = '(empty)'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (logEntry.stderr.length === 0) {
|
||||||
|
logEntry.stderr = '(empty)'
|
||||||
|
}
|
||||||
|
|
||||||
|
let logTableExitCode = logEntry.exitCode
|
||||||
|
|
||||||
|
if (logEntry.exitCode === 0) {
|
||||||
|
logTableExitCode = 'OK'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (logEntry.timedOut) {
|
||||||
|
logTableExitCode += ' (timed out)'
|
||||||
|
}
|
||||||
|
|
||||||
row.querySelector('.timestamp').innerText = logEntry.datetime
|
row.querySelector('.timestamp').innerText = logEntry.datetime
|
||||||
row.querySelector('.content').innerText = logEntry.actionTitle
|
row.querySelector('.content').innerText = logEntry.actionTitle
|
||||||
row.querySelector('pre').innerText = logEntry.stdout
|
row.querySelector('.icon').innerHTML = logEntry.actionIcon
|
||||||
|
row.querySelector('pre.stdout').innerText = logEntry.stdout
|
||||||
|
row.querySelector('pre.stderr').innerText = logEntry.stderr
|
||||||
|
row.querySelector('.exitCode').innerText = logTableExitCode
|
||||||
|
|
||||||
document.querySelector('#logTableBody').prepend(row)
|
document.querySelector('#logTableBody').prepend(row)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,8 +31,8 @@ function setupSections () {
|
|||||||
showSection('Actions')
|
showSection('Actions')
|
||||||
}
|
}
|
||||||
|
|
||||||
function fetchGetButtons () {
|
function fetchGetDashboardComponents () {
|
||||||
window.fetch(window.restBaseUrl + 'GetButtons', {
|
window.fetch(window.restBaseUrl + 'GetDashboardComponents', {
|
||||||
cors: 'cors'
|
cors: 'cors'
|
||||||
}).then(res => {
|
}).then(res => {
|
||||||
return res.json()
|
return res.json()
|
||||||
@@ -67,6 +67,13 @@ function processWebuiSettingsJson (settings) {
|
|||||||
document.head.appendChild(themeCss)
|
document.head.appendChild(themeCss)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
document.querySelector('#currentVersion').innerText = 'Version: ' + settings.CurrentVersion
|
||||||
|
|
||||||
|
if (settings.ShowNewVersions && settings.AvailableVersion !== 'none') {
|
||||||
|
document.querySelector('#availableVersion').innerText = 'New Version Available: ' + settings.AvailableVersion
|
||||||
|
document.querySelector('#availableVersion').hidden = false
|
||||||
|
}
|
||||||
|
|
||||||
document.querySelector('#switcher').hidden = settings.HideNavigation
|
document.querySelector('#switcher').hidden = settings.HideNavigation
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -77,10 +84,10 @@ window.fetch('webUiSettings.json').then(res => {
|
|||||||
}).then(res => {
|
}).then(res => {
|
||||||
processWebuiSettingsJson(res)
|
processWebuiSettingsJson(res)
|
||||||
|
|
||||||
fetchGetButtons()
|
fetchGetDashboardComponents()
|
||||||
fetchGetLogs()
|
fetchGetLogs()
|
||||||
|
|
||||||
window.buttonInterval = setInterval(fetchGetButtons, 3000)
|
window.buttonInterval = setInterval(fetchGetDashboardComponents, 3000)
|
||||||
}).catch(err => {
|
}).catch(err => {
|
||||||
showBigError('fetch-webui-settings', 'getting webui settings', err)
|
showBigError('fetch-webui-settings', 'getting webui settings', err)
|
||||||
})
|
})
|
||||||
|
|||||||
230
webui/style.css
230
webui/style.css
@@ -52,6 +52,14 @@ legend {
|
|||||||
|
|
||||||
span[role="icon"] {
|
span[role="icon"] {
|
||||||
display: block;
|
display: block;
|
||||||
|
font-size: 3em;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
form span[role="icon"],
|
||||||
|
tr.logRow span[role="icon"] {
|
||||||
|
display: inline-block;
|
||||||
|
padding-right: 0.2em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.error {
|
.error {
|
||||||
@@ -73,11 +81,17 @@ div.entity {
|
|||||||
grid-template-columns: minmax(min-content, auto);
|
grid-template-columns: minmax(min-content, auto);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
font-size: 1em;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
div.entity h2 {
|
div.entity h2 {
|
||||||
grid-column: 1 / span all;
|
grid-column: 1 / span all;
|
||||||
}
|
}
|
||||||
|
|
||||||
button {
|
button,
|
||||||
|
input[type="submit"] {
|
||||||
padding: 1em;
|
padding: 1em;
|
||||||
color: black;
|
color: black;
|
||||||
display: table-cell;
|
display: table-cell;
|
||||||
@@ -88,16 +102,19 @@ button {
|
|||||||
user-select: none;
|
user-select: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
button:hover {
|
button:hover,
|
||||||
|
input[type="submit"]:hover {
|
||||||
box-shadow: 0 0 10px 0 #666;
|
box-shadow: 0 0 10px 0 #666;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
button:focus {
|
button:focus,
|
||||||
|
input[type="submit"]:focus {
|
||||||
outline: 1px solid black;
|
outline: 1px solid black;
|
||||||
}
|
}
|
||||||
|
|
||||||
button:disabled {
|
button:disabled,
|
||||||
|
input[type="submit"]:disabled {
|
||||||
color: gray;
|
color: gray;
|
||||||
background-color: #333;
|
background-color: #333;
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
@@ -117,10 +134,6 @@ fieldset#switcher button:last-child {
|
|||||||
border-radius: 0 1em 1em 0;
|
border-radius: 0 1em 1em 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
span.icon {
|
|
||||||
font-size: 3em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.actionFailed {
|
.actionFailed {
|
||||||
animation: kfActionFailed 1s;
|
animation: kfActionFailed 1s;
|
||||||
}
|
}
|
||||||
@@ -180,13 +193,115 @@ img.logo {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
main {
|
||||||
|
padding: 1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
summary {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
details {
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
details[open] {
|
||||||
|
margin-top: 1em;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
form.actionArguments {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
padding: 1em;
|
||||||
|
box-shadow: 0 0 6px 0 #aaa;
|
||||||
|
background-color: #dee3e7;
|
||||||
|
}
|
||||||
|
|
||||||
|
form div.wrapper {
|
||||||
|
border-radius: 1em;
|
||||||
|
box-shadow: 0 0 10px 0 #444;
|
||||||
|
background-color: white;
|
||||||
|
border: 1px solid #999;
|
||||||
|
text-align: left;
|
||||||
|
padding: 1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
label {
|
||||||
|
width: 20%;
|
||||||
|
text-align: right;
|
||||||
|
display: inline-block;
|
||||||
|
padding-right: 1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
input {
|
||||||
|
padding: 0.6em;
|
||||||
|
}
|
||||||
|
|
||||||
|
input:invalid {
|
||||||
|
outline: 2px solid red;
|
||||||
|
}
|
||||||
|
|
||||||
|
form .wrapper span.icon {
|
||||||
|
display: inline-block;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
form input[type="submit"]:first-child {
|
||||||
|
margin-right: 1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
button[name=cancel]:hover {
|
||||||
|
background-color: salmon;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[name=start]:hover {
|
||||||
|
background-color: #aceaac;
|
||||||
|
}
|
||||||
|
|
||||||
|
form div.buttons {
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
pre {
|
||||||
|
border: 1px solid gray;
|
||||||
|
padding: 1em;
|
||||||
|
min-height: 1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
td.exitCode {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
input.invalid {
|
||||||
|
background-color: salmon;
|
||||||
|
}
|
||||||
|
|
||||||
|
#availableVersion {
|
||||||
|
background-color: #aceaac;
|
||||||
|
padding: 0.2em;
|
||||||
|
border-radius: 1em;
|
||||||
|
}
|
||||||
|
|
||||||
@media (prefers-color-scheme: dark) {
|
@media (prefers-color-scheme: dark) {
|
||||||
body {
|
body {
|
||||||
background-color: #333;
|
background-color: #333;
|
||||||
color: white;
|
color: white;
|
||||||
}
|
}
|
||||||
|
|
||||||
button {
|
form.actionArguments {
|
||||||
|
background-color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
form div.wrapper {
|
||||||
|
background-color: #222;
|
||||||
|
}
|
||||||
|
|
||||||
|
button,
|
||||||
|
input[type="submit"] {
|
||||||
border: 1px solid #666;
|
border: 1px solid #666;
|
||||||
background-color: #222;
|
background-color: #222;
|
||||||
box-shadow: 0 0 6px 0 #444;
|
box-shadow: 0 0 6px 0 #444;
|
||||||
@@ -218,100 +333,3 @@ img.logo {
|
|||||||
color: gray;
|
color: gray;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
main {
|
|
||||||
padding: 1em;
|
|
||||||
}
|
|
||||||
|
|
||||||
summary {
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
details {
|
|
||||||
display: inline-block;
|
|
||||||
}
|
|
||||||
|
|
||||||
details[open] {
|
|
||||||
margin-top: 1em;
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
|
|
||||||
form.actionArguments {
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
bottom: 0;
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
padding: 1em;
|
|
||||||
box-shadow: 0 0 6px 0 #aaa;
|
|
||||||
background-color: #dee3e7;
|
|
||||||
}
|
|
||||||
|
|
||||||
h2 {
|
|
||||||
font-size: 1em;
|
|
||||||
}
|
|
||||||
|
|
||||||
h2 span.icon {
|
|
||||||
vertical-align: middle;
|
|
||||||
padding-right: .2em;
|
|
||||||
}
|
|
||||||
|
|
||||||
form div.wrapper {
|
|
||||||
border-radius: 1em;
|
|
||||||
box-shadow: 0 0 10px 0 #444;
|
|
||||||
background-color: white;
|
|
||||||
border: 1px solid #999;
|
|
||||||
text-align: left;
|
|
||||||
padding: 1em;
|
|
||||||
}
|
|
||||||
|
|
||||||
label {
|
|
||||||
width: 30%;
|
|
||||||
text-align: right;
|
|
||||||
display: inline-block;
|
|
||||||
padding-right: 1em;
|
|
||||||
}
|
|
||||||
|
|
||||||
input {
|
|
||||||
padding: .6em;
|
|
||||||
}
|
|
||||||
|
|
||||||
form.actionArguments {
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
bottom: 0;
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
padding: 1em;
|
|
||||||
box-shadow: 0 0 6px 0 #aaa;
|
|
||||||
background-color: #dee3e7;
|
|
||||||
}
|
|
||||||
|
|
||||||
h2 {
|
|
||||||
font-size: 1em;
|
|
||||||
}
|
|
||||||
|
|
||||||
h2 span.icon {
|
|
||||||
vertical-align: middle;
|
|
||||||
padding-right: .2em;
|
|
||||||
}
|
|
||||||
|
|
||||||
form div.wrapper {
|
|
||||||
border-radius: 1em;
|
|
||||||
box-shadow: 0 0 10px 0 #444;
|
|
||||||
background-color: white;
|
|
||||||
border: 1px solid #999;
|
|
||||||
text-align: left;
|
|
||||||
padding: 1em;
|
|
||||||
}
|
|
||||||
|
|
||||||
label {
|
|
||||||
width: 30%;
|
|
||||||
text-align: right;
|
|
||||||
display: inline-block;
|
|
||||||
padding-right: 1em;
|
|
||||||
}
|
|
||||||
|
|
||||||
input {
|
|
||||||
padding: .6em;
|
|
||||||
}
|
|
||||||
|
|||||||
Reference in New Issue
Block a user