Compare commits

...

40 Commits

Author SHA1 Message Date
Evan Chen 639e3e97a5 feat: shrink modal width 2021-11-30 18:42:33 +08:00
Evan Chen 49b28eef61 update version 2021-11-30 16:40:58 +08:00
Evan Chen 9bbabf6479 add configui
continuous-integration/drone/tag Build is passing Details
2021-11-30 16:34:36 +08:00
Evan Chen dcc140b722 fix: file blocked by reporter
continuous-integration/drone/tag Build is passing Details
2021-11-18 01:51:18 +08:00
Evan Chen 5634bf108f fix: shell not configuable
continuous-integration/drone/tag Build is passing Details
2021-11-18 01:24:47 +08:00
Evan 591fb2cfa1 chore: change wording 2021-11-17 22:11:49 +08:00
Evan Chen 6801e56013 chore 2021-11-17 18:36:10 +08:00
Evan Chen 93410f9ddf fix: change highlight color
continuous-integration/drone/tag Build is failing Details
2021-11-17 18:35:10 +08:00
Evan Chen 0b0a54085c fix: #58 2021-11-17 17:41:21 +08:00
Evan Chen 221b52da75 build: go mod tidy 2021-11-17 17:24:46 +08:00
Evan Chen 3895e90876 fix: #56 2021-11-17 17:24:04 +08:00
Evan Chen b0d76dff3e chore 2021-11-15 16:54:55 +08:00
Evan Chen 4b1ad5b791 fix: action not running 2021-11-12 15:08:34 +08:00
Evan Chen 3327182dc0 fix: sh and cmd use absolute path 2021-11-12 14:49:15 +08:00
Evan Chen 69ad2a961c reorder 2021-11-12 14:29:47 +08:00
Evan Chen ef76eff228 v0.1.11
continuous-integration/drone/tag Build is passing Details
2021-11-11 10:37:43 +08:00
Evan Chen 4415ffe2ee fix: deprecate 'action' and use 'cmd' in file 2021-11-11 10:21:10 +08:00
Evan Chen acec4a6af9 fix: error togging unexpectedly 2021-11-11 10:05:55 +08:00
Evan Chen eddd813846 limit run command to single instance 2021-11-11 09:58:36 +08:00
Evan Chen 091c026a46 fix: hide file.data in config
continuous-integration/drone/tag Build is passing Details
2021-11-09 11:41:41 +08:00
Evan Chen 85b5416db5 fix: #51
continuous-integration/drone/tag Build is passing Details
2021-11-09 11:28:34 +08:00
Evan Chen ac7c25dde3 build: add build flag 2021-11-08 17:03:22 +08:00
Evan Chen 94dfb39d59 v0.1.9
continuous-integration/drone/tag Build is passing Details
2021-11-06 00:14:30 +08:00
Evan Chen 060b112eb3 feat: bind esc to close view (#48) 2021-11-06 00:09:51 +08:00
Evan Chen 8b85b8e89b fix: concurrency protect configuing
continuous-integration/drone/tag Build is passing Details
2021-11-05 11:10:13 +08:00
Evan Chen f76cc2f75e build: use ksrv engine 2021-11-04 13:55:45 +08:00
Evan Chen 0aa278efa0 fix: follow file not updating when file is trunc 2021-11-04 10:01:20 +08:00
Evan Chen 04580e4672 fix: log level and log prod not affected 2021-11-04 03:46:27 +08:00
Evan Chen 239be22094 refact: use klog and ksrv
continuous-integration/drone/tag Build is passing Details
2021-11-04 03:35:51 +08:00
Evan Chen cff4c13b78 fix: #46
continuous-integration/drone/tag Build is passing Details
2021-11-03 18:08:37 +08:00
Evan Chen 3a64217a10 fix: #44, #45
continuous-integration/drone/tag Build is passing Details
2021-11-03 16:21:49 +08:00
Evan Chen 38afcf64cf docs: add usage in subpath 2021-11-01 15:58:02 +08:00
Evan Chen c027d486c1 fix: allowIP not passed to configui
continuous-integration/drone/tag Build is passing Details
2021-10-25 13:21:36 +08:00
Evan 9814f61015 docs: version 2021-10-24 15:18:53 +08:00
evanchen c9031c6f3f Merge pull request 'fix: lib mode in subpath' (#39) from test/lib into master
continuous-integration/drone/tag Build is passing Details
Reviewed-on: #39
2021-10-24 07:07:51 +00:00
Evan 54f1d3278f fix: lib mode in subpath 2021-10-24 15:06:02 +08:00
Evan 40549e6552 feat: #37 fix: #38
continuous-integration/drone/tag Build was killed Details
2021-10-24 14:32:55 +08:00
Evan Chen e985a8f30e build: fix apline error in dind
continuous-integration/drone/tag Build was killed Details
2021-10-24 04:03:45 +08:00
evanchen 02b7446064 更新 '.drone.yml'
continuous-integration/drone/tag Build was killed Details
2021-10-23 19:10:19 +00:00
Evan Chen 45d425c78b fix: app name not showing in title 2021-10-24 02:43:26 +08:00
33 changed files with 1056 additions and 6277 deletions

View File

@ -3,10 +3,11 @@ name: default
steps:
- name: build
image: golang
image: golang:1.17.2
commands:
- git tag $DRONE_TAG
- bash release.sh cmd/configui/main.go
- echo -n "latest,${DRONE_TAG#v}" > .tags
- name: gitea_release
image: plugins/gitea-release
@ -25,7 +26,8 @@ steps:
from_secret: hub_user
password:
from_secret: hub_passwd
auto_tag: true
# auto_tag: true
mtu: 1000
# purge: true
repo: hub.kumoly.io/tools/configui
registry: hub.kumoly.io

View File

@ -1,3 +1,12 @@
# 0.1.3
## Feature
* bind key for run #37
## Fix
* Not building latest tag #38
# 0.1.2
## Feature

100
README.md
View File

@ -33,21 +33,78 @@ sudo sh -c "curl -fsSL RELEASE_URL | tar -C /usr/local/bin/ -xz"
## Config
```json
[
{
"path": "configui.log",
"ro": true
},
{
"path": "etc/test.ini",
"name": "test",
"action": "myip local -P"
}
]
{
"app_name": "ConfigUI",
"base_url": "/",
"config_path": "conf.json",
"no_reconfig": false,
"allow_ip": "",
"files": [
{
"path": "main.go",
"name": "GoPlayground",
"action": "go run main.go",
"ro": false,
"lang": "",
"order": 0,
"data": ""
}
],
"Actions": [
{
"name": "User",
"cmd": "whoami"
}
],
"result_bellow": false,
"hide_config": false,
"log_path": "access.log",
"silent_sys_out": false
}
```
`configui -f PATH/TO/CONFIG`
## Add custom links in nav
**config**
```json
{
"cust":"path/to/dir"
}
```
**No link**
> path/to/dir/none.tmpl
```go
{{ define "links" }}
<!-- none -->
{{end}}
```
**Custom links**
> path/to/dir/links.tmpl
```go
{{ define "links" }}
<a class="button is-white level-item"
href="https://kumoly.io/tools/configui/issues"
>Report Issue</a>
{{end}}
```
## Add integrations to ConfigUI
```go
cui.Integrations = append(cui.Integrations, &configui.Integration{
Name: "test", Description: "test", Cmd: func() (string, error) {
if rand.Int31n(40) > 20 {
return "ok", nil
}
return "", fmt.Errorf("error")
},
})
```
## Systemd
```ini
@ -64,6 +121,27 @@ Restart=always
WantedBy=multi-user.target
```
## Use as a sub route
```go
cui.BaseUrl = "/configui/"
mux := http.NewServeMux()
mux.Handle("/configui/", http.StripPrefix("/configui", cui))
```
## Use as subpath in caddy
```caddyfile
DOMAIN {
...
route /configui/* {
uri strip_prefix /configui
reverse_proxy localhost:8000
}
...
}
```
## Api
### Files

38
app.go
View File

@ -12,7 +12,7 @@ type ActiveFile struct {
RO bool
Path string
Name string
Action string
Cmd string
Content string
Order int
}
@ -23,13 +23,16 @@ type Editor struct {
}
type Page struct {
AppName string
Version string
Build string
Files []ActiveFile
Error string
File ActiveFile
Editor Editor
AppName string
BaseUrl string
Actions []Action
Integrations []*Integration
Version string
Build string
Files []ActiveFile
Error string
File ActiveFile
Editor Editor
Static bool
HideConfig bool
@ -43,6 +46,12 @@ func (cui *ConfigUI) App(w http.ResponseWriter, r *http.Request) {
}
Files := []ActiveFile{}
// for i := range cui.Files {
// Files = append(Files, ActiveFile{
// Name: cui.Files[i].Name,
// Path: cui.Files[i].Path,
// })
// }
for _, i := range cui.fileIndex {
Files = append(Files, ActiveFile{
Name: cui.Files[i].Name,
@ -62,11 +71,14 @@ func (cui *ConfigUI) App(w http.ResponseWriter, r *http.Request) {
}
data := Page{
AppName: cui.AppName,
BaseUrl: cui.BaseUrl,
File: ActiveFile{},
Files: Files,
Editor: Editor{
Platform: plat,
},
Actions: cui.Actions,
Integrations: cui.Integrations,
Static: cui.NoReconfig,
HideConfig: cui.HideConfig,
ResultBellow: cui.ResultBellow,
@ -81,14 +93,14 @@ func (cui *ConfigUI) App(w http.ResponseWriter, r *http.Request) {
if name == "" || err != nil {
tmp, err = cui.Config()
data.File.Name = cui.AppName
data.File.Path = ":mem:"
// data.File.Path = ":mem:"
data.Editor.Lang = "json"
if name != "" {
data.Error = name + " not found\n"
}
} else {
tmp, err = file.Read()
data.File.Action = file.Action
data.File.Cmd = file.Cmd
data.File.Name = file.Name
if file.Lang != "" {
data.Editor.Lang = file.Lang
@ -110,6 +122,8 @@ func (cui *ConfigUI) App(w http.ResponseWriter, r *http.Request) {
}
data.File.Content = content
cui.Parse(w, "home", data)
err = cui.tmpl.ExecuteTemplate(w, "home", data)
if err != nil {
panic(err)
}
}

View File

@ -3,11 +3,10 @@ package main
import (
"flag"
"fmt"
"io"
"log"
"net/http"
"os"
"time"
log "kumoly.io/lib/klog"
"kumoly.io/tools/configui"
)
@ -19,6 +18,7 @@ var (
flagConfigPath string
flagBind string
flagAddr string
flagNoReconfig bool
flagLogFile string
flagAllow string
@ -37,7 +37,8 @@ func init() {
flag.StringVar(&flagAction, "c", "", "cmd to apply")
flag.StringVar(&flagLogFile, "log", "", "log to file")
flag.StringVar(&flagAllow, "allow", "", "IPs to allow, blank to allow all")
flag.StringVar(&flagBind, "bind", "0.0.0.0:8000", "address to bind")
flag.StringVar(&flagBind, "bind", "0.0.0.0:8000", "address to bind, (deprecated, use -addr instead)")
flag.StringVar(&flagAddr, "addr", "0.0.0.0:8000", "address to bind")
flag.BoolVar(&flagNoReconfig, "static", false, "disable config api")
flag.BoolVar(&flagVer, "v", false, "show version")
flag.Usage = func() {
@ -54,19 +55,13 @@ func main() {
return
}
cui := configui.New()
// setup logging
if flagLogFile != "" {
f, err := os.OpenFile(flagLogFile, os.O_RDWR|os.O_CREATE|os.O_APPEND, 0666)
if err != nil {
log.Fatalf("Error opening file: %v", err)
}
defer f.Close()
mwriter := io.MultiWriter(f, os.Stderr)
log.SetOutput(mwriter)
// deprecate fix
if flagBind != "0.0.0.0:8000" && flagAddr == "0.0.0.0:8000" {
flagAddr = flagBind
}
cui := configui.New()
// setup values
if flagPath != "" {
if flagName == "" {
@ -78,34 +73,50 @@ func main() {
Action: flagAction,
}
if err := cui.AppendFile(file); err != nil {
log.Fatalln(err)
log.Error(err)
os.Exit(1)
}
} else if flagConfigPath == "" {
log.Println("no config specified")
log.Error("no config specified")
} else {
conf, err := os.ReadFile(flagConfigPath)
if err != nil {
log.Fatalln(err)
log.Error(err)
os.Exit(1)
}
cui.LoadConfig(string(conf))
if err != nil {
log.Fatalln(err)
log.Error(err)
os.Exit(1)
}
}
cui.LogPath = flagLogFile
cui.ConfigPath = flagConfigPath
cui.NoReconfig = flagNoReconfig
if flagNoReconfig {
cui.NoReconfig = flagNoReconfig
}
if flagLogFile != "" {
cui.LogPath = flagLogFile
}
if flagAllow != "" {
cui.AllowIP = flagAllow
}
// setup routes
server := &http.Server{
Addr: flagBind,
WriteTimeout: time.Second * 30,
ReadTimeout: time.Second * 30,
Handler: cui,
Addr: flagAddr,
// disable timeout due to cui controlling cmd timeouts
// WriteTimeout: time.Second * 30,
// ReadTimeout: time.Second * 30,
Handler: cui,
}
// start server
log.Println("Listening on", flagBind)
log.Fatal(server.ListenAndServe())
log.Info("Listening on ", flagAddr)
err := server.ListenAndServe()
if err != nil {
log.Error(err)
os.Exit(1)
}
}

View File

@ -6,51 +6,89 @@ import (
"errors"
"fmt"
"html/template"
"os"
"path/filepath"
"runtime"
"strings"
"sync"
"time"
"kumoly.io/lib/klog"
"kumoly.io/lib/ksrv/engine"
"kumoly.io/tools/configui/public"
)
var UNIX_SHELL = "sh"
var WIN_SHELL = "cmd"
const UNIX_SHELL = "/usr/bin/sh"
const WIN_SHELL = "C:\\Windows\\System32\\cmd"
const DARWIN_SHELL = "/bin/bash"
const version = "0.1.2-rc1"
const version = "v0.1.14"
//go:embed templates
var tmplFS embed.FS
//go:embed schema.json
var SCHEMA []byte
var Ext2Mode map[string]string = map[string]string{
"go": "golang",
"log": "sh",
"txt": "text",
"yml": "yaml",
"conf": "ini",
"md": "markdown",
}
type Action struct {
Name string `json:"name"`
Cmd string `json:"cmd"`
run chan struct{} `json:"-"`
pid int `json:"-"`
}
type Integration struct {
Name string `json:"name"`
Description string `json:"description"`
Cmd func() (string, error) `json:"-"`
run chan struct{} `json:"-"`
}
type ConfigUI struct {
AppName string `json:"app_name"`
Prod bool `json:"production"`
BaseUrl string `json:"base_url"`
ConfigPath string `json:"config_path"`
SHELL string `json:"shell"`
NoReconfig bool `json:"no_reconfig"`
AllowIP string `json:"allow_ip"`
CmdTimeout string `json:"timeout"`
cmdTimeout time.Duration
Files []*File `json:"files"`
fileIndex map[string]int
Files []*File `json:"files"`
fileIndex map[string]int
Actions []Action `json:"actions"`
Integrations []*Integration `json:"integrations,omitempty"`
// TODO
ResultBellow bool `json:"result_bellow"`
HideConfig bool `json:"hide_config"`
// Should be in main app
LogPath string `json:"log_path"`
SilentSysOut bool `json:"silent_sys_out"`
LogPath string `json:"log_path"`
LogLevel klog.Llevel `json:"log_level"`
TmplFS embed.FS `json:"-"`
tmpl *template.Template
PublicFS embed.FS `json:"-"`
TmplFS embed.FS `json:"-"`
CustTmpl string `json:"cust,omitempty"`
tmpl *engine.Engine
PublicFS embed.FS `json:"-"`
log *klog.Logger
ksrv_log *klog.Logger
f *os.File
configLock sync.Mutex
}
func New() *ConfigUI {
tmpl := template.Must(template.New("").Funcs(template.FuncMap{
tmpl := engine.Must(engine.New("").Funcs(template.FuncMap{
"step": func(from, to, step uint) []uint {
items := []uint{}
for i := from; i <= to; i += step {
@ -58,22 +96,43 @@ func New() *ConfigUI {
}
return items
},
"normal": func(name string) string {
return strings.ReplaceAll(name, " ", "-")
},
}).ParseFS(tmplFS, "templates/*.tmpl", "templates/**/*.tmpl"))
sh := ""
switch runtime.GOOS {
case "darwin":
sh = DARWIN_SHELL
case "windows":
sh = WIN_SHELL
default:
sh = UNIX_SHELL
}
return &ConfigUI{
fileIndex: map[string]int{},
AppName: "ConfigUI",
PublicFS: public.FS,
TmplFS: tmplFS,
tmpl: tmpl,
fileIndex: map[string]int{},
SHELL: sh,
Prod: true,
AppName: "ConfigUI",
BaseUrl: "/",
Actions: []Action{{Name: "User", Cmd: "whoami"}},
PublicFS: public.FS,
TmplFS: tmplFS,
tmpl: tmpl,
CmdTimeout: "10s",
cmdTimeout: time.Second * 10,
LogLevel: klog.Lerror | klog.Linfo,
log: klog.Sub("ConfigUI"),
}
}
func (cui *ConfigUI) File(file_name string) (*File, error) {
if file_name == cui.AppName {
return &File{
Path: cui.ConfigPath,
Name: cui.AppName,
Lang: "json",
Path: cui.ConfigPath,
Name: cui.AppName,
Lang: "json",
owner: cui,
}, nil
}
index, ok := cui.fileIndex[file_name]
@ -93,29 +152,111 @@ func (cui *ConfigUI) LoadConfig(confstr string) error {
// construct fileIndex
tmpIndex := map[string]int{}
for i, f := range tmpConf.Files {
if f.Name == "" && f.Path == "" {
continue
}
if f.Name == "" {
f.Name = f.Path
}
f.owner = cui
tmpIndex[f.Name] = i
// deprecated fix
if f.Cmd == "" && f.Action != "" {
f.Cmd = f.Action
f.Action = ""
}
}
// copy
cui.configLock.Lock()
defer cui.configLock.Unlock()
cui.fileIndex = tmpIndex
cui.Files = tmpConf.Files
cui.AllowIP = tmpConf.AllowIP
cui.Prod = tmpConf.Prod
klog.PROD = cui.Prod
cui.ConfigPath = tmpConf.ConfigPath
cui.HideConfig = tmpConf.HideConfig
cui.LogPath = tmpConf.LogPath
cui.NoReconfig = tmpConf.NoReconfig
cui.AppName = tmpConf.AppName
// NOT implemented
cui.ResultBellow = tmpConf.ResultBellow
cui.SilentSysOut = tmpConf.SilentSysOut
cui.SHELL = tmpConf.SHELL
cui.CustTmpl = tmpConf.CustTmpl
if cui.CustTmpl != "" {
ntmpl, err := cui.tmpl.OverrideGlob(filepath.Join(cui.CustTmpl, "*.tmpl"))
if err != nil {
cui.log.Error(err)
} else {
cui.tmpl = ntmpl
}
}
cui.Actions = tmpConf.Actions
for i := range cui.Actions {
if cui.Actions[i].Name == "" {
cui.Actions[i].Name = cui.Actions[i].Cmd
}
}
cui.AppName = tmpConf.AppName
if cui.AppName == "" {
cui.AppName = "ConfigUI"
}
cui.BaseUrl = tmpConf.BaseUrl
if cui.BaseUrl == "" {
cui.BaseUrl = "/"
}
ct, err := time.ParseDuration(tmpConf.CmdTimeout)
if err != nil || cui.CmdTimeout == "" {
cui.CmdTimeout = "10s"
cui.cmdTimeout = time.Second * 10
} else {
cui.CmdTimeout = tmpConf.CmdTimeout
cui.cmdTimeout = ct
}
cui.log = klog.Sub(cui.AppName)
cui.LogLevel = tmpConf.LogLevel
if cui.LogLevel == 0 {
cui.LogLevel = klog.Lerror | klog.Linfo
}
klog.LEVEL = cui.LogLevel
cui.LogPath = tmpConf.LogPath
cui.setLog()
// fmt.Printf("%+v", cui)
return nil
}
func (cui *ConfigUI) setLog() {
var err error
if cui.f != nil {
cui.f.Close()
}
if cui.LogPath != "" {
cui.f, err = os.OpenFile(cui.LogPath, os.O_RDWR|os.O_CREATE|os.O_APPEND, 0666)
if err != nil {
cui.log.Error(err)
}
cui.log.SetErrOutput(cui.f)
cui.log.SetOutput(cui.f)
if cui.ksrv_log != nil {
cui.ksrv_log.SetErrOutput(cui.f)
cui.ksrv_log.SetOutput(cui.f)
}
} else {
cui.log.SetErrOutput(os.Stderr)
cui.log.SetOutput(os.Stderr)
if cui.ksrv_log != nil {
cui.ksrv_log.SetErrOutput(os.Stderr)
cui.ksrv_log.SetOutput(os.Stderr)
}
}
}
func (cui *ConfigUI) Config() ([]byte, error) {
return json.MarshalIndent(cui, "", " ")
}
@ -124,6 +265,7 @@ func (cui *ConfigUI) AppendFile(file *File) error {
if file.Name == "" {
file.Name = file.Path
}
file.owner = cui
i, ok := cui.fileIndex[file.Name]
if ok {
return fmt.Errorf("%v already exists at %d", file.Name, i)

13
errors.go Normal file
View File

@ -0,0 +1,13 @@
package configui
import (
"net/http"
"kumoly.io/lib/ksrv"
)
var ErrorServerReloading = ksrv.Error{
Code: http.StatusServiceUnavailable,
ID: "ErrorServerReloading",
Message: "server is reloading",
}

94
file.go
View File

@ -1,9 +1,9 @@
package configui
import (
"encoding/json"
"bytes"
"errors"
"log"
"fmt"
"os"
"os/exec"
"runtime"
@ -12,20 +12,26 @@ import (
)
type File struct {
Path string `json:"path"`
Name string `json:"name"`
Action string `json:"action"`
Path string `json:"path"`
Name string `json:"name"`
Cmd string `json:"cmd"`
// RO is readonly
RO bool `json:"ro"`
Lang string `json:"lang"`
Lang string `json:"lang,omitempty"`
// Order order of the display on ui
Order int `json:"order"`
// used for parsing post data
Data string `json:"data"`
Data string `json:"data,omitempty"`
lock sync.RWMutex `json:"-"`
lock sync.RWMutex `json:"-"`
owner *ConfigUI `json:"-"`
run chan struct{} `json:"-"`
pid int `json:"-"`
// deprecated
Action string `json:"action,omitempty"`
}
func (f *File) Read() ([]byte, error) {
@ -33,7 +39,7 @@ func (f *File) Read() ([]byte, error) {
defer f.lock.RUnlock()
data, err := os.ReadFile(f.Path)
if err != nil {
log.Println(err)
f.owner.log.Error(err)
return nil, err
}
return data, nil
@ -52,57 +58,55 @@ func (f *File) Write(data []byte) error {
return os.WriteFile(f.Path, data, info.Mode())
}
func (f *File) Do() (string, error) {
if f.Action == "" {
func (f *File) Do(CmdTimeout time.Duration, report chan int) (string, error) {
if f.Cmd == "" {
return "", nil
}
f.lock.RLock()
defer f.lock.RUnlock()
// limit running instance to 1
if cap(f.run) == 0 {
f.run = make(chan struct{}, 1)
}
select {
case f.run <- struct{}{}:
defer func() { <-f.run }()
default:
f.owner.log.Error("task is running: ", f.Name)
return "", fmt.Errorf("another task of %s is running with pid: %d", f.Name, f.pid)
}
// prepare cmd
cmd := &exec.Cmd{}
if runtime.GOOS == "windows" {
cmd = exec.Command(WIN_SHELL, "/c", f.Action)
cmd = exec.Command(f.owner.SHELL, "/c", f.Cmd)
} else {
cmd = exec.Command(UNIX_SHELL, "-c", f.Action)
cmd = exec.Command(f.owner.SHELL, "-c", f.Cmd)
}
log.Println("DO: ", f.Action)
f.owner.log.Info("DO: ", f.Cmd)
done := make(chan string, 1)
var b bytes.Buffer
cmd.Stdout = &b
cmd.Stderr = &b
err := cmd.Start()
if err != nil {
f.owner.log.Error("cmd start error: ", err)
panic(err)
}
go func() {
out, _ := cmd.CombinedOutput()
// real cmd err is unhandled, but passed to client
// if err != nil {
// return string(out), err
// }
done <- string(out)
f.pid = cmd.Process.Pid
report <- cmd.Process.Pid
cmd.Wait()
done <- b.String()
}()
select {
case <-time.After(10 * time.Second):
case <-time.After(CmdTimeout):
cmd.Process.Kill()
log.Println("timeout")
f.owner.log.Error("timeout")
return "", errors.New("command timeout")
case out := <-done:
log.Printf("\n%v", out)
f.owner.log.Info("\n", out)
return out, nil
}
}
func ReadConfig(confstr string) ([]File, error) {
conf := []File{}
err := json.Unmarshal([]byte(confstr), &conf)
if err != nil {
return nil, err
}
for i := range conf {
if conf[i].Name == "" {
conf[i].Name = conf[i].Path
}
}
return conf, nil
}
func GetFileMap(files []File) map[string]*File {
fileMap := map[string]*File{}
for i := range files {
fileMap[files[i].Name] = &files[i]
}
return fileMap
}

14
go.mod
View File

@ -1,3 +1,17 @@
module kumoly.io/tools/configui
go 1.17
require (
kumoly.io/lib/klog v0.0.8
kumoly.io/lib/ksrv v0.0.2-0.20211112060911-0d61b343a298
kumoly.io/lib/stat v0.0.1
kumoly.io/tools/kconfig v0.1.3
)
require (
github.com/mattn/go-isatty v0.0.14 // indirect
github.com/rs/zerolog v1.26.0 // indirect
golang.org/x/sys v0.0.0-20211103235746-7861aae1554b // indirect
kumoly.io/lib/guard v0.1.2-0.20211124052638-9dfd98f9a848 // indirect
)

44
go.sum Normal file
View File

@ -0,0 +1,44 @@
github.com/coreos/go-systemd/v22 v22.3.2/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/mattn/go-isatty v0.0.14 h1:yVuAays6BHfxijgZPzw+3Zlu5yQgKGP2/hcQbHb7S9Y=
github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/rs/xid v1.3.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg=
github.com/rs/zerolog v1.26.0 h1:ORM4ibhEZeTeQlCojCK2kPz1ogAY4bGs4tD+SaAdGaE=
github.com/rs/zerolog v1.26.0/go.mod h1:yBiM87lvSqX8h0Ww4sdzNSkVYZ8dL2xjZJG1lAuGZEo=
github.com/yuin/goldmark v1.4.0/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210805182204-aaa1db679c0d/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20211103235746-7861aae1554b h1:1VkfZQv42XQlA/jchYumAnv1UPo6RgF9rJFkTgZIxO4=
golang.org/x/sys v0.0.0-20211103235746-7861aae1554b/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.7/go.mod h1:LGqMHiF4EqQNHR1JncWGqT5BVaXmza+X+BDGol+dOxo=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
kumoly.io/lib/guard v0.1.2-0.20211124052638-9dfd98f9a848 h1:ALCeJga3775AJDkk7YbMQEcYxpwHtinUN+YjkpacNhA=
kumoly.io/lib/guard v0.1.2-0.20211124052638-9dfd98f9a848/go.mod h1:yWg9RDSI6YXkOPmP6Ad93aMqzlxhgW8LOe/ZRjjYX3U=
kumoly.io/lib/klog v0.0.8 h1:6hTfDlZh7KGnPrd2tUrauCKRImSnyyN9DHXpey3Czn8=
kumoly.io/lib/klog v0.0.8/go.mod h1:Snm+c1xRrh/RbXsxQf7UGYbAJGPcIa6bEEN+CmzJh7M=
kumoly.io/lib/ksrv v0.0.2-0.20211112060911-0d61b343a298 h1:0raqoIXmNpD6s1SrJbieAyIIkDyhe+aqfaXvx8wenrI=
kumoly.io/lib/ksrv v0.0.2-0.20211112060911-0d61b343a298/go.mod h1:pwd+NspxnoxPJAETRY2V4i2qZc+orKLxvWzGUBiqBW8=
kumoly.io/lib/stat v0.0.1 h1:Ck596El7Ixk7GZyzQq/86F1YCl7iYffHmzEdFx1sSRM=
kumoly.io/lib/stat v0.0.1/go.mod h1:zqMV9q4TC94VGbpDn/mGBTwRNWBVWlVg1taLlCBAWc8=
kumoly.io/tools/kconfig v0.1.3 h1:okLWqlvASZzfAj3kXzpB/Q8V7WZNd24Y6MLPhWPTwP8=
kumoly.io/tools/kconfig v0.1.3/go.mod h1:j6GYUb50c0pFMxfQgoUl5n4P++AapoG+L5YHxHyyjN4=

View File

@ -2,10 +2,14 @@ package configui
import (
"encoding/json"
"fmt"
"io/ioutil"
"net/http"
"os"
"path/filepath"
"strconv"
"kumoly.io/lib/ksrv"
)
func (cui *ConfigUI) ListFiles(w http.ResponseWriter, r *http.Request) {
@ -20,18 +24,67 @@ func (cui *ConfigUI) GetFile(w http.ResponseWriter, r *http.Request) {
name := r.URL.Query().Get("name")
file, err := cui.File(name)
if err != nil {
response(w, 404, []byte("file not found"))
ksrv.Response(w, 404, []byte("file not found"))
return
}
data, err := file.Read()
if err != nil {
panic(err)
}
stat, err := os.Stat(file.Path)
if err != nil {
panic(err)
}
response, err := json.Marshal(map[string]string{
"path": file.Path,
"name": file.Name,
"action": file.Action,
"data": string(data),
"path": file.Path,
"name": file.Name,
"cmd": file.Cmd,
"data": string(data),
"delta": strconv.Itoa(int(stat.Size())),
})
if err != nil {
panic(err)
}
w.Header().Set("Content-Type", "application/json")
w.Write(response)
}
func (cui *ConfigUI) GetDelta(w http.ResponseWriter, r *http.Request) {
name := r.URL.Query().Get("name")
delta, err := strconv.ParseInt(r.URL.Query().Get("delta"), 10, 64)
if err != nil {
panic(err)
}
file, err := cui.File(name)
if err != nil {
ksrv.Response(w, 404, []byte("file not found"))
return
}
f, err := os.Open(file.Path)
if err != nil {
panic(err)
}
defer f.Close()
stat, err := os.Stat(file.Path)
if err != nil {
panic(err)
}
if delta > stat.Size() {
panic(fmt.Errorf("delta(%d) is larger than file size(%d), the file might have been changed", delta, stat.Size()))
}
buf := make([]byte, stat.Size()-delta)
_, err = f.ReadAt(buf, delta)
if err != nil {
panic(err)
}
response, err := json.Marshal(map[string]string{
"path": file.Path,
"name": file.Name,
"cmd": file.Cmd,
"data": string(buf),
"delta": strconv.Itoa(int(stat.Size())),
})
if err != nil {
panic(err)
@ -52,7 +105,7 @@ func (cui *ConfigUI) PostFile(w http.ResponseWriter, r *http.Request) {
}
file, err := cui.File(f.Name)
if err != nil {
response(w, 404, []byte("file not found"))
ksrv.Response(w, 404, []byte("file not found"))
return
}
if err := file.Write([]byte(f.Data)); err != nil {
@ -65,16 +118,75 @@ func (cui *ConfigUI) Apply(w http.ResponseWriter, r *http.Request) {
name := r.URL.Query().Get("name")
file, err := cui.File(name)
if err != nil {
response(w, 404, []byte("file not found"))
ksrv.Response(w, 404, []byte("file not found"))
return
}
result, err := file.Do()
result, err := file.Do(cui.cmdTimeout, make(chan int, 1))
if err != nil {
panic(err)
}
w.Write([]byte(result))
}
func (cui *ConfigUI) DoAction(w http.ResponseWriter, r *http.Request) {
name := r.URL.Query().Get("name")
for i, v := range cui.Actions {
if v.Name == name {
// limit running instance to one
if cap(cui.Actions[i].run) != 1 {
cui.Actions[i].run = make(chan struct{}, 1)
}
select {
case cui.Actions[i].run <- struct{}{}:
defer func() { <-cui.Actions[i].run }()
default:
panic(fmt.Errorf("another task of %s is running with pid: %d", name, v.pid))
}
file := &File{Name: name, Cmd: v.Cmd, owner: cui}
pid := make(chan int)
go func() {
cui.Actions[i].pid = <-pid
}()
result, err := file.Do(cui.cmdTimeout, pid)
if err != nil {
panic(err)
}
w.Write([]byte(result))
return
}
}
panic(fmt.Errorf("no action named: %v", name))
}
func (cui *ConfigUI) DoIntegration(w http.ResponseWriter, r *http.Request) {
name := r.URL.Query().Get("name")
for i, v := range cui.Integrations {
if v.Name == name {
// limit running instance to one
if cap(cui.Integrations[i].run) != 1 {
cui.Integrations[i].run = make(chan struct{}, 1)
}
select {
case cui.Integrations[i].run <- struct{}{}:
defer func() { <-cui.Integrations[i].run }()
default:
panic(fmt.Errorf("another task of %s is running", name))
}
result, err := v.Cmd()
if err != nil {
panic(err)
}
w.Write([]byte(result))
return
}
}
panic(fmt.Errorf("no integration named: %v", name))
}
func (cui *ConfigUI) Download(w http.ResponseWriter, r *http.Request) {
if name := r.URL.Query().Get("name"); name != "" {
if name == cui.AppName {
@ -83,15 +195,15 @@ func (cui *ConfigUI) Download(w http.ResponseWriter, r *http.Request) {
panic(err)
}
w.Header().Set(
"Content-Disposition", `
attachment; filename="`+cui.AppName+`.json"`,
"Content-Disposition",
`attachment; filename="`+cui.AppName+`.json"`,
)
w.Write(data)
return
}
file, err := cui.File(name)
if err != nil {
response(w, 404, []byte("file not found"))
ksrv.Response(w, 404, []byte("file not found"))
return
}
data, err := file.Read()

1
log.go
View File

@ -1 +0,0 @@
package configui

View File

@ -1,68 +0,0 @@
package configui
import (
"bytes"
"log"
"net/http"
"strconv"
)
type CuiResponseWriter struct {
http.ResponseWriter
StatueCode int
}
func (w *CuiResponseWriter) WriteHeader(statusCode int) {
if w.StatueCode != 0 {
return
}
w.StatueCode = statusCode
w.ResponseWriter.WriteHeader(statusCode)
}
func (w *CuiResponseWriter) Write(body []byte) (int, error) {
if w.StatueCode == 0 {
w.WriteHeader(200)
}
return w.ResponseWriter.Write(body)
}
func response(w http.ResponseWriter, status int, body []byte) (int, error) {
w.WriteHeader(status)
return w.Write(body)
}
func abort(w http.ResponseWriter, err interface{}) (int, error) {
switch v := err.(type) {
case int:
w.WriteHeader(v)
return w.Write([]byte(strconv.Itoa(v)))
case string:
w.WriteHeader(500)
return w.Write([]byte(v))
case error:
w.WriteHeader(500)
return w.Write([]byte(v.Error()))
default:
w.WriteHeader(500)
return w.Write([]byte(strconv.Itoa(500)))
}
}
func catch(rw *CuiResponseWriter, r *http.Request) {
ex := recover()
if ex != nil {
abort(rw, ex)
log.Printf("%s %s %d %s %s\n", GetIP(r), r.Method, rw.StatueCode, r.URL, r.Header.Get("User-Agent"))
}
}
func (cui *ConfigUI) Parse(w http.ResponseWriter, name string, data interface{}) error {
buf := &bytes.Buffer{}
err := cui.tmpl.ExecuteTemplate(buf, "home", data)
if err != nil {
panic(err)
}
_, err = w.Write(buf.Bytes())
return err
}

5961
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -17,10 +17,6 @@
"dependencies": {
"@creativebulma/bulma-tooltip": "^1.2.0",
"bulma": "^0.9.3",
"prismjs": "^1.25.0",
"sass": "^1.43.2"
},
"devDependencies": {
"parcel": "^2.0.0"
}
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -9,11 +9,11 @@ HUB=hub.kumoly.io
HUB_PROJECT=tools
DIST=dist
LDFLAGS="-ldflags \"-X main.Version=${VERSION} -X main.Build=${BUILD} -w\""
LDFLAGS="-ldflags \"-X main.Version=${VERSION} -X main.Build=${BUILD} -w -s\""
FAILURES=""
PLATFORMS="darwin/amd64 darwin/arm64"
PLATFORMS="$PLATFORMS windows/amd64" # arm compilation not available for Windows
PLATFORMS="$PLATFORMS windows/amd64"
PLATFORMS="$PLATFORMS linux/amd64"

148
schema.json Normal file
View File

@ -0,0 +1,148 @@
{
"title": "ConfigUI",
"type": "object",
"id": "configui",
"properties": {
"app_name": {
"type": "string",
"title": "AppName",
"default": "ConfigUI",
"required":true
},
"files":{
"title":"Files",
"type": "array",
"format":"table",
"items":{
"$ref": "#/definitions/file"
}
},
"actions":{
"title":"Actions",
"type": "array",
"format":"table",
"items":{
"$ref": "#/definitions/action"
}
},
"base_url":{
"type": "string",
"title": "BaseUrl",
"default": "/"
},
"config_path":{
"title":"Config Path",
"type": "string",
"discription":"path to config file"
},
"shell":{
"title":"Shell",
"type": "string",
"description":"shell to use when running cmds",
"required":true,
"options":{
"infoText":"run commands are structured as SHELL -c \"CMD\", \nthe default shell is \nunix:`/usr/bin/sh`\nwindows:`C:\\Windows\\System32\\cmd`\ndarwin:`/bin/bash`"
}
},
"allow_ip":{
"type":"string",
"title":"AllowedIP",
"description":"IPs to allow, blank to allow all"
},
"timeout":{
"type": "string",
"title": "Command Timeout",
"default": "10s",
"description": "timeout to wait for command to finish"
},
"log_path": {
"type":"string",
"title":"Log Path",
"description":"empty for stdout"
},
"log_level": {
"type": "integer",
"title": "log level",
"default": 9
},
"cust":{
"title": "Custom Template",
"type": "string",
"description": "path to custom templates"
},
"result_bellow": {
"title":"Result Bellow",
"type": "boolean",
"format": "checkbox",
"description":"show results bellow editor",
"default": false
},
"production":{
"type": "boolean",
"format": "checkbox",
"title": "Production Mode",
"default": true
},
"no_reconfig":{
"title":"NoReconfig",
"type": "boolean",
"format": "checkbox",
"description":"disable config at runtime",
"default": false
},
"hide_config": {
"title":"Hide Config",
"type": "boolean",
"format": "checkbox",
"description":"hide config panel",
"default": false
}
},
"definitions": {
"file": {
"type": "object",
"properties":{
"name": {
"title": "Name",
"type": "string"
},
"path": {
"title": "Path",
"type": "string"
},
"cmd": {
"title": "Command",
"type": "string"
},
"ro": {
"title": "Read Only",
"format":"checkbox",
"type": "boolean"
},
"lang": {
"title": "Lang",
"type": "string",
"description":"set editor language mode"
},
"order": {
"type": "integer",
"default":0
}
}
},
"action":{
"type": "object",
"properties":{
"name": {
"title": "Name",
"type": "string"
},
"cmd": {
"title": "Command",
"type": "string"
}
}
}
}
}

View File

@ -1,9 +1,12 @@
package configui
import (
"log"
"net/http"
"strings"
"path/filepath"
"kumoly.io/lib/ksrv"
"kumoly.io/lib/stat"
"kumoly.io/tools/kconfig"
)
func (cui *ConfigUI) ServeHTTP(w http.ResponseWriter, r *http.Request) {
@ -11,28 +14,48 @@ func (cui *ConfigUI) ServeHTTP(w http.ResponseWriter, r *http.Request) {
}
func (cui *ConfigUI) middleware(next http.Handler) http.Handler {
return http.HandlerFunc(
func(w http.ResponseWriter, r *http.Request) {
rw := &CuiResponseWriter{w, 0}
defer catch(rw, r)
ip := GetIP(r)
k := ksrv.New()
cui.ksrv_log = k.GetLogger()
cui.setLog()
k.SetNoLogCondition(func(r *http.Request) bool {
ext := filepath.Ext(r.URL.Path)
switch ext {
case ".js":
return true
case ".css":
return true
case ".ttf":
return true
case ".ico":
return true
}
return r.URL.Query().Get("f") == "true"
})
return k.Middleware(
http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
if cui.AllowIP != "" {
if !matchIPGlob(ip, cui.AllowIP) {
ip := ksrv.GetIP(r)
if !ksrv.MatchIPGlob(ip, cui.AllowIP) {
rw.WriteHeader(403)
panic("permission denied")
}
}
next.ServeHTTP(rw, r)
//logging
if !strings.HasPrefix(r.URL.Path, "/public") && r.URL.Query().Get("f") != "true" {
log.Printf("%s %s %d %s %s\n", ip, r.Method, rw.StatueCode, r.URL, r.Header.Get("User-Agent"))
}
},
}),
)
}
func (cui *ConfigUI) mux() *http.ServeMux {
cuiR := http.NewServeMux()
cuiR.HandleFunc("/api/profile", func(w http.ResponseWriter, r *http.Request) {
if r.Method == "GET" {
ksrv.JSON(w, stat.GetProfile())
} else {
w.WriteHeader(404)
}
})
cuiR.HandleFunc("/api/conf", func(w http.ResponseWriter, r *http.Request) {
if r.Method == "GET" {
cui.GetConfig(w, r)
@ -58,6 +81,13 @@ func (cui *ConfigUI) mux() *http.ServeMux {
w.WriteHeader(404)
}
})
cuiR.HandleFunc("/api/delta", func(w http.ResponseWriter, r *http.Request) {
if r.Method == "GET" {
cui.GetDelta(w, r)
} else {
w.WriteHeader(404)
}
})
cuiR.HandleFunc("/api/apply", func(w http.ResponseWriter, r *http.Request) {
if r.Method == "POST" {
cui.Apply(w, r)
@ -72,6 +102,40 @@ func (cui *ConfigUI) mux() *http.ServeMux {
w.WriteHeader(404)
}
})
cuiR.HandleFunc("/api/action", func(w http.ResponseWriter, r *http.Request) {
if r.Method == "POST" {
cui.DoAction(w, r)
} else {
w.WriteHeader(404)
}
})
cuiR.HandleFunc("/api/integration", func(w http.ResponseWriter, r *http.Request) {
if r.Method == "POST" {
cui.DoIntegration(w, r)
} else {
w.WriteHeader(404)
}
})
k := kconfig.New()
k.Schema = SCHEMA
k.Load = func() []byte {
b, err := cui.Config()
if err != nil {
cui.log.Error(err)
return []byte{}
}
return b
}
k.Apply = func(b []byte) error {
err := cui.LoadConfig(string(b))
if err == nil {
k.AppName = cui.AppName
}
return err
}
cuiR.Handle("/kconfig/", http.StripPrefix("/kconfig", k))
cuiR.Handle("/public/", http.StripPrefix("/public/", http.FileServer(http.FS(cui.PublicFS))))
cuiR.HandleFunc("/", cui.App)
return cuiR

View File

@ -22,6 +22,13 @@ $footer-padding: 0.5rem 1.5rem 0.5rem;
padding: 0.5rem;
}
#kconfigFrame{
border: none;
width: 100%;
height: 100%;
display: block;
}
.icn-spinner {
animation: spin-animation 1s infinite;
display: inline-block;
@ -40,3 +47,9 @@ $footer-padding: 0.5rem 1.5rem 0.5rem;
transform: rotate(359deg);
}
}
.ace_selection {
// background: #999900 !important;
background: #7f7f00 !important;
}

View File

@ -2,7 +2,7 @@
font-family: 'Material Icons';
font-style: normal;
font-weight: 400;
src: url(/public/assets/mat.ttf) format('truetype');
src: url(../assets/mat.ttf) format('truetype');
}
.material-icons {

View File

@ -2,12 +2,13 @@
<footer class="footer">
<div class="content has-text-right">
<p>
<strong>Configui</strong> {{.Version}} by <a href="https://kumoly.io/evanchen">Evan Chen</a>.
<strong>Configui</strong> {{.Version}} by <a href="mailto:evanchen@kumoly.io">Evan Chen</a>.
</p>
</div>
</footer>
<script src="/public/ace/js/ace.js" type="text/javascript" charset="utf-8"></script>
<script src="/public/ace/js/ext-beautify.js" type="text/javascript" charset="utf-8"></script>
<script src="{{.BaseUrl}}public/ace/js/ace.js" type="text/javascript" charset="utf-8"></script>
<script src="{{.BaseUrl}}public/ace/js/ext-beautify.js" type="text/javascript" charset="utf-8"></script>
<script src="{{.BaseUrl}}public/ace/js/ext-searchbox.js" type="text/javascript" charset="utf-8"></script>
{{template "base/script" .}}
<!-- <script src="/public/js/main.js"></script> -->
</body>

View File

@ -4,8 +4,8 @@
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>ConfigUI</title>
<link rel="stylesheet" href="/public/css/main.css">
<title>{{.AppName}}</title>
<link rel="stylesheet" href="{{.BaseUrl}}public/css/main.css">
</head>
<body>
{{end}}

View File

@ -1,37 +1,37 @@
{{define "base/script"}}
<script>
window.ToolIsFollow = false;
window.LastDelta = 0;
window.ContentChanged = false;
async function FileGet(follower=false){
let f = ''
if (Active == 'ConfigUI') {
if (follower) f = '?f=true'
const res = await fetch('/api/conf'+f)
async function FileGet(){
if (Active == '{{.AppName}}') {
const res = await fetch('{{.BaseUrl}}api/conf')
.catch(err=>{console.log(err);return;});
const body = await res.text();
if(!res.ok){
Catch(res)
return
}
LastDelta = 0;
editor.session.setValue(body);
}
else {
if (follower) f = '&f=true'
const res = await fetch('/api/file?name=' + Active + f)
const res = await fetch('{{.BaseUrl}}api/file?name=' + Active)
.catch(err=>{console.log(err);return;});
const body = await res.json();
if(!res.ok){
Catch(res)
return
}
LastDelta = body.delta;
editor.session.setValue(body.data);
}
}
async function FileSave(){
if (Active == '{{.AppName}}') {
const res = await fetch('/api/conf', {
const res = await fetch('{{.BaseUrl}}api/conf', {
method: 'POST',
body: editor.getValue(),
headers: new Headers({
@ -45,7 +45,7 @@ async function FileSave(){
else Catch(res)
}
else {
const res = await fetch('/api/file', {
const res = await fetch('{{.BaseUrl}}api/file', {
method: 'POST',
body: JSON.stringify({"name":Active,"data":editor.getValue()}),
headers: new Headers({
@ -62,7 +62,7 @@ async function FileApply(){
return;
}
else {
const res = await fetch('/api/apply?name='+ Active, {
const res = await fetch('{{.BaseUrl}}api/apply?name='+ Active, {
method: 'POST',
});
if(!res.ok){
@ -75,6 +75,38 @@ async function FileApply(){
}
}
async function DoAction(type, name){
const res = await fetch('{{.BaseUrl}}api/'+type+'?name='+ name, {
method: 'POST',
});
if(!res.ok){
const result = await Catch(res)
result_editor.session.setValue(result)
return
}
const result = await res.text()
result_editor.session.setValue(result)
}
async function FileDelta(){
if (Active == '{{.AppName}}') {
await FileGet()
return
}
else {
const res = await fetch('{{.BaseUrl}}api/delta?f=true&name=' + Active+'&delta='+LastDelta)
.catch(err=>{console.log(err);return;});
if(!res.ok){
await Catch(res)
FileGet()
return
}
const body = await res.json();
LastDelta = body.delta;
editor.session.setValue(editor.getValue()+body.data);
}
}
async function Catch(res){
{{/* console.trace()
console.log(res) */}}
@ -91,8 +123,16 @@ function setResult(){
result_editor.session.setMode("ace/mode/sh");
result_editor.setReadOnly(true);
result_editor.setOptions({
maxLines: 1000
maxLines: Infinity
});
{{if not .ResultBellow}}
document.addEventListener('keydown', function (e) {
if (e.key === "Escape"){
let el = document.getElementById('result_view')
el.classList.remove('is-active')
}
})
{{end}}
}
// starting point
@ -107,7 +147,7 @@ function setResult(){
// for follow mode
setInterval((async ()=>{
if (ToolIsFollow){
await FileGet(true)
await FileDelta()
editor.gotoLine(editor.session.getLength());
}
}), 1000)

View File

@ -30,6 +30,25 @@ function setEditor(){
},
readOnly: false // false if this command should not apply in readOnly mode
});
editor.commands.addCommand({
name: 'Reload',
bindKey: {win: 'F5', mac: 'Command-R'},
exec: function(editor) {
if (window.ContentChanged)
if(confirm("You have unsaved changes, are you sure to reload?"))
window.location.reload()
},
readOnly: false
});
editor.commands.addCommand({
name: 'SaveAndApply',
bindKey: {win: 'Ctrl-E', mac: 'Command-E'},
exec: function(editor) {
if ('{{.File.Name}}'=='{{.AppName}}') toolSave()
else toolApply()
},
readOnly: true
});
}
</script>
{{end}}

View File

@ -1,6 +1,6 @@
{{define "components/error"}}
<div class="notification is-danger {{if not .Error}}is-hidden{{end}}" id="error_view">
<button class="delete" onclick="ErrorTog()"></button>
<button class="delete" onclick="ErrorClose()"></button>
<p id="err_msg">{{.Error}}</p>
</div>
<script>
@ -8,10 +8,18 @@ function ErrorTog(){
let el = document.getElementById('error_view');
el.classList.toggle('is-hidden');
}
function ErrorOpen(){
let el = document.getElementById('error_view');
el.classList.remove('is-hidden');
}
function ErrorClose(){
let el = document.getElementById('error_view');
el.classList.add('is-hidden');
}
function ShowError(msg){
let el = document.getElementById('err_msg');
el.innerHTML = msg;
ErrorTog();
ErrorOpen();
}
</script>
{{end}}

View File

@ -0,0 +1,42 @@
{{define "components/kconfig"}}
<div class="modal" id="kconfig">
<div class="modal-background"></div>
<div class="modal-card" style="max-width:960px">
<header class="modal-card-head">
<p class="modal-card-title">{{.AppName}} Config</p>
<button class="delete" aria-label="close" onclick="KconfigViewTog()"></button>
</header>
<section class="modal-card-body" style="height:80vh;">
<iframe src="{{.BaseUrl}}kconfig/" id="kconfigFrame"></iframe>
</section>
<footer class="modal-card-foot">
<div class="level">
<div class="level-left"></div>
<div class="level-left">
<button class="button" onclick="KconfigViewTog()">OK</button>
</div>
</div>
</footer>
</div>
</div>
<script>
document.addEventListener('keydown', function (e) {
if (e.key === "Escape"){
let el = document.getElementById('kconfig')
el.classList.remove('is-active')
}
})
function KSubmit(){
window.location.reload();
window.ContentChanged = false
}
function KconfigViewTog(){
let el = document.getElementById('kconfig')
el.classList.toggle('is-active')
}
</script>
{{end}}

View File

@ -9,7 +9,7 @@
<li>
<a class="has-tooltip-arrow {{if eq $.File.Name .Name }}is-active{{end}}"
data-tooltip="{{.Path}}"
href="/?name={{.Name}}"
href="{{$.BaseUrl}}?name={{.Name}}"
>{{ .Name }}</a>
</li>
{{ end }}
@ -21,8 +21,8 @@
<ul class="menu-list">
<li>
<a class="has-tooltip-arrow {{if eq .File.Name .AppName }}is-active{{end}}"
data-tooltip="{{$.File.Path}}"
href="/"
data-tooltip="Configuration"
href="{{.BaseUrl}}"
>Config</a></li>
</ul>
{{end}}

View File

@ -7,7 +7,7 @@
<button class="delete" aria-label="close" onclick="ResultViewTog()"></button>
</header>
<section class="modal-card-body">
<p class="title">{{.File.Action}}</p>
<p class="title">{{.File.Cmd}}</p>
<p class="subtitle">{{.File.Path}}</p>
<div id="result_editor"></div>
</section>

View File

@ -91,10 +91,10 @@
{{end}}
</p>
{{end}}
{{if .File.Action}}
{{if .File.Cmd}}
<p class="control">
<button class="button is-small has-tooltip-arrow"
data-tooltip="{{.File.Action}}"
data-tooltip="{{.File.Cmd}}"
onclick="toolApply()">
<span class="icon is-small"><span class="material-icons"id="toolApplyIco">
play_arrow
@ -105,7 +105,7 @@
<p class="control">
<a class="button is-small has-tooltip-arrow" data-tooltip="Download"
href="/api/export?name={{.File.Name}}">
href="{{.BaseUrl}}api/export?name={{.File.Name}}">
<span class="icon is-small"><span class="material-icons">
download
</span></span>
@ -164,20 +164,24 @@ function toolSetFontSize(obj){
function toolFollow(){
if (ToolIsFollow){
ToolIsFollow = false
ToolIsFollow = false;
editor.setReadOnly({{.File.RO}});
let el = document.getElementById('toolFollow');
let icon = document.getElementById('toolFollowIcon');
el.classList.remove('is-primary');
icon.classList.remove('icn-spinner');
icon.innerText='sync_disabled';
} else {
ToolIsFollow = true
editor.setReadOnly(true);
let el = document.getElementById('toolFollow');
let icon = document.getElementById('toolFollowIcon');
el.classList.add('is-primary');
icon.innerText='sync';
icon.classList.add('icn-spinner');
editor.gotoLine(editor.session.getLength());
FileGet().then(()=>{
ToolIsFollow = true
})
}
}

View File

@ -1,25 +1,63 @@
{{define "home"}}
{{template "base/header" .}}
{{template "components/error" .}}
{{template "components/kconfig" .}}
<script>
var Active = "{{.File.Name}}";
</script>
<section class="hero is-small is-primary">
<div class="hero-body" id="title">
<p class="title">
{{.AppName}}
</p>
<nav class="level py-1">
<div class="level-left">
<div class="level-item">
<a class="title is-size-4 ml-2" href="{{.BaseUrl}}">
{{.AppName}}
</a>
</div>
</div>
</section>
<div class="level-right">
{{ block "links" .}}
<a class="button is-white level-item"
href="https://kumoly.io/tools/configui/issues"
>Report Bug</a>
{{end}}
<p class="level-item mr-2"></p>
</div>
</nav>
<div class="columns">
<div class="column is-one-quarter">
<div class="box">
{{template "components/menu" .}}
</div>
{{if .Actions}}
<div class="box">
<p class="menu-label has-text-left">Actions</p>
<div class="buttons are-small">
{{- range .Actions -}}
<button class="button has-tooltip-arrow" id="actbtn-{{.Name|normal}}"
data-tooltip="{{.Cmd}}" onclick="toolDoAction('{{.Name}}')">
<span>{{- .Name -}}</span>
</button>
{{- end -}}
</div>
</div>
{{end}}
{{if .Integrations}}
<div class="box">
<p class="menu-label has-text-left">Integrations</p>
<div class="buttons are-small">
{{- range .Integrations -}}
<button class="button has-tooltip-arrow" id="itgbtn-{{.Name|normal}}"
data-tooltip="{{.Description}}" onclick="toolDoIntegration('{{.Name}}')">
<span>{{- .Name -}}</span>
</button>
{{- end -}}
</div>
</div>
{{end}}
<div class="box has-text-centered">
<a href="/api/export" class="button is-small">Export Files</a>
<a href="{{.BaseUrl}}api/export" class="button is-small">Export Files</a>
{{if not .HideConfig}}<a onclick="KconfigViewTog()" class="button is-small">Config</a>{{end}}
</div>
</div>
<div class="column">
@ -37,6 +75,28 @@ var Active = "{{.File.Name}}";
{{end}}
</div>
</div>
<script>
async function toolDoAction(name){
let el = document.getElementById('actbtn-'+name.replaceAll(" ","-"));
el.classList.add("is-loading")
el.classList.remove("has-tooltip-arrow")
await DoAction('action',name)
el.classList.remove("is-loading")
el.classList.add("has-tooltip-arrow")
{{if not .ResultBellow}}ResultViewTog(){{end}}
}
async function toolDoIntegration(name){
let el = document.getElementById('itgbtn-'+name.replaceAll(" ","-"));
el.classList.add("is-loading")
el.classList.remove("has-tooltip-arrow")
await DoAction('integration',name)
el.classList.remove("is-loading")
el.classList.add("has-tooltip-arrow")
{{if not .ResultBellow}}ResultViewTog(){{end}}
}
</script>
{{if not .ResultBellow}}
{{template "components/result" .}}
{{end}}

79
util.go
View File

@ -4,55 +4,13 @@ import (
"archive/tar"
"compress/gzip"
"io"
"net"
"net/http"
"os"
"path/filepath"
"reflect"
"strings"
"sync"
)
func matchIPGlob(ip, pattern string) bool {
parts := strings.Split(pattern, ".")
seg := strings.Split(ip, ".")
for i, part := range parts {
// normalize pattern to 3 digits
switch len(part) {
case 1:
if part == "*" {
part = "***"
} else {
part = "00" + part
}
case 2:
if strings.HasPrefix(part, "*") {
part = "*" + part
} else if strings.HasSuffix(part, "*") {
part = part + "*"
} else {
part = "0" + part
}
}
// normalize ip to 3 digits
switch len(seg[i]) {
case 1:
seg[i] = "00" + seg[i]
case 2:
seg[i] = "0" + seg[i]
}
for j := range part {
if string(part[j]) == "*" {
continue
}
if part[j] != seg[i][j] {
return false
}
}
}
return true
}
func bundle(buf io.Writer, paths []string, rootDir string, flat bool) error {
gw := gzip.NewWriter(buf)
defer gw.Close()
@ -111,19 +69,24 @@ func bundle(buf io.Writer, paths []string, rootDir string, flat bool) error {
return nil
}
func GetIP(r *http.Request) string {
ip := r.Header.Get("X-Real-Ip")
if ip == "" {
ips := r.Header.Get("X-Forwarded-For")
ipArr := strings.Split(ips, ",")
ip = strings.Trim(ipArr[len(ipArr)-1], " ")
}
if ip == "" {
var err error
ip, _, err = net.SplitHostPort(r.RemoteAddr)
if err != nil {
ip = r.RemoteAddr
func mkdir(args ...interface{}) error {
var path string
var mode os.FileMode
mode = 0755
for _, arg := range args {
switch arg := arg.(type) {
case string:
path = filepath.Join(path, arg)
case os.FileMode:
mode = arg
}
}
return ip
return os.MkdirAll(path, mode)
}
const mutexLocked = 1
func MutexLocked(m *sync.Mutex) bool {
state := reflect.ValueOf(m).Elem().FieldByName("state")
return state.Int()&mutexLocked == mutexLocked
}