mirror of
https://github.com/postmannen/ctrl.git
synced 2025-03-31 01:24:31 +00:00
Initial Web UI, and TUI implementations :
NB: Breaking change, since the message format have changed. Uses nats to communicate with ctrl, and nkeys for auth. Supports the use of files as templates for scripts to run. The main source for the templates are the ./files directory on the node named central. webUI: Settings are stored locally using Javascript localStorage. shortcut ctrl+t to open the template menu for webui
This commit is contained in:
parent
ee8474e71a
commit
5d99554c3b
17 changed files with 1803 additions and 25 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
@ -18,3 +18,4 @@ notes.txt
|
|||
toolbox/
|
||||
signing/
|
||||
frontend/
|
||||
.vscode/
|
||||
|
|
13
go.mod
13
go.mod
|
@ -5,6 +5,7 @@ go 1.22
|
|||
require (
|
||||
github.com/fsnotify/fsnotify v1.8.0
|
||||
github.com/fxamacker/cbor/v2 v2.5.0
|
||||
github.com/gdamore/tcell/v2 v2.8.1
|
||||
github.com/go-playground/validator/v10 v10.10.1
|
||||
github.com/google/uuid v1.3.0
|
||||
github.com/jinzhu/copier v0.4.0
|
||||
|
@ -15,9 +16,10 @@ require (
|
|||
github.com/nats-io/nkeys v0.4.7
|
||||
github.com/pkg/profile v1.7.0
|
||||
github.com/prometheus/client_golang v1.14.0
|
||||
github.com/rivo/tview v0.0.0-20241227133733-17b7edb88c57
|
||||
github.com/tenebris-tech/tail v1.0.5
|
||||
go.etcd.io/bbolt v1.3.7
|
||||
golang.org/x/crypto v0.18.0
|
||||
golang.org/x/crypto v0.23.0
|
||||
golang.org/x/exp v0.0.0-20230321023759-10a507213a29
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
)
|
||||
|
@ -26,11 +28,14 @@ require (
|
|||
github.com/beorn7/perks v1.0.1 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.2.0 // indirect
|
||||
github.com/felixge/fgprof v0.9.3 // indirect
|
||||
github.com/gdamore/encoding v1.0.1 // indirect
|
||||
github.com/go-playground/locales v0.14.0 // indirect
|
||||
github.com/go-playground/universal-translator v0.18.0 // indirect
|
||||
github.com/golang/protobuf v1.5.3 // indirect
|
||||
github.com/google/pprof v0.0.0-20230323073829-e72429f035bd // indirect
|
||||
github.com/leodido/go-urn v1.2.1 // indirect
|
||||
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
|
||||
github.com/mattn/go-runewidth v0.0.16 // indirect
|
||||
github.com/matttproud/golang_protobuf_extensions v1.0.4 // indirect
|
||||
github.com/minio/highwayhash v1.0.2 // indirect
|
||||
github.com/nats-io/jwt/v2 v2.2.1-0.20220330180145-442af02fd36a // indirect
|
||||
|
@ -38,9 +43,11 @@ require (
|
|||
github.com/prometheus/client_model v0.3.0 // indirect
|
||||
github.com/prometheus/common v0.42.0 // indirect
|
||||
github.com/prometheus/procfs v0.9.0 // indirect
|
||||
github.com/rivo/uniseg v0.4.7 // indirect
|
||||
github.com/x448/float16 v0.8.4 // indirect
|
||||
golang.org/x/sys v0.27.0 // indirect
|
||||
golang.org/x/text v0.14.0 // indirect
|
||||
golang.org/x/sys v0.29.0 // indirect
|
||||
golang.org/x/term v0.28.0 // indirect
|
||||
golang.org/x/text v0.21.0 // indirect
|
||||
golang.org/x/time v0.0.0-20220609170525-579cf78fd858 // indirect
|
||||
google.golang.org/protobuf v1.30.0 // indirect
|
||||
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 // indirect
|
||||
|
|
83
go.sum
83
go.sum
|
@ -15,6 +15,10 @@ github.com/fsnotify/fsnotify v1.8.0 h1:dAwr6QBTBZIkG8roQaJjGof0pp0EeF+tNV7YBP3F/
|
|||
github.com/fsnotify/fsnotify v1.8.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
|
||||
github.com/fxamacker/cbor/v2 v2.5.0 h1:oHsG0V/Q6E/wqTS2O1Cozzsy69nqCiguo5Q1a1ADivE=
|
||||
github.com/fxamacker/cbor/v2 v2.5.0/go.mod h1:TA1xS00nchWmaBnEIxPSE5oHLuJBAVvqrtAnWBwBCVo=
|
||||
github.com/gdamore/encoding v1.0.1 h1:YzKZckdBL6jVt2Gc+5p82qhrGiqMdG/eNs6Wy0u3Uhw=
|
||||
github.com/gdamore/encoding v1.0.1/go.mod h1:0Z0cMFinngz9kS1QfMjCP8TY7em3bZYeeklsSDPivEo=
|
||||
github.com/gdamore/tcell/v2 v2.8.1 h1:KPNxyqclpWpWQlPLx6Xui1pMk8S+7+R37h3g07997NU=
|
||||
github.com/gdamore/tcell/v2 v2.8.1/go.mod h1:bj8ori1BG3OYMjmb3IklZVWfZUJ1UBQt9JXrOCOhGWw=
|
||||
github.com/go-playground/assert/v2 v2.0.1 h1:MsBgLAaY856+nPRTKrp3/OZK38U/wa0CcBYNjji3q3A=
|
||||
github.com/go-playground/assert/v2 v2.0.1/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
||||
github.com/go-playground/locales v0.14.0 h1:u50s323jtVGugKlcYeyzC0etD1HifMjqmJqb8WugfUU=
|
||||
|
@ -29,8 +33,8 @@ github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaS
|
|||
github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg=
|
||||
github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
|
||||
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
|
||||
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/google/pprof v0.0.0-20211214055906-6f57359322fd/go.mod h1:KgnwoLYCZ8IQu3XUZ8Nc/bM9CCZFOyjUNOSygVozoDg=
|
||||
github.com/google/pprof v0.0.0-20230323073829-e72429f035bd h1:r8yyd+DJDmsUhGrRBxH5Pj7KeFK5l+Y3FsgT8keqKtk=
|
||||
github.com/google/pprof v0.0.0-20230323073829-e72429f035bd/go.mod h1:79YE0hCXdHag9sBkw2o+N/YnZtTkXi0UT9Nnixa5eYk=
|
||||
|
@ -53,6 +57,10 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
|||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/leodido/go-urn v1.2.1 h1:BqpAaACuzVSgi/VLzGZIobT2z4v53pjosyNd9Yv6n/w=
|
||||
github.com/leodido/go-urn v1.2.1/go.mod h1:zt4jvISO2HfUBqxjfIshjdMTYS56ZS/qv49ictyFfxY=
|
||||
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
|
||||
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
||||
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
|
||||
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
|
||||
github.com/matttproud/golang_protobuf_extensions v1.0.4 h1:mmDVorXM7PCGKw94cs5zkfA9PSy5pEvNWRP0ET0TIVo=
|
||||
github.com/matttproud/golang_protobuf_extensions v1.0.4/go.mod h1:BSXmuO+STAnVfrANrmjBb36TMTDstsz7MSK+HVaYKv4=
|
||||
github.com/minio/highwayhash v1.0.2 h1:Aak5U0nElisjDCfPSG79Tgzkn2gl66NxOMspRrKnA/g=
|
||||
|
@ -81,6 +89,12 @@ github.com/prometheus/common v0.42.0 h1:EKsfXEYo4JpWMHH5cg+KOUWeuJSov1Id8zGR8eeI
|
|||
github.com/prometheus/common v0.42.0/go.mod h1:xBwqVerjNdUDjgODMpudtOMwlOwf2SaTr1yjz4b7Zbc=
|
||||
github.com/prometheus/procfs v0.9.0 h1:wzCHvIvM5SxWqYvwgVL7yJY8Lz3PKn49KQtpgMYJfhI=
|
||||
github.com/prometheus/procfs v0.9.0/go.mod h1:+pB4zwohETzFnmlpe6yd2lSc+0/46IYZRB/chUwxUZY=
|
||||
github.com/rivo/tview v0.0.0-20241227133733-17b7edb88c57 h1:LmsF7Fk5jyEDhJk0fYIqdWNuTxSyid2W42A0L2YWjGE=
|
||||
github.com/rivo/tview v0.0.0-20241227133733-17b7edb88c57/go.mod h1:02iFIz7K/A9jGCvrizLPvoqr4cEIx7q54RH5Qudkrss=
|
||||
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||
github.com/rivo/uniseg v0.4.3/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||
github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc=
|
||||
github.com/rogpeppe/go-internal v1.8.0 h1:FCbCCtXNOY3UtUuHUYaghJg4y7Fd14rXifAYUAtL9R8=
|
||||
github.com/rogpeppe/go-internal v1.8.0/go.mod h1:WmiCO8CzOY8rg0OYDC4/i/2WRWAB6poM+XZ2dLUbcbE=
|
||||
|
@ -96,36 +110,87 @@ github.com/tenebris-tech/tail v1.0.5 h1:gKDA1qEP+kxG/SqaFzJWC/5jLHlG1y4hgibSPHdi
|
|||
github.com/tenebris-tech/tail v1.0.5/go.mod h1:RpxaZO+UNwbKgXA6VzHdR5FTLTM0pk9zZU/mmHxUeZQ=
|
||||
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
|
||||
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
|
||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
go.etcd.io/bbolt v1.3.7 h1:j+zJOnnEjF/kyHlDDgGnVL/AIqIJPq8UoB2GSNfkUfQ=
|
||||
go.etcd.io/bbolt v1.3.7/go.mod h1:N9Mkw9X8x5fupy0IKsmuqVtoGDyxsaDlbk4Rd05IAQw=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20210314154223-e6e6c4f2bb5b/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4=
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.0.0-20211215153901-e495a2d5b3d3/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
|
||||
golang.org/x/crypto v0.18.0 h1:PGVlW0xEltQnzFZ55hkuX5+KLyrMYhHld1YHO4AKcdc=
|
||||
golang.org/x/crypto v0.18.0/go.mod h1:R0j02AL6hcrfOiy9T4ZYp/rcWeMxM3L6QYxlOuEG1mg=
|
||||
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
|
||||
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
|
||||
golang.org/x/crypto v0.23.0 h1:dIJU/v2J8Mdglj/8rJ6UUOM3Zc9zLZxVZwwxMooUSAI=
|
||||
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
|
||||
golang.org/x/exp v0.0.0-20230321023759-10a507213a29 h1:ooxPy7fPvB4kwsA2h+iBNHkAbp/4JxTSwCmvdjEYmug=
|
||||
golang.org/x/exp v0.0.0-20230321023759-10a507213a29/go.mod h1:CxIveKay+FTh1D0yPZemJVgC/95VzuuOLq5Qi4xnoYc=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
|
||||
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
|
||||
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
|
||||
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
|
||||
golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
|
||||
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sys v0.0.0-20190130150945-aca44879d564/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
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-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20210806184541-e5e7981a1069/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20211007075335-d3039528d8ac/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.27.0 h1:wBqf8DvsY9Y/2P8gAfPDEYNuS30J4lPHJxXSb/nJZ+s=
|
||||
golang.org/x/sys v0.27.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU=
|
||||
golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.16.0 h1:m+B6fahuftsE9qjo0VWp2FW0mB3MTJvR0BaMQrq0pmE=
|
||||
golang.org/x/term v0.16.0/go.mod h1:yn7UURbUtPyrVJPGPq404EukNFxcm/foM+bV/bfcDsY=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
|
||||
golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
|
||||
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
|
||||
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
|
||||
golang.org/x/term v0.28.0 h1:/Ts8HFuMR2E6IP/jlo7QVLZHggjKQbhu/7H0LJFr3Gg=
|
||||
golang.org/x/term v0.28.0/go.mod h1:Sw/lC2IAUZ92udQNf3WodGtn4k/XoLyZoh8v/8uiwek=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
|
||||
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
|
||||
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
|
||||
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
|
||||
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
|
||||
golang.org/x/time v0.0.0-20220609170525-579cf78fd858 h1:Dpdu/EMxGMFgq0CeYMh4fazTD2vtlZRYE7wyynxJb9U=
|
||||
golang.org/x/time v0.0.0-20220609170525-579cf78fd858/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||
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.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
||||
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
|
||||
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
|
||||
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
|
||||
|
|
|
@ -89,6 +89,12 @@ type Message struct {
|
|||
Schedule []int `json:"schedule" yaml:"schedule"`
|
||||
// Use auto detection of shell for cliCommands
|
||||
UseDetectedShell bool `json:"useDetectedShell" yaml:"useDetectedShell"`
|
||||
// MethodInstructions is a string that contains eventual extra instructions
|
||||
// for the method that is used when the method is executed.
|
||||
MethodInstructions []string `json:"methodInstructions" yaml:"methodInstructions"`
|
||||
// ReplyMethodInstructions is a string that contains eventual extra instructions
|
||||
// for the method that is used when the reply method is executed for the reply message.
|
||||
ReplyMethodInstructions []string `json:"replyMethodInstructions" yaml:"replyMethodInstructions"`
|
||||
}
|
||||
|
||||
// --- Subject
|
||||
|
|
|
@ -543,6 +543,9 @@ func (s *server) readHttpListener() {
|
|||
}
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/", s.readHTTPlistenerHandler)
|
||||
// TODO: Make this configurable, and move it out of the readHttpListener function,
|
||||
// make a separate conf flag and function for it.
|
||||
mux.Handle("/webui/", http.StripPrefix("/webui/", http.FileServer(http.Dir("/Users/bt/ctrl/webui"))))
|
||||
|
||||
err = http.Serve(n, mux)
|
||||
if err != nil {
|
||||
|
|
28
requests.go
28
requests.go
|
@ -169,6 +169,9 @@ const (
|
|||
AclExport = "aclExport"
|
||||
// REQAclImport
|
||||
AclImport = "aclImport"
|
||||
|
||||
// WebUI is used to send messages to the web ui.
|
||||
WebUI Method = "webUI"
|
||||
)
|
||||
|
||||
type Handler func(proc process, message Message, node string) ([]byte, error)
|
||||
|
@ -230,6 +233,8 @@ func (m Method) GetMethodsAvailable() MethodsAvailable {
|
|||
AclExport: Handler(methodAclExport),
|
||||
AclImport: Handler(methodAclImport),
|
||||
Test: Handler(methodTest),
|
||||
|
||||
WebUI: Handler(nil),
|
||||
},
|
||||
}
|
||||
|
||||
|
@ -353,17 +358,18 @@ func newReplyMessage(proc process, message Message, outData []byte) {
|
|||
// are injected f.ex. on a socket, and there they are directly converted into separate
|
||||
// node messages. With other words a message in the system are only for single nodes,
|
||||
// so we don't have to worry about the ToNodes field when creating reply messages.
|
||||
FromNode: message.ToNode,
|
||||
Data: outData,
|
||||
Method: message.ReplyMethod,
|
||||
MethodArgs: message.ReplyMethodArgs,
|
||||
MethodTimeout: message.ReplyMethodTimeout,
|
||||
IsReply: true,
|
||||
RetryWait: message.RetryWait,
|
||||
ACKTimeout: message.ReplyACKTimeout,
|
||||
Retries: message.ReplyRetries,
|
||||
Directory: message.Directory,
|
||||
FileName: message.FileName,
|
||||
FromNode: message.ToNode,
|
||||
Data: outData,
|
||||
Method: message.ReplyMethod,
|
||||
MethodArgs: message.ReplyMethodArgs,
|
||||
MethodTimeout: message.ReplyMethodTimeout,
|
||||
IsReply: true,
|
||||
RetryWait: message.RetryWait,
|
||||
ACKTimeout: message.ReplyACKTimeout,
|
||||
Retries: message.ReplyRetries,
|
||||
Directory: message.Directory,
|
||||
FileName: message.FileName,
|
||||
MethodInstructions: message.ReplyMethodInstructions,
|
||||
|
||||
// Put in a copy of the initial request message, so we can use it's properties if
|
||||
// needed to for example create the file structure naming on the subscriber.
|
||||
|
|
25
server.go
25
server.go
|
@ -631,6 +631,16 @@ func (s *server) exposeDataFolder() {
|
|||
// messageSerializeAndCompress will serialize and compress the Message, and
|
||||
// return the result as a []byte.
|
||||
func (s *server) messageSerializeAndCompress(msg Message) ([]byte, error) {
|
||||
// NB: Implementing json encoding for WebUI messages for now.
|
||||
if msg.Method == WebUI {
|
||||
bSerialized, err := json.Marshal(msg)
|
||||
if err != nil {
|
||||
er := fmt.Errorf("error: messageDeliverNats: json encode message failed: %v", err)
|
||||
return nil, er
|
||||
}
|
||||
fmt.Printf("JSON JSON JSON JSON JSON JSON JSON JSON JSON JSON JSON JSON \n")
|
||||
return bSerialized, nil
|
||||
}
|
||||
|
||||
// encode the message structure into cbor
|
||||
bSerialized, err := cbor.Marshal(msg)
|
||||
|
@ -657,6 +667,7 @@ func (s *server) messageDeserializeAndUncompress(msgData []byte) (Message, error
|
|||
// er := fmt.Errorf("info: subscriberHandlerJetstream: nats message received from %v, with subject %v ", headerFromNode, msg.Subject())
|
||||
// s.errorKernel.logDebug(er)
|
||||
// }
|
||||
msgData2 := msgData
|
||||
|
||||
zr, err := zstd.NewReader(nil)
|
||||
if err != nil {
|
||||
|
@ -665,9 +676,19 @@ func (s *server) messageDeserializeAndUncompress(msgData []byte) (Message, error
|
|||
}
|
||||
msgData, err = zr.DecodeAll(msgData, nil)
|
||||
if err != nil {
|
||||
er := fmt.Errorf("error: subscriberHandlerJetstream: zstd decoding failed: %v", err)
|
||||
// er := fmt.Errorf("error: subscriberHandlerJetstream: zstd decoding failed: %v", err)
|
||||
zr.Close()
|
||||
return Message{}, er
|
||||
|
||||
// Not zstd encoded, try to decode as JSON. This is for messages from the WebUI.
|
||||
var msg Message
|
||||
fmt.Printf("DEBUG: msgData2: %v\n", string(msgData2))
|
||||
err = json.Unmarshal(msgData2, &msg)
|
||||
if err != nil {
|
||||
return Message{}, err
|
||||
}
|
||||
|
||||
// JSON decoded, return the message
|
||||
return msg, nil
|
||||
}
|
||||
|
||||
zr.Close()
|
||||
|
|
1
tui/files/script.sh
Normal file
1
tui/files/script.sh
Normal file
|
@ -0,0 +1 @@
|
|||
pwd
|
1
tui/files/script2.sh
Normal file
1
tui/files/script2.sh
Normal file
|
@ -0,0 +1 @@
|
|||
pwd
|
1
tui/files/script3.sh
Normal file
1
tui/files/script3.sh
Normal file
|
@ -0,0 +1 @@
|
|||
pwd
|
397
tui/main.go
Normal file
397
tui/main.go
Normal file
|
@ -0,0 +1,397 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
tcell "github.com/gdamore/tcell/v2"
|
||||
"github.com/nats-io/nats.go"
|
||||
"github.com/rivo/tview"
|
||||
)
|
||||
|
||||
type CommandData struct {
|
||||
ToNodes []string `json:"toNodes"`
|
||||
ToNode string `json:"toNode"`
|
||||
JetstreamToNode string `json:"jetstreamToNode,omitempty"`
|
||||
UseDetectedShell bool `json:"useDetectedShell"`
|
||||
Method string `json:"method"`
|
||||
MethodArgs []string `json:"methodArgs"`
|
||||
MethodTimeout int `json:"methodTimeout"`
|
||||
FromNode string `json:"fromNode"`
|
||||
ReplyMethod string `json:"replyMethod"`
|
||||
ACKTimeout int `json:"ACKTimeout"`
|
||||
}
|
||||
|
||||
// Create a global variable for the output view so it can be accessed from NATS handlers
|
||||
var outputView *tview.TextView
|
||||
var historyList *tview.List
|
||||
var filesList *tview.List
|
||||
var app *tview.Application
|
||||
var nc *nats.Conn
|
||||
|
||||
// Store form values globally
|
||||
var (
|
||||
toNodes string = "btdev1"
|
||||
methodArgs = []string{"/bin/bash", "-c", "ls -l"}
|
||||
// Store history commands
|
||||
historyCommands []string
|
||||
)
|
||||
|
||||
func createCommandPage() tview.Primitive {
|
||||
// Create a flex container for the entire page
|
||||
mainFlex := tview.NewFlex().SetDirection(tview.FlexRow)
|
||||
|
||||
// Create form for inputs
|
||||
formFields := tview.NewForm()
|
||||
formFields.SetBorder(true).SetTitle("Command Form")
|
||||
|
||||
// Create a List for command history
|
||||
historyList = tview.NewList()
|
||||
historyList.SetBorder(true)
|
||||
historyList.SetTitle("History")
|
||||
// Handle selection from history
|
||||
historyList.SetSelectedFunc(func(index int, mainText string, secondaryText string, shortcut rune) {
|
||||
// Update Method Arg 3 field with selected command
|
||||
methodArg3Field := formFields.GetFormItemByLabel("Method Arg 3").(*tview.InputField)
|
||||
methodArg3Field.SetText(mainText)
|
||||
methodArgs[2] = mainText // Update global variable
|
||||
app.SetFocus(formFields) // Return focus to form
|
||||
})
|
||||
|
||||
// Create a List for files
|
||||
filesList = tview.NewList()
|
||||
filesList.SetBorder(true)
|
||||
filesList.SetTitle("Files")
|
||||
filesList.SetSelectedFunc(func(index int, mainText string, secondaryText string, shortcut rune) {
|
||||
// When a file is selected, update Method Arg 3 with the CTRL_FILE template
|
||||
methodArg3Field := formFields.GetFormItemByLabel("Method Arg 3").(*tview.InputField)
|
||||
methodArg3Field.SetText("{{CTRL_FILE:" + secondaryText + "}}")
|
||||
methodArgs[2] = methodArg3Field.GetText()
|
||||
app.SetFocus(formFields)
|
||||
})
|
||||
|
||||
// Create a vertical flex for history and files lists
|
||||
rightPanelFlex := tview.NewFlex().
|
||||
SetDirection(tview.FlexRow).
|
||||
AddItem(historyList, 0, 1, false).
|
||||
AddItem(filesList, 0, 1, false)
|
||||
|
||||
// Create a horizontal flex for the form and right panel
|
||||
topFlex := tview.NewFlex().
|
||||
AddItem(formFields, 0, 2, true).
|
||||
AddItem(rightPanelFlex, 0, 1, false)
|
||||
|
||||
// Create output text view
|
||||
outputView = tview.NewTextView().
|
||||
SetDynamicColors(true).
|
||||
SetChangedFunc(func() {
|
||||
app.Draw()
|
||||
})
|
||||
outputView.SetBorder(true).SetTitle("Output")
|
||||
|
||||
// Add form fields
|
||||
formFields.AddInputField("To Nodes", toNodes, 30, nil, func(text string) {
|
||||
})
|
||||
|
||||
formFields.AddInputField("Jetstream To Node", "", 30, nil, func(text string) {
|
||||
// Value will be read directly from form when needed
|
||||
})
|
||||
|
||||
formFields.AddCheckbox("Use Detected Shell", false, func(checked bool) {
|
||||
// Value will be read directly from form when needed
|
||||
})
|
||||
|
||||
formFields.AddInputField("Method", "cliCommand", 30, nil, func(text string) {
|
||||
// Value will be read directly from form when needed
|
||||
})
|
||||
|
||||
formFields.AddInputField("Method Arg 1", methodArgs[0], 30, nil, func(text string) {
|
||||
methodArgs[0] = text
|
||||
})
|
||||
|
||||
formFields.AddInputField("Method Arg 2", methodArgs[1], 30, nil, func(text string) {
|
||||
methodArgs[1] = text
|
||||
})
|
||||
|
||||
formFields.AddInputField("Method Arg 3", methodArgs[2], 30, nil, func(text string) {
|
||||
methodArgs[2] = text
|
||||
}).SetFieldStyle(tcell.StyleDefault.Blink(true))
|
||||
|
||||
formFields.AddInputField("Method Timeout", "3", 30, nil, func(text string) {
|
||||
// Value will be read directly from form when needed
|
||||
})
|
||||
|
||||
formFields.AddInputField("Reply Method", "webUI", 30, nil, func(text string) {
|
||||
// Value will be read directly from form when needed
|
||||
})
|
||||
|
||||
formFields.AddInputField("ACK Timeout", "0", 30, nil, func(text string) {
|
||||
// Value will be read directly from form when needed
|
||||
})
|
||||
|
||||
// Subscribe to responses
|
||||
fromNode := "btdev1"
|
||||
|
||||
// Add buttons
|
||||
formFields.AddButton("Send", func() {
|
||||
// Get and save the current command to history
|
||||
currentCmd := formFields.GetFormItemByLabel("Method Arg 3").(*tview.InputField).GetText()
|
||||
if currentCmd != "" {
|
||||
// Add to history if not already present
|
||||
found := false
|
||||
for _, cmd := range historyCommands {
|
||||
if cmd == currentCmd {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
historyCommands = append([]string{currentCmd}, historyCommands...)
|
||||
historyList.Clear()
|
||||
for _, cmd := range historyCommands {
|
||||
historyList.AddItem(cmd, "", 0, nil).ShowSecondaryText(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Get current values from form
|
||||
methodTimeout, _ := strconv.Atoi(formFields.GetFormItemByLabel("Method Timeout").(*tview.InputField).GetText())
|
||||
ackTimeout, _ := strconv.Atoi(formFields.GetFormItemByLabel("ACK Timeout").(*tview.InputField).GetText())
|
||||
|
||||
// Get and clean toNodes
|
||||
toNodesText := formFields.GetFormItemByLabel("To Nodes").(*tview.InputField).GetText()
|
||||
toNodes := strings.Split(toNodesText, ",")
|
||||
// Trim spaces and filter out empty nodes
|
||||
// cleanedToNodes := []string{}
|
||||
// for _, node := range toNodes {
|
||||
// if trimmed := strings.TrimSpace(node); trimmed != "" {
|
||||
// cleanedToNodes = append(cleanedToNodes, trimmed)
|
||||
// }
|
||||
// }
|
||||
|
||||
cmd := CommandData{
|
||||
// ToNodes: The values of toNodes will be used to fill toNode field when sending below.
|
||||
JetstreamToNode: formFields.GetFormItemByLabel("Jetstream To Node").(*tview.InputField).GetText(),
|
||||
UseDetectedShell: formFields.GetFormItemByLabel("Use Detected Shell").(*tview.Checkbox).IsChecked(),
|
||||
Method: formFields.GetFormItemByLabel("Method").(*tview.InputField).GetText(),
|
||||
MethodArgs: methodArgs,
|
||||
MethodTimeout: methodTimeout,
|
||||
FromNode: fromNode,
|
||||
ReplyMethod: formFields.GetFormItemByLabel("Reply Method").(*tview.InputField).GetText(),
|
||||
ACKTimeout: ackTimeout,
|
||||
}
|
||||
|
||||
// Send command to each node
|
||||
for _, node := range toNodes {
|
||||
cmd.ToNode = node
|
||||
|
||||
// ---------------------------
|
||||
|
||||
var filePathToOpen string
|
||||
foundFile := false
|
||||
|
||||
if strings.Contains(cmd.MethodArgs[2], "{{CTRL_FILE:") {
|
||||
foundFile = true
|
||||
|
||||
// Example to split:
|
||||
// echo {{CTRL_FILE:/somedir/msg_file.yaml}}>ctrlfile.txt
|
||||
//
|
||||
// Split at colon. We want the part after.
|
||||
ss := strings.Split(cmd.MethodArgs[2], ":")
|
||||
// Split at "}}",so pos [0] in the result contains just the file path.
|
||||
sss := strings.Split(ss[1], "}}")
|
||||
filePathToOpen = sss[0]
|
||||
|
||||
}
|
||||
|
||||
if foundFile {
|
||||
|
||||
fh, err := os.Open(filePathToOpen)
|
||||
if err != nil {
|
||||
fmt.Fprintf(outputView, "Error opening file: %v\n", err)
|
||||
return
|
||||
}
|
||||
defer fh.Close()
|
||||
|
||||
b, err := io.ReadAll(fh)
|
||||
if err != nil {
|
||||
fmt.Fprintf(outputView, "Error reading file: %v\n", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Replace the {{CTRL_FILE}} with the actual content read from file.
|
||||
re := regexp.MustCompile(`(.*)({{CTRL_FILE.*}})(.*)`)
|
||||
cmd.MethodArgs[2] = re.ReplaceAllString(cmd.MethodArgs[2], `${1}`+string(b)+`${3}`)
|
||||
|
||||
fmt.Fprintf(outputView, "DEBUG: Replaced {{CTRL_FILE}} with file content: %s\n", cmd.MethodArgs[2])
|
||||
// ---
|
||||
|
||||
}
|
||||
|
||||
// ---------------------------
|
||||
|
||||
jsonData, err := json.Marshal(cmd)
|
||||
if err != nil {
|
||||
fmt.Fprintf(outputView, "Error creating command: %v\n", err)
|
||||
return
|
||||
}
|
||||
|
||||
subject := fmt.Sprintf("%s.%s", strings.TrimSpace(node), cmd.Method)
|
||||
|
||||
if err := nc.Publish(subject, jsonData); err != nil {
|
||||
fmt.Fprintf(outputView, "Error sending to %s: %v\n", node, err)
|
||||
} else {
|
||||
// fmt.Fprintf(outputView, "*** Command %s sent to %s, subject: %s\n", jsonData, node, subject)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Layout setup - adjust the proportion between form and output
|
||||
mainFlex.AddItem(topFlex, 0, 7, true) // Form and history section
|
||||
mainFlex.AddItem(outputView, 0, 5, false) // Output takes remaining space
|
||||
|
||||
return mainFlex
|
||||
}
|
||||
|
||||
func createNodesPage() tview.Primitive {
|
||||
// Create an empty page for now
|
||||
return tview.NewBox().SetBorder(true).SetTitle("Nodes")
|
||||
}
|
||||
|
||||
func updateFilesList() {
|
||||
// Clear current list
|
||||
// filesList.Clear()
|
||||
|
||||
// Create tui/files directory if it doesn't exist
|
||||
filesDir := "files"
|
||||
|
||||
// Read all files from the directory
|
||||
files, err := os.ReadDir(filesDir)
|
||||
if err != nil {
|
||||
fmt.Fprintf(outputView, "Error reading files directory: %v\n", err)
|
||||
return
|
||||
}
|
||||
|
||||
if filesList == nil {
|
||||
fmt.Printf("FILES FILE FILE: %v\n", files)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Add each file to the list
|
||||
for _, file := range files {
|
||||
//fmt.Fprintf(outputView, "DEBUG: File: %s\n", file.Name())
|
||||
//if !file.IsDir() {
|
||||
filesList.AddItem(file.Name(), filepath.Join(filesDir, file.Name()), 0, nil)
|
||||
//}
|
||||
}
|
||||
}
|
||||
|
||||
func main() {
|
||||
app = tview.NewApplication()
|
||||
|
||||
// Connect to NATS
|
||||
var err error
|
||||
nc, err = nats.Connect("nats://localhost:4222")
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
defer nc.Close()
|
||||
|
||||
fromNode := "btdev1"
|
||||
// --------------------------
|
||||
// fmt.Fprintf(outputView, "DEBUG: BEFORE SUBSCRIBE\n")
|
||||
sub, err := nc.Subscribe(fromNode+".webUI", func(msg *nats.Msg) {
|
||||
var jsonMsg struct {
|
||||
Data string `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(msg.Data, &jsonMsg); err != nil {
|
||||
app.QueueUpdateDraw(func() {
|
||||
fmt.Fprintf(outputView, "Error decoding message: %v\n", err)
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Decode base64 data
|
||||
decoded, err := base64.StdEncoding.DecodeString(jsonMsg.Data)
|
||||
if err != nil {
|
||||
app.QueueUpdateDraw(func() {
|
||||
fmt.Fprintf(outputView, "Error decoding base64: %v\n", err)
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
app.QueueUpdateDraw(func() {
|
||||
fmt.Fprintf(outputView, "\nReceived message on %s:\n%s\n", msg.Subject, string(decoded))
|
||||
})
|
||||
})
|
||||
// fmt.Fprintf(outputView, "DEBUG: AFTER SUBSCRIBE\n")
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
defer func() {
|
||||
sub.Unsubscribe()
|
||||
}()
|
||||
// --------------------------
|
||||
|
||||
// Create pages to manage multiple screens
|
||||
pages := tview.NewPages()
|
||||
|
||||
// Create menu
|
||||
menu := tview.NewList().
|
||||
AddItem("Commands", "Send commands to nodes", 'c', func() {
|
||||
pages.SwitchToPage("commands")
|
||||
}).ShowSecondaryText(false).
|
||||
AddItem("Nodes", "View and manage nodes", 'n', func() {
|
||||
pages.SwitchToPage("nodes")
|
||||
}).ShowSecondaryText(false).
|
||||
AddItem("Quit", "Press to exit", 'q', func() {
|
||||
app.Stop()
|
||||
})
|
||||
menu.SetBorder(true).SetTitle("Menu")
|
||||
|
||||
// Create layout with menu on left and pages on right
|
||||
flex := tview.NewFlex().
|
||||
AddItem(menu, 20, 1, true).
|
||||
AddItem(pages, 0, 1, false)
|
||||
|
||||
// Add pages
|
||||
pages.AddPage("commands", createCommandPage(), true, true)
|
||||
pages.AddPage("nodes", createNodesPage(), true, false)
|
||||
|
||||
// Global keyboard shortcuts
|
||||
app.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
|
||||
switch event.Key() {
|
||||
case tcell.KeyEsc:
|
||||
// If on a page, go back to menu
|
||||
if menu.HasFocus() {
|
||||
app.Stop()
|
||||
} else {
|
||||
app.SetFocus(menu)
|
||||
}
|
||||
return nil
|
||||
case tcell.KeyTab:
|
||||
// Toggle between menu and current page
|
||||
if menu.HasFocus() {
|
||||
app.SetFocus(pages)
|
||||
} else {
|
||||
app.SetFocus(menu)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
return event
|
||||
})
|
||||
|
||||
// Update files list at startup
|
||||
updateFilesList()
|
||||
|
||||
if err := app.SetRoot(flex, true).EnableMouse(true).Run(); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
228
webui/index.html
Normal file
228
webui/index.html
Normal file
|
@ -0,0 +1,228 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Command Dashboard</title>
|
||||
<link rel="stylesheet" href="styles.css" />
|
||||
<style>
|
||||
.popup {
|
||||
display: none;
|
||||
position: fixed;
|
||||
z-index: 1000;
|
||||
left: 0;
|
||||
top: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
.popup-content {
|
||||
background-color: #fefefe;
|
||||
margin: 15% auto;
|
||||
padding: 20px;
|
||||
border: 1px solid #888;
|
||||
width: 80%;
|
||||
max-width: 600px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.close-popup {
|
||||
color: #aaa;
|
||||
float: right;
|
||||
font-size: 28px;
|
||||
font-weight: bold;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.close-popup:hover,
|
||||
.close-popup:focus {
|
||||
color: black;
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="hamburger-menu">
|
||||
<div class="hamburger-icon">
|
||||
<span></span>
|
||||
<span></span>
|
||||
<span></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav class="side-menu">
|
||||
<a href="index.html" class="active">Command</a>
|
||||
<a href="#" id="fileTemplatesLink">File Templates</a>
|
||||
<a href="settings.html">Settings</a>
|
||||
</nav>
|
||||
|
||||
<div class="split-container">
|
||||
<div class="left-panel">
|
||||
<form id="commandForm">
|
||||
<div class="form-group">
|
||||
<label for="toNodes">To Nodes (comma-separated):</label>
|
||||
<input
|
||||
type="text"
|
||||
id="toNodes"
|
||||
placeholder="btdev1, btdev2..."
|
||||
value="btdev1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="jetstreamToNode">Jetstream To Node:</label>
|
||||
<input type="text" id="jetstreamToNode" placeholder="btdev1" />
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="useDetectedShell">Use Detected Shell:</label>
|
||||
<select id="useDetectedShell">
|
||||
<option value="false">false</option>
|
||||
<option value="true">true</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="method">Method:</label>
|
||||
<input type="text" id="method" value="cliCommand" />
|
||||
</div>
|
||||
|
||||
<div class="form-group method-args">
|
||||
<label>Method Arguments:</label>
|
||||
<input
|
||||
type="text"
|
||||
class="method-arg"
|
||||
placeholder="/bin/bash"
|
||||
value="/bin/bash"
|
||||
/>
|
||||
<input type="text" class="method-arg" placeholder="-c" value="-c" />
|
||||
<textarea
|
||||
class="method-arg"
|
||||
placeholder="Enter command here..."
|
||||
value="ls -l"
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="methodTimeout">Method Timeout (seconds):</label>
|
||||
<input type="number" id="methodTimeout" value="3" />
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="replyMethod">Reply Method:</label>
|
||||
<input type="text" id="replyMethod" value="webUI" />
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="ackTimeout">ACK Timeout:</label>
|
||||
<input type="number" id="ackTimeout" value="0" />
|
||||
</div>
|
||||
|
||||
<div class="button-group">
|
||||
<button type="submit" id="sendBtn">Send</button>
|
||||
<button type="submit" id="writeFileBtn">Write File</button>
|
||||
<button type="button" id="generateBtn">Generate Command</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div class="right-panel">
|
||||
<textarea
|
||||
id="outputArea"
|
||||
readonly
|
||||
placeholder="Command output will appear here..."
|
||||
></textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="fileTemplatesPopup" class="popup">
|
||||
<div class="popup-content">
|
||||
<span class="close-popup">×</span>
|
||||
<!-- Content will go here later -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script
|
||||
src="https://cdn.jsdelivr.net/npm/nats@2.29.1/index.min.js"
|
||||
type="module"
|
||||
></script>
|
||||
<script type="module" src="script.js"></script>
|
||||
<script>
|
||||
// Function to close menu
|
||||
function closeMenu() {
|
||||
document.body.classList.remove("menu-open");
|
||||
}
|
||||
|
||||
// Toggle menu on hamburger click
|
||||
document
|
||||
.querySelector(".hamburger-menu")
|
||||
.addEventListener("click", function (e) {
|
||||
e.stopPropagation(); // Prevent click from immediately bubbling to document
|
||||
document.body.classList.toggle("menu-open");
|
||||
});
|
||||
|
||||
// Close menu when clicking outside
|
||||
document.addEventListener("click", function (e) {
|
||||
// If menu is open and click is outside the side-menu and hamburger-menu
|
||||
if (
|
||||
document.body.classList.contains("menu-open") &&
|
||||
!e.target.closest(".side-menu") &&
|
||||
!e.target.closest(".hamburger-menu")
|
||||
) {
|
||||
closeMenu();
|
||||
}
|
||||
});
|
||||
|
||||
// Close menu on Escape key
|
||||
document.addEventListener("keydown", function (e) {
|
||||
if (
|
||||
e.key === "Escape" &&
|
||||
document.body.classList.contains("menu-open")
|
||||
) {
|
||||
closeMenu();
|
||||
}
|
||||
});
|
||||
|
||||
// Prevent clicks on the menu itself from closing it
|
||||
document
|
||||
.querySelector(".side-menu")
|
||||
.addEventListener("click", function (e) {
|
||||
e.stopPropagation();
|
||||
});
|
||||
|
||||
// File Templates popup functionality
|
||||
const fileTemplatesLink = document.getElementById("fileTemplatesLink");
|
||||
const fileTemplatesPopup = document.getElementById("fileTemplatesPopup");
|
||||
const closePopup = document.querySelector(".close-popup");
|
||||
|
||||
fileTemplatesLink.addEventListener("click", function (e) {
|
||||
e.preventDefault();
|
||||
fileTemplatesPopup.style.display = "block";
|
||||
closeMenu(); // Close the side menu when opening popup
|
||||
});
|
||||
|
||||
closePopup.addEventListener("click", function () {
|
||||
fileTemplatesPopup.style.display = "none";
|
||||
});
|
||||
|
||||
// Close popup when clicking outside
|
||||
window.addEventListener("click", function (e) {
|
||||
if (e.target == fileTemplatesPopup) {
|
||||
fileTemplatesPopup.style.display = "none";
|
||||
}
|
||||
});
|
||||
|
||||
// Add Escape key handler for popup
|
||||
document.addEventListener("keydown", function (e) {
|
||||
if (e.key === "Escape") {
|
||||
if (fileTemplatesPopup.style.display === "block") {
|
||||
fileTemplatesPopup.style.display = "none";
|
||||
} else if (document.body.classList.contains("menu-open")) {
|
||||
closeMenu();
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
25
webui/main.go
Normal file
25
webui/main.go
Normal file
|
@ -0,0 +1,25 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"log"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// Define command line flag for port
|
||||
port := flag.String("port", "localhost:8080", "Port to serve on (default 8080)")
|
||||
flag.Parse()
|
||||
|
||||
// Create file server handler
|
||||
fs := http.FileServer(http.Dir("."))
|
||||
|
||||
// Register handler for root path
|
||||
http.Handle("/", fs)
|
||||
|
||||
// Start server
|
||||
log.Printf("Starting server on %v", port)
|
||||
if err := http.ListenAndServe(*port, nil); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
471
webui/script.js
Normal file
471
webui/script.js
Normal file
|
@ -0,0 +1,471 @@
|
|||
/* jshint esversion: 9 */
|
||||
// import { connect } from 'https://cdn.jsdelivr.net/npm/nats@2.29.1/index.min.js';
|
||||
|
||||
// import { wsconnect, nkeyAuthenticator } from "https://esm.run/@nats-io/nats-core";
|
||||
// import { createUser, fromPublic, fromSeed } from "https://esm.run/@nats-io/nkeys";
|
||||
|
||||
import { wsconnect, nkeyAuthenticator } from "https://esm.run/@nats-io/nats-core";
|
||||
|
||||
const commandForm = document.getElementById('commandForm');
|
||||
const generateBtn = document.getElementById('generateBtn');
|
||||
const sendBtn = document.getElementById('sendBtn');
|
||||
const writeFileBtn = document.getElementById('writeFileBtn');
|
||||
const outputArea = document.getElementById('outputArea');
|
||||
const fileTemplatesLink = document.getElementById('fileTemplatesLink');
|
||||
const fileTemplatesPopup = document.getElementById('fileTemplatesPopup');
|
||||
|
||||
let nc;
|
||||
let filesList = new Map();
|
||||
let settings2;
|
||||
|
||||
document.addEventListener('DOMContentLoaded', async function() {
|
||||
const outputArea = document.getElementById('outputArea');
|
||||
|
||||
// Check for, and load saved settings first
|
||||
const savedSettings = localStorage.getItem("settings");
|
||||
|
||||
settings2 = JSON.parse(savedSettings);
|
||||
// outputArea.value += "DEBUG: settings2: "+JSON.stringify(settings2)+"\n";
|
||||
|
||||
// Initialize NATS client when the page loads.
|
||||
try {
|
||||
console.log("Connecting to NATS server: "+settings2.natsServer);
|
||||
const connectOptions = {
|
||||
servers: settings2.natsServer,
|
||||
timeout: 3000
|
||||
};
|
||||
|
||||
// Only add authenticator if we're using nkeys and have valid seed
|
||||
if (settings2.useNkeys && settings2.nkeysSeedRAW) {
|
||||
connectOptions.authenticator = nkeyAuthenticator(settings2.nkeysSeedRAW);
|
||||
console.log("DEBUG: Using nkeys authentication");
|
||||
}
|
||||
console.log("******* settings.nkeysSeedRAW", settings2.nkeysSeedRAW);
|
||||
|
||||
nc = await wsconnect(connectOptions);
|
||||
console.log("Connected to NATS server: "+settings2.natsServer);
|
||||
|
||||
outputArea.value += "Connected to NATS server: "+settings2.natsServer+"\n";
|
||||
|
||||
const sub = nc.subscribe(settings2.defaultNode+".webUI");
|
||||
(async () => {
|
||||
for await (const msg of sub) {
|
||||
try {
|
||||
const msgJson = msg.json();
|
||||
if (!msgJson || !msgJson.data) {
|
||||
console.error("Invalid message format:", msg);
|
||||
continue;
|
||||
}
|
||||
|
||||
// The data is a byte array for UTF8 characters that are base64 encoded.
|
||||
const ctrlMsgData = decodeBase64(msgJson.data);
|
||||
const ctrlMsgMethodInstructions = msgJson.methodInstructions || [];
|
||||
|
||||
console.log("DEBUG: Received message:", {
|
||||
subject: msg.subject,
|
||||
data: ctrlMsgData,
|
||||
instructions: ctrlMsgMethodInstructions
|
||||
});
|
||||
|
||||
if (ctrlMsgMethodInstructions[0] === "templateFilesList") {
|
||||
filesList = ctrlMsgData.split("\n");
|
||||
console.log("DEBUG: filesList:", filesList);
|
||||
|
||||
// For each file in the list, we send a message to the central node
|
||||
for (const file of filesList) {
|
||||
if (!file.trim()) continue; // Skip empty lines
|
||||
|
||||
console.log("DEBUG: Requesting content for file:", file);
|
||||
// Send a command to get the content of each file
|
||||
const js2 = {
|
||||
"toNodes": ["central"],
|
||||
"useDetectedShell": false,
|
||||
"method": "cliCommand",
|
||||
"methodArgs": ["/bin/bash","-c","cat files/"+file],
|
||||
"methodTimeout": 3,
|
||||
"fromNode": settings2.defaultNode,
|
||||
"replyMethod": "webUI",
|
||||
"replyMethodInstructions": ["templateFileContent",file],
|
||||
"ACKTimeout": 0
|
||||
};
|
||||
nc.publish("central.cliCommand", JSON.stringify(js2));
|
||||
}
|
||||
}
|
||||
|
||||
// Handle file content responses
|
||||
if (ctrlMsgMethodInstructions[0] === "templateFileContent" && ctrlMsgMethodInstructions[1]) {
|
||||
const file = ctrlMsgMethodInstructions[1];
|
||||
const fileContent = ctrlMsgData;
|
||||
console.log("DEBUG: Storing file content in localStorage:", {
|
||||
file: file,
|
||||
contentLength: fileContent.length
|
||||
});
|
||||
|
||||
// Get existing filelist or create new one
|
||||
let filelist = {};
|
||||
const existingFilelist = localStorage.getItem('filelist');
|
||||
if (existingFilelist) {
|
||||
filelist = JSON.parse(existingFilelist);
|
||||
}
|
||||
|
||||
// Add or update file in filelist
|
||||
filelist[file] = {
|
||||
content: fileContent,
|
||||
lastModified: new Date().toISOString()
|
||||
};
|
||||
|
||||
// Store updated filelist
|
||||
localStorage.setItem('filelist', JSON.stringify(filelist));
|
||||
}
|
||||
|
||||
outputArea.value += `Received message on ${msg.subject}:\n${ctrlMsgData}\n`;
|
||||
|
||||
// Scroll to bottom
|
||||
outputArea.scrollTop = outputArea.scrollHeight;
|
||||
} catch (error) {
|
||||
console.error("Error processing message:", error);
|
||||
outputArea.value += `\nError processing message: ${error.message}\n`;
|
||||
}
|
||||
}
|
||||
})().catch((err) => {
|
||||
console.error("Subscription error:", err);
|
||||
outputArea.value += `\nSubscription error: ${err.message}\n`;
|
||||
});
|
||||
|
||||
// Handle connection close
|
||||
nc.closed().then((err) => {
|
||||
if (err) {
|
||||
outputArea.value += `\nNATS connection closed with error: ${err.message}\n`;
|
||||
} else {
|
||||
outputArea.value += "\nNATS connection closed\n";
|
||||
}
|
||||
});
|
||||
|
||||
} catch (err) {
|
||||
outputArea.value += `Failed to connect to NATS server: ${err.message}\n`;
|
||||
}
|
||||
|
||||
// Send a command to list the files in the files folder.
|
||||
// We will later use the list we get back to ask for the content of each file.
|
||||
const js2 = {"toNodes": ["central"],"useDetectedShell": false,"method": "cliCommand","methodArgs": ["/bin/bash","-c","ls -1 files/"],
|
||||
"methodTimeout": 3,
|
||||
"fromNode": settings2.defaultNode,
|
||||
"replyMethod": "webUI",
|
||||
"replyMethodInstructions": ["templateFilesList"],
|
||||
"ACKTimeout": 0
|
||||
};
|
||||
nc.publish("central.cliCommand", JSON.stringify(js2));
|
||||
|
||||
// localStorage.clear();
|
||||
|
||||
// Disable default form submission
|
||||
commandForm.onsubmit = function(e) {
|
||||
e.preventDefault();
|
||||
return false;
|
||||
};
|
||||
|
||||
// Disable Enter key submission for all inputs except textarea
|
||||
commandForm.addEventListener('keypress', function(e) {
|
||||
if (e.key === 'Enter' && e.target.tagName !== 'TEXTAREA') {
|
||||
e.preventDefault();
|
||||
return false;
|
||||
}
|
||||
}, true);
|
||||
|
||||
initSplitter();
|
||||
});
|
||||
|
||||
function generateCommandData() {
|
||||
const methodArgs = Array.from(document.querySelectorAll('.method-arg'))
|
||||
.map(arg => arg.value)
|
||||
.filter(value => value !== ''); // Remove empty values
|
||||
|
||||
return {
|
||||
toNodes: document.getElementById('toNodes').value.split(',').map(node => node.trim()).filter(node => node !== ''),
|
||||
jetstreamToNode: document.getElementById('jetstreamToNode').value || undefined,
|
||||
useDetectedShell: document.getElementById('useDetectedShell').value === 'true',
|
||||
method: document.getElementById('method').value,
|
||||
methodArgs: methodArgs,
|
||||
methodTimeout: parseInt(document.getElementById('methodTimeout').value),
|
||||
fromNode: settings2.defaultNode,
|
||||
replyMethod: document.getElementById('replyMethod').value,
|
||||
ACKTimeout: parseInt(document.getElementById('ackTimeout').value)
|
||||
};
|
||||
}
|
||||
|
||||
// Handle Send button click
|
||||
sendBtn.addEventListener('click', function(e) {
|
||||
e.preventDefault();
|
||||
|
||||
if (!nc) {
|
||||
outputArea.value += "Error: Not connected to NATS server\n";
|
||||
return false;
|
||||
}
|
||||
|
||||
const formData = generateCommandData();
|
||||
|
||||
// Check if Method Arg 3 contains a CTRL_FILE template
|
||||
if (formData.methodArgs[2] && formData.methodArgs[2].includes('{{CTRL_FILE:')) {
|
||||
// Extract the file path from the template
|
||||
const match = formData.methodArgs[2].match(/{{CTRL_FILE:([^}]+)}}/);
|
||||
if (match) {
|
||||
const filePath = match[1];
|
||||
const fileName = filePath.split('/').pop(); // Get just the filename
|
||||
const filelist = JSON.parse(localStorage.getItem('filelist') || '{}');
|
||||
const fileContent = filelist[fileName] && filelist[fileName].content;
|
||||
|
||||
if (fileContent) {
|
||||
// Replace the entire CTRL_FILE clause with the file content
|
||||
formData.methodArgs[2] = formData.methodArgs[2].replace(/{{CTRL_FILE:[^}]+}}/, fileContent);
|
||||
} else {
|
||||
outputArea.value += `\nError: File content not found for ${fileName}\n`;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Send the command to the NATS server
|
||||
for (const toNode of formData.toNodes) {
|
||||
console.log("Sending command to "+toNode);
|
||||
nc.publish(toNode+"."+formData.method, JSON.stringify(formData));
|
||||
}
|
||||
|
||||
return false;
|
||||
});
|
||||
|
||||
// Handle Generate Command button click
|
||||
generateBtn.addEventListener('click', function(e) {
|
||||
e.preventDefault();
|
||||
|
||||
const formData = generateCommandData();
|
||||
const output = formatCommandYaml(formData);
|
||||
|
||||
outputArea.value += output;
|
||||
return false;
|
||||
});
|
||||
|
||||
// Handle Write File button click
|
||||
writeFileBtn.addEventListener('click', function(e) {
|
||||
e.preventDefault();
|
||||
const formData = generateCommandData();
|
||||
const output = formatCommandYaml(formData);
|
||||
outputArea.value += output;
|
||||
|
||||
// Use hardcoded path instead of reading from input
|
||||
writeToFile(output, '/Users/bt/ctrl/tmp/bt1/readfolder/command.yaml');
|
||||
|
||||
return false;
|
||||
});
|
||||
|
||||
// Add this after the other event listeners in the DOMContentLoaded function
|
||||
|
||||
fileTemplatesLink.addEventListener("click", function (e) {
|
||||
e.preventDefault();
|
||||
|
||||
// Get the popup content div and clear it
|
||||
const popupContent = document.querySelector('.popup-content');
|
||||
|
||||
// Get filelist from localStorage
|
||||
const filelist = JSON.parse(localStorage.getItem('filelist') || '{}');
|
||||
const fileListHTML = Object.keys(filelist)
|
||||
.map(fileName =>
|
||||
fileName.trim() ? `<div class="file-item">${fileName}</div>` : ''
|
||||
).join('');
|
||||
|
||||
// Add title and container for files
|
||||
popupContent.innerHTML = `
|
||||
<span class="close-popup">×</span>
|
||||
<h2>File Templates</h2>
|
||||
<div class="files-list">
|
||||
${fileListHTML}
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Re-attach close button handler since we replaced the content
|
||||
document.querySelector('.close-popup').addEventListener('click', function() {
|
||||
fileTemplatesPopup.style.display = "none";
|
||||
});
|
||||
|
||||
// Add click handlers for file items
|
||||
document.querySelectorAll('.file-item').forEach(item => {
|
||||
item.addEventListener('click', () => {
|
||||
const methodArg3 = document.querySelector('.method-arg:last-child');
|
||||
methodArg3.value = `{{CTRL_FILE:files/${item.textContent}}}`;
|
||||
fileTemplatesPopup.style.display = "none";
|
||||
});
|
||||
});
|
||||
|
||||
fileTemplatesPopup.style.display = "block";
|
||||
closeMenu();
|
||||
});
|
||||
|
||||
// Add keyboard shortcut for template popup
|
||||
document.addEventListener('keydown', function(e) {
|
||||
// Check if Ctrl+T was pressed (and not Cmd+T on Mac which is for new tab)
|
||||
if (e.ctrlKey && e.key === 't') {
|
||||
e.preventDefault(); // Prevent default browser behavior
|
||||
|
||||
// Show the template popup
|
||||
fileTemplatesPopup.style.display = "block";
|
||||
|
||||
// Get the popup content div and clear it
|
||||
const popupContent = document.querySelector('.popup-content');
|
||||
|
||||
// Get filelist from localStorage
|
||||
const filelist = JSON.parse(localStorage.getItem('filelist') || '{}');
|
||||
const fileListHTML = Object.keys(filelist)
|
||||
.map(fileName =>
|
||||
fileName.trim() ? `<div class="file-item">${fileName}</div>` : ''
|
||||
).join('');
|
||||
|
||||
// Add title and container for files
|
||||
popupContent.innerHTML = `
|
||||
<span class="close-popup">×</span>
|
||||
<h2>File Templates</h2>
|
||||
<div class="files-list">
|
||||
${fileListHTML}
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Re-attach close button handler
|
||||
document.querySelector('.close-popup').addEventListener('click', function() {
|
||||
fileTemplatesPopup.style.display = "none";
|
||||
});
|
||||
|
||||
// Add click handlers for file items
|
||||
document.querySelectorAll('.file-item').forEach(item => {
|
||||
item.addEventListener('click', () => {
|
||||
const methodArg3 = document.querySelector('.method-arg:last-child');
|
||||
methodArg3.value = `{{CTRL_FILE:files/${item.textContent}}}`;
|
||||
fileTemplatesPopup.style.display = "none";
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
function writeToFile(content, filename) {
|
||||
// Create a Blob containing the text content
|
||||
const blob = new Blob([content], { type: 'text/plain' });
|
||||
|
||||
// Create a URL for the Blob
|
||||
const url = globalThis.URL.createObjectURL(blob);
|
||||
|
||||
// Create a temporary anchor element
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = filename; // Use the provided filename
|
||||
|
||||
// Append link to body, click it, and remove it
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
|
||||
// Clean up by revoking the Blob URL
|
||||
globalThis.URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
|
||||
function decodeBase64(base64) {
|
||||
const text = atob(base64);
|
||||
const length = text.length;
|
||||
const bytes = new Uint8Array(length);
|
||||
for (let i = 0; i < length; i++) {
|
||||
bytes[i] = text.charCodeAt(i);
|
||||
}
|
||||
const decoder = new TextDecoder(); // default is utf-8
|
||||
return decoder.decode(bytes);
|
||||
}
|
||||
|
||||
function formatCommandYaml(commandData) {
|
||||
const output = `---
|
||||
- toNodes:
|
||||
- ${commandData.toNodes[0]}
|
||||
${commandData.jetstreamToNode ? `jetstreamToNode: ${commandData.jetstreamToNode}` : '#jetstreamToNode: btdev1'}
|
||||
useDetectedShell: ${commandData.useDetectedShell}
|
||||
method: ${commandData.method}
|
||||
methodArgs:
|
||||
- ${commandData.methodArgs.join('\n - ')}
|
||||
methodTimeout: ${commandData.methodTimeout}
|
||||
replyMethod: ${commandData.replyMethod}
|
||||
ACKTimeout: ${commandData.ACKTimeout}`;
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
// Add splitter functionality
|
||||
function initSplitter() {
|
||||
const splitter = document.createElement('div');
|
||||
splitter.className = 'splitter';
|
||||
|
||||
const leftPanel = document.querySelector('.left-panel');
|
||||
const container = document.querySelector('.split-container');
|
||||
|
||||
let isDragging = false;
|
||||
|
||||
splitter.addEventListener('mousedown', function(e) {
|
||||
isDragging = true;
|
||||
document.body.style.cursor = 'col-resize';
|
||||
|
||||
document.addEventListener('mousemove', handleMouseMove);
|
||||
document.addEventListener('mouseup', handleMouseUp);
|
||||
|
||||
// Prevent text selection while dragging
|
||||
e.preventDefault();
|
||||
});
|
||||
|
||||
function handleMouseMove(e) {
|
||||
if (!isDragging) return;
|
||||
|
||||
const containerRect = container.getBoundingClientRect();
|
||||
let newWidth = e.clientX - containerRect.left;
|
||||
|
||||
// Calculate percentage width
|
||||
const containerWidth = containerRect.width;
|
||||
const minWidth = 200;
|
||||
const maxWidth = containerWidth - 200;
|
||||
|
||||
// Constrain the width
|
||||
newWidth = Math.max(minWidth, Math.min(maxWidth, newWidth));
|
||||
|
||||
leftPanel.style.width = `${newWidth}px`;
|
||||
}
|
||||
|
||||
function handleMouseUp() {
|
||||
isDragging = false;
|
||||
document.body.style.cursor = '';
|
||||
document.removeEventListener('mousemove', handleMouseMove);
|
||||
document.removeEventListener('mouseup', handleMouseUp);
|
||||
}
|
||||
|
||||
// Insert splitter between panels
|
||||
leftPanel.after(splitter);
|
||||
}
|
||||
|
||||
// Add these styles to the existing style section in the head
|
||||
const style = document.createElement('style');
|
||||
style.textContent = `
|
||||
.files-list {
|
||||
margin-top: 20px;
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.file-item {
|
||||
padding: 10px;
|
||||
border: 1px solid #ddd;
|
||||
margin-bottom: 5px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.file-item:hover {
|
||||
background-color: #f0f0f0;
|
||||
}
|
||||
|
||||
.popup-content h2 {
|
||||
margin-top: 0;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(style);
|
76
webui/settings.html
Normal file
76
webui/settings.html
Normal file
|
@ -0,0 +1,76 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Settings - Command Dashboard</title>
|
||||
<link rel="stylesheet" href="styles.css" />
|
||||
</head>
|
||||
<body>
|
||||
<div class="hamburger-menu">
|
||||
<div class="hamburger-icon">
|
||||
<span></span>
|
||||
<span></span>
|
||||
<span></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav class="side-menu">
|
||||
<a href="index.html">Command</a>
|
||||
<a href="#" id="fileTemplatesLink">File Templates</a>
|
||||
<a href="settings.html" class="active">Settings</a>
|
||||
</nav>
|
||||
|
||||
<div class="settings-container">
|
||||
<h1>Settings</h1>
|
||||
<button id="generateNkeysBtn" class="settings-button">
|
||||
Generate Nkeys
|
||||
</button>
|
||||
<div class="form-group nkeys-group">
|
||||
<div class="form-group">
|
||||
<label for="nkeysSeedRaw">Nkeys SeedRaw:</label>
|
||||
<input
|
||||
type="text"
|
||||
id="nkeysSeedRaw"
|
||||
placeholder="Enter seed raw value..."
|
||||
/>
|
||||
<button id="updateKeysBtn" class="settings-button">
|
||||
Update Keys
|
||||
</button>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="nkeysSeed">Nkeys Seed:</label>
|
||||
<input type="text" id="nkeysSeed" readonly />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="nkeysPublic">Nkeys Public:</label>
|
||||
<input type="text" id="nkeysPublic" readonly />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="useNkeys">
|
||||
<input type="checkbox" id="useNkeys" />
|
||||
Use Nkeys
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<form id="settingsForm">
|
||||
<div class="form-group">
|
||||
<label for="natsServer">NATS Server URL:</label>
|
||||
<input type="text" id="natsServer" value="ws://localhost:4223" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="defaultNode">Default Node:</label>
|
||||
<input type="text" id="defaultNode" value="btdev1" />
|
||||
</div>
|
||||
</form>
|
||||
<button id="saveSettingsBtn" class="settings-button">
|
||||
Save Settings
|
||||
</button>
|
||||
<button id="clearAllBtn" class="settings-button clear-button">
|
||||
Clear All Information
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<script type="module" src="settings.js"></script>
|
||||
</body>
|
||||
</html>
|
150
webui/settings.js
Normal file
150
webui/settings.js
Normal file
|
@ -0,0 +1,150 @@
|
|||
/* jshint esversion: 9 */
|
||||
import { createUser, fromPublic, fromSeed } from "https://esm.run/@nats-io/nkeys";
|
||||
// import { createUser, fromSeed } from "https://cdn.jsdelivr.net/npm/@nats.io/nkeys@3.1.1/lib/nkeys.js";
|
||||
|
||||
const hamburgerMenu = document.querySelector(".hamburger-menu");
|
||||
const generateNkeysBtn = document.getElementById("generateNkeysBtn");
|
||||
const saveSettingsBtn = document.getElementById("saveSettingsBtn");
|
||||
const natsServerInput = document.getElementById("natsServer");
|
||||
const defaultNodeInput = document.getElementById("defaultNode");
|
||||
const nkeysSeedInput = document.getElementById("nkeysSeed");
|
||||
const nkeysPublicInput = document.getElementById("nkeysPublic");
|
||||
const useNkeysCheckbox = document.getElementById("useNkeys");
|
||||
const clearAllBtn = document.getElementById("clearAllBtn");
|
||||
const nkeysSeedRawInput = document.getElementById("nkeysSeedRaw");
|
||||
const updateKeysBtn = document.getElementById("updateKeysBtn");
|
||||
|
||||
// Global settings object
|
||||
const settings = {
|
||||
natsServer: "ws://localhost:4223",
|
||||
defaultNode: "btdev1",
|
||||
nkeysSeed: "",
|
||||
nkeysPublic: "",
|
||||
useNkeys: false,
|
||||
nkeysSeedRAW: null
|
||||
};
|
||||
|
||||
// Add input event listeners to update settings object
|
||||
natsServerInput.addEventListener('input', () => {
|
||||
settings.natsServer = natsServerInput.value;
|
||||
});
|
||||
|
||||
defaultNodeInput.addEventListener('input', () => {
|
||||
settings.defaultNode = defaultNodeInput.value;
|
||||
});
|
||||
|
||||
useNkeysCheckbox.addEventListener('change', () => {
|
||||
settings.useNkeys = useNkeysCheckbox.checked;
|
||||
});
|
||||
|
||||
hamburgerMenu.addEventListener("click", function () {
|
||||
document.body.classList.toggle("menu-open");
|
||||
});
|
||||
|
||||
// Add click handler for the generate nkeys button
|
||||
generateNkeysBtn.addEventListener("click", function () {
|
||||
// create an user nkey KeyPair
|
||||
const user = createUser();
|
||||
// A seed is the public and private keys together.
|
||||
const seed = user.getSeed();
|
||||
|
||||
// console.log("DEBUG: seed array:", Array.from(seed));
|
||||
const publicKey = user.getPublicKey();
|
||||
|
||||
// Update settings object
|
||||
settings.nkeysSeed = new TextDecoder().decode(seed);
|
||||
settings.nkeysPublic = publicKey;
|
||||
settings.nkeysSeedRAW = Array.from(seed); // Store as regular array for JSON serialization
|
||||
|
||||
// console.log("DEBUG: settings.nkeysSeedRAW:", settings.nkeysSeedRAW);
|
||||
|
||||
// Update the input fields
|
||||
nkeysSeedInput.value = settings.nkeysSeed;
|
||||
nkeysPublicInput.value = settings.nkeysPublic;
|
||||
nkeysSeedRawInput.value = settings.nkeysSeedRAW.join(',');
|
||||
});
|
||||
|
||||
// Load existing settings when page loads
|
||||
document.addEventListener("DOMContentLoaded", function () {
|
||||
const savedSettings = localStorage.getItem("settings");
|
||||
|
||||
if (savedSettings) {
|
||||
// Update our settings object with saved values
|
||||
const loadedSettings = JSON.parse(savedSettings);
|
||||
Object.assign(settings, loadedSettings);
|
||||
|
||||
// Update UI with values from settings object
|
||||
natsServerInput.value = settings.natsServer;
|
||||
defaultNodeInput.value = settings.defaultNode;
|
||||
nkeysSeedInput.value = settings.nkeysSeed || "";
|
||||
nkeysPublicInput.value = settings.nkeysPublic || "";
|
||||
nkeysSeedRawInput.value = settings.nkeysSeedRAW ? settings.nkeysSeedRAW.join(',') : "";
|
||||
useNkeysCheckbox.checked = settings.useNkeys;
|
||||
|
||||
if (settings.nkeysSeedRAW) {
|
||||
// Convert the stored array back to Uint8Array
|
||||
settings.nkeysSeedRAW = Uint8Array.from(settings.nkeysSeedRAW);
|
||||
}
|
||||
}
|
||||
|
||||
// console.log("DEBUG: Loaded settings:", settings);
|
||||
// console.log("DEBUG: Loaded nkeysSeedRAW:", settings.nkeysSeedRAW ? Array.from(settings.nkeysSeedRAW) : "not set");
|
||||
});
|
||||
|
||||
// Handle save settings button click
|
||||
saveSettingsBtn.addEventListener("click", function () {
|
||||
// Save settings object directly to localStorage
|
||||
localStorage.setItem("settings", JSON.stringify(settings));
|
||||
|
||||
// Show save confirmation
|
||||
alert("Settings saved successfully!");
|
||||
});
|
||||
|
||||
// Handle clear all button click
|
||||
clearAllBtn.addEventListener("click", function () {
|
||||
// Show confirmation dialog
|
||||
if (confirm("Are you sure you want to clear all information? This will delete all settings and file templates.")) {
|
||||
// Clear localStorage
|
||||
localStorage.clear();
|
||||
|
||||
// Reset settings object to defaults
|
||||
settings.natsServer = "ws://localhost:4223";
|
||||
settings.defaultNode = "btdev1";
|
||||
settings.nkeysSeed = "";
|
||||
settings.nkeysPublic = "";
|
||||
settings.useNkeys = false;
|
||||
settings.nkeysSeedRAW = null;
|
||||
|
||||
// Reset form fields
|
||||
natsServerInput.value = settings.natsServer;
|
||||
defaultNodeInput.value = settings.defaultNode;
|
||||
nkeysSeedInput.value = "";
|
||||
nkeysPublicInput.value = "";
|
||||
useNkeysCheckbox.checked = false;
|
||||
|
||||
alert("All information has been cleared.");
|
||||
}
|
||||
});
|
||||
|
||||
// Add click handler for update keys button
|
||||
updateKeysBtn.addEventListener('click', function() {
|
||||
try {
|
||||
// Convert input string to array of numbers
|
||||
const rawArray = nkeysSeedRawInput.value.split(',').map(num => parseInt(num.trim()));
|
||||
const seedRaw = new Uint8Array(rawArray);
|
||||
|
||||
// Create key pair from seed
|
||||
const keyPair = fromSeed(seedRaw);
|
||||
|
||||
// Update settings and fields
|
||||
settings.nkeysSeedRAW = Array.from(seedRaw);
|
||||
settings.nkeysSeed = new TextDecoder().decode(seedRaw);
|
||||
settings.nkeysPublic = keyPair.getPublicKey();
|
||||
|
||||
nkeysSeedInput.value = settings.nkeysSeed;
|
||||
nkeysPublicInput.value = settings.nkeysPublic;
|
||||
} catch (error) {
|
||||
console.error("Invalid seed raw value:", error);
|
||||
alert("Invalid seed raw value. Please check the format.");
|
||||
}
|
||||
});
|
319
webui/styles.css
Normal file
319
webui/styles.css
Normal file
|
@ -0,0 +1,319 @@
|
|||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: "Segoe UI", Tahoma, Geneva, Verdana, sans-serif;
|
||||
background-color: #f5f6fa;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.split-container {
|
||||
display: flex;
|
||||
height: 100vh;
|
||||
position: relative;
|
||||
padding-left: 60px;
|
||||
}
|
||||
|
||||
.left-panel {
|
||||
min-width: 200px;
|
||||
padding: 20px;
|
||||
overflow-y: auto;
|
||||
font-family: "Courier New", monospace;
|
||||
font-size: 14px;
|
||||
width: 50%; /* Set initial width */
|
||||
}
|
||||
|
||||
.right-panel {
|
||||
flex: 1; /* Take remaining space */
|
||||
padding: 20px;
|
||||
background-color: #2c3e50;
|
||||
min-width: 200px;
|
||||
}
|
||||
|
||||
#outputArea {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: #34495e;
|
||||
color: #ecf0f1;
|
||||
font-family: "Courier New", monospace;
|
||||
padding: 15px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
resize: none;
|
||||
white-space: pre-wrap;
|
||||
font-size: 14px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
form {
|
||||
background-color: white;
|
||||
padding: 30px;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
label {
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
font-weight: 600;
|
||||
color: #2c3e50;
|
||||
font-family: "Courier New", monospace;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
input,
|
||||
select,
|
||||
textarea {
|
||||
width: 100%;
|
||||
padding: 10px;
|
||||
border: 1px solid #dcdde1;
|
||||
border-radius: 4px;
|
||||
font-family: "Courier New", monospace;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
textarea {
|
||||
min-height: 100px;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.method-args input,
|
||||
.method-args textarea {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
button {
|
||||
background-color: #3498db;
|
||||
color: white;
|
||||
padding: 12px 24px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-family: "Courier New", monospace;
|
||||
font-size: 14px;
|
||||
transition: background-color 0.3s;
|
||||
}
|
||||
|
||||
button:hover {
|
||||
background-color: #2980b9;
|
||||
}
|
||||
|
||||
.active-connections {
|
||||
background-color: white;
|
||||
padding: 30px;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
#connectionsList {
|
||||
display: grid;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.connection-item {
|
||||
border: 1px solid var(--border-color);
|
||||
padding: 15px;
|
||||
border-radius: 4px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.connection-info {
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.connection-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.delete-btn {
|
||||
background-color: #e74c3c;
|
||||
}
|
||||
|
||||
.delete-btn:hover {
|
||||
background-color: #c0392b;
|
||||
}
|
||||
|
||||
.button-group {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.button-group button {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.splitter {
|
||||
width: 8px;
|
||||
background-color: #cbd5e0;
|
||||
cursor: col-resize;
|
||||
transition: background-color 0.2s;
|
||||
user-select: none; /* Prevent text selection while dragging */
|
||||
}
|
||||
|
||||
.splitter:hover,
|
||||
.splitter.dragging {
|
||||
background-color: #3498db;
|
||||
}
|
||||
|
||||
/* Hamburger Menu Styles */
|
||||
.hamburger-menu {
|
||||
position: fixed;
|
||||
top: 20px;
|
||||
left: 20px;
|
||||
z-index: 100;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.hamburger-icon {
|
||||
width: 30px;
|
||||
height: 20px;
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.hamburger-icon span {
|
||||
display: block;
|
||||
position: absolute;
|
||||
height: 3px;
|
||||
width: 100%;
|
||||
background: #2c3e50;
|
||||
border-radius: 3px;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.hamburger-icon span:nth-child(1) {
|
||||
top: 0;
|
||||
}
|
||||
.hamburger-icon span:nth-child(2) {
|
||||
top: 8px;
|
||||
}
|
||||
.hamburger-icon span:nth-child(3) {
|
||||
top: 16px;
|
||||
}
|
||||
|
||||
.menu-open .hamburger-icon span:nth-child(1) {
|
||||
transform: rotate(45deg);
|
||||
top: 8px;
|
||||
}
|
||||
|
||||
.menu-open .hamburger-icon span:nth-child(2) {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.menu-open .hamburger-icon span:nth-child(3) {
|
||||
transform: rotate(-45deg);
|
||||
top: 8px;
|
||||
}
|
||||
|
||||
.side-menu {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: -250px;
|
||||
width: 250px;
|
||||
height: 100vh;
|
||||
background: #2c3e50;
|
||||
padding-top: 60px;
|
||||
transition: left 0.3s ease;
|
||||
z-index: 99;
|
||||
}
|
||||
|
||||
.menu-open .side-menu {
|
||||
left: 0;
|
||||
}
|
||||
|
||||
.side-menu a {
|
||||
display: block;
|
||||
padding: 15px 25px;
|
||||
color: white;
|
||||
text-decoration: none;
|
||||
font-family: "Courier New", monospace;
|
||||
font-size: 14px;
|
||||
transition: background-color 0.3s;
|
||||
}
|
||||
|
||||
.side-menu a:hover {
|
||||
background: #34495e;
|
||||
}
|
||||
|
||||
.side-menu a.active {
|
||||
background: #3498db;
|
||||
}
|
||||
|
||||
.settings-container {
|
||||
padding: 20px;
|
||||
margin-left: 60px; /* Space for the hamburger menu */
|
||||
}
|
||||
|
||||
.settings-container h1 {
|
||||
margin-bottom: 30px;
|
||||
color: #2c3e50;
|
||||
font-family: "Courier New", monospace;
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
#settingsForm {
|
||||
background: white;
|
||||
padding: 30px;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.settings-button {
|
||||
padding: 10px 20px;
|
||||
background-color: #4caf50;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 16px;
|
||||
margin: 10px 0;
|
||||
}
|
||||
|
||||
.settings-button:hover {
|
||||
background-color: #45a049;
|
||||
}
|
||||
|
||||
.clear-button {
|
||||
background-color: #e74c3c;
|
||||
margin-left: 10px;
|
||||
}
|
||||
|
||||
.clear-button:hover {
|
||||
background-color: #c0392b;
|
||||
}
|
||||
|
||||
.nkeys-group {
|
||||
background: white;
|
||||
padding: 20px;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
margin: 20px 0;
|
||||
}
|
||||
|
||||
.nkeys-group input[readonly] {
|
||||
background-color: #f5f5f5;
|
||||
cursor: text;
|
||||
}
|
||||
|
||||
/* Checkbox styling */
|
||||
.nkeys-group .form-group label[for="useNkeys"] {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.nkeys-group .form-group input[type="checkbox"] {
|
||||
width: auto;
|
||||
margin: 0;
|
||||
}
|
Loading…
Add table
Reference in a new issue