1
0
Fork 0
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:
postmannen 2025-02-14 06:43:52 +01:00
parent ee8474e71a
commit 5d99554c3b
17 changed files with 1803 additions and 25 deletions

1
.gitignore vendored
View file

@ -18,3 +18,4 @@ notes.txt
toolbox/
signing/
frontend/
.vscode/

13
go.mod
View file

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

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

View file

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

View file

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

View file

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

View file

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

@ -0,0 +1 @@
pwd

1
tui/files/script2.sh Normal file
View file

@ -0,0 +1 @@
pwd

1
tui/files/script3.sh Normal file
View file

@ -0,0 +1 @@
pwd

397
tui/main.go Normal file
View 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
View 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">&times;</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
View 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
View 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">&times;</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">&times;</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
View 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
View 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
View 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;
}