diff --git a/.gitignore b/.gitignore index e0c919d..a2fb4eb 100644 --- a/.gitignore +++ b/.gitignore @@ -18,3 +18,4 @@ notes.txt toolbox/ signing/ frontend/ +.vscode/ diff --git a/go.mod b/go.mod index 66cf4d0..c27016d 100644 --- a/go.mod +++ b/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 diff --git a/go.sum b/go.sum index 96f54eb..3790e1d 100644 --- a/go.sum +++ b/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= diff --git a/message_and_subject.go b/message_and_subject.go index c9261bd..450defa 100644 --- a/message_and_subject.go +++ b/message_and_subject.go @@ -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 diff --git a/message_readers.go b/message_readers.go index 8d6bd10..495612f 100644 --- a/message_readers.go +++ b/message_readers.go @@ -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 { diff --git a/requests.go b/requests.go index de9cb63..a08fa8f 100644 --- a/requests.go +++ b/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. diff --git a/server.go b/server.go index ae23a93..c53c945 100644 --- a/server.go +++ b/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() diff --git a/tui/files/script.sh b/tui/files/script.sh new file mode 100644 index 0000000..f748bdd --- /dev/null +++ b/tui/files/script.sh @@ -0,0 +1 @@ +pwd diff --git a/tui/files/script2.sh b/tui/files/script2.sh new file mode 100644 index 0000000..f748bdd --- /dev/null +++ b/tui/files/script2.sh @@ -0,0 +1 @@ +pwd diff --git a/tui/files/script3.sh b/tui/files/script3.sh new file mode 100644 index 0000000..f748bdd --- /dev/null +++ b/tui/files/script3.sh @@ -0,0 +1 @@ +pwd diff --git a/tui/main.go b/tui/main.go new file mode 100644 index 0000000..ba7edca --- /dev/null +++ b/tui/main.go @@ -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) + } +} diff --git a/webui/index.html b/webui/index.html new file mode 100644 index 0000000..dcbdb73 --- /dev/null +++ b/webui/index.html @@ -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> diff --git a/webui/main.go b/webui/main.go new file mode 100644 index 0000000..a06ba65 --- /dev/null +++ b/webui/main.go @@ -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) + } +} diff --git a/webui/script.js b/webui/script.js new file mode 100644 index 0000000..957f33c --- /dev/null +++ b/webui/script.js @@ -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); diff --git a/webui/settings.html b/webui/settings.html new file mode 100644 index 0000000..23afc8a --- /dev/null +++ b/webui/settings.html @@ -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> diff --git a/webui/settings.js b/webui/settings.js new file mode 100644 index 0000000..5f21d59 --- /dev/null +++ b/webui/settings.js @@ -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."); + } +}); \ No newline at end of file diff --git a/webui/styles.css b/webui/styles.css new file mode 100644 index 0000000..61b6c5d --- /dev/null +++ b/webui/styles.css @@ -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; +}