feat(tests): add comprehensive tests for ProxyHosts and Uptime components
- Introduced isolated coverage tests for ProxyHosts with various scenarios including rendering, bulk apply, and link behavior. - Enhanced existing ProxyHosts coverage tests to include additional assertions and error handling. - Added tests for Uptime component to verify rendering and monitoring toggling functionality. - Created utility functions for setting labels and help texts related to proxy host settings. - Implemented bulk settings application logic with progress tracking and error handling. - Added toast utility tests to ensure callback functionality and ID incrementing. - Improved type safety in test files by using appropriate TypeScript types.
This commit is contained in:
@@ -1,5 +1,3 @@
|
||||
cloud.google.com/go/compute v1.14.0/go.mod h1:YfLtxrj9sU4Yxv+sXzZkyPjEyPBZfXHUvjxega5vAdo=
|
||||
cloud.google.com/go/compute/metadata v0.2.3/go.mod h1:VAV5nSsACxMJvgaAuX6Pk2AawlZn8kiOGuCv6gTkwuA=
|
||||
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg=
|
||||
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
|
||||
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
|
||||
@@ -12,17 +10,14 @@ github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1x
|
||||
github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw=
|
||||
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
|
||||
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
|
||||
github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=
|
||||
github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI=
|
||||
github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M=
|
||||
github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE=
|
||||
github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk=
|
||||
github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I=
|
||||
github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo=
|
||||
github.com/containerd/typeurl/v2 v2.2.0/go.mod h1:8XOOxnyatxSWuG8OfsZXVnAF4iZfedjS/8UHSPJnX4g=
|
||||
github.com/containrrr/shoutrrr v0.8.0 h1:mfG2ATzIS7NR2Ec6XL+xyoHzN97H8WPjir8aYzJUSec=
|
||||
github.com/containrrr/shoutrrr v0.8.0/go.mod h1:ioyQAyu1LJY6sILuNyKaQaw+9Ttik5QePU8atnAdO2o=
|
||||
github.com/creack/pty v1.1.18/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
@@ -38,7 +33,6 @@ github.com/fatih/color v1.15.0 h1:kOqh6YHBtK8aywxGerMG2Eq3H6Qgoqeo13Bk2Mv/nBs=
|
||||
github.com/fatih/color v1.15.0/go.mod h1:0h5ZqXfHYED7Bhv2ZJamyIOUej9KtShiJESRwBDUSsw=
|
||||
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
|
||||
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
|
||||
github.com/fsnotify/fsnotify v1.6.0/go.mod h1:sl3t1tCWJFWoRz9R8WJCbQihKKwmorjAbSClcnxKAGw=
|
||||
github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM=
|
||||
github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8=
|
||||
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
|
||||
@@ -80,8 +74,6 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.2 h1:8Tjv8EJ+pM1xP8mK6egEbD1OgnVTyacbefKhmbLhIhU=
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.2/go.mod h1:pkJQ2tZHJ0aFOVEEot6oZmaVEZcRme73eIFmhiVuRWs=
|
||||
github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
|
||||
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||
github.com/jarcoal/httpmock v1.3.0 h1:2RJ8GP0IIaWwcC9Fp2BmVi8Kog3v2Hn7VXM3fTd+nuc=
|
||||
github.com/jarcoal/httpmock v1.3.0/go.mod h1:3yb8rc4BI7TCBhFY8ng0gjuLKJNquuDNiPaZjnENuYg=
|
||||
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
|
||||
@@ -98,7 +90,6 @@ 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.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
||||
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
||||
github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
|
||||
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
|
||||
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
|
||||
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
|
||||
@@ -106,7 +97,6 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
|
||||
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
|
||||
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
|
||||
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
|
||||
github.com/moby/sys/atomicwriter v0.1.0 h1:kw5D/EqkBwsBFi0ss9v1VG3wIkVhzGvLklJ+w3A14Sw=
|
||||
@@ -144,27 +134,17 @@ github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
|
||||
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
|
||||
github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII=
|
||||
github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o=
|
||||
github.com/russross/blackfriday v1.6.0/go.mod h1:ti0ldHuxg49ri4ksnFxlkCfN+hvslNlmVHqNRXXJNAY=
|
||||
github.com/santhosh-tekuri/jsonschema/v5 v5.3.1/go.mod h1:uToXkOrWAZ6/Oc07xWQrPOhJotwFIyu2bBVN41fcDUY=
|
||||
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
|
||||
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
|
||||
github.com/spf13/afero v1.9.3/go.mod h1:iUV7ddyEEZPO5gA3zD4fJt6iStLlL+Lg4m2cihcDf8Y=
|
||||
github.com/spf13/cast v1.5.0/go.mod h1:SpXXQ5YoyJw6s3/6cMTQuxvgRl3PCJiyaX9p6b155UU=
|
||||
github.com/spf13/cobra v1.7.0/go.mod h1:uLxZILRyS/50WlhOIKD7W6V5bgeIt+4sICxh6uRMrb0=
|
||||
github.com/spf13/jwalterweatherman v1.1.0/go.mod h1:aNWZUN0dPAAO/Ljvb5BEdw96iTZ0EXowPYD95IqWIGo=
|
||||
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/spf13/viper v1.15.0/go.mod h1:fFcTBJxvhhzSJiZy8n+PeW6t8l+KeT/uTARa0jHOQLA=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/subosito/gotenv v1.4.2/go.mod h1:ayKnFf/c6rvx/2iiLrJUk1e6plDbT3edrFNGqEflhK0=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||
github.com/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA=
|
||||
@@ -206,15 +186,12 @@ golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBc
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
|
||||
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254=
|
||||
golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
|
||||
golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
|
||||
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
|
||||
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
|
||||
golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ=
|
||||
golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20250825161204-c5933d9347a5 h1:BIRfGDEjiHRrk0QKZe3Xv2ieMhtgRGeLcZQ0mIVn4EY=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20250825161204-c5933d9347a5/go.mod h1:j3QtIyytwqGr1JUDtYXwtMXWPKsEa5LtzIFN1Wn5WvE=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20250825161204-c5933d9347a5 h1:eaY8u2EuxbRv7c3NiGK0/NedzVsCcV6hDuU5qPX5EGE=
|
||||
@@ -226,7 +203,6 @@ google.golang.org/protobuf v1.36.9/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXn
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
|
||||
gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc=
|
||||
gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
@@ -238,4 +214,3 @@ gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg=
|
||||
gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
|
||||
gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q=
|
||||
gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA=
|
||||
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=
|
||||
|
||||
@@ -267,7 +267,7 @@ func TestProxyHostHandler_List(t *testing.T) {
|
||||
db.Create(host)
|
||||
|
||||
ns := services.NewNotificationService(db)
|
||||
handler := handlers.NewProxyHostHandler(db, nil, ns)
|
||||
handler := handlers.NewProxyHostHandler(db, nil, ns, nil)
|
||||
router := gin.New()
|
||||
handler.RegisterRoutes(router.Group("/api/v1"))
|
||||
|
||||
@@ -290,7 +290,7 @@ func TestProxyHostHandler_Create(t *testing.T) {
|
||||
db := setupTestDB()
|
||||
|
||||
ns := services.NewNotificationService(db)
|
||||
handler := handlers.NewProxyHostHandler(db, nil, ns)
|
||||
handler := handlers.NewProxyHostHandler(db, nil, ns, nil)
|
||||
router := gin.New()
|
||||
handler.RegisterRoutes(router.Group("/api/v1"))
|
||||
|
||||
|
||||
@@ -22,14 +22,16 @@ type ProxyHostHandler struct {
|
||||
service *services.ProxyHostService
|
||||
caddyManager *caddy.Manager
|
||||
notificationService *services.NotificationService
|
||||
uptimeService *services.UptimeService
|
||||
}
|
||||
|
||||
// NewProxyHostHandler creates a new proxy host handler.
|
||||
func NewProxyHostHandler(db *gorm.DB, caddyManager *caddy.Manager, ns *services.NotificationService) *ProxyHostHandler {
|
||||
func NewProxyHostHandler(db *gorm.DB, caddyManager *caddy.Manager, ns *services.NotificationService, uptimeService *services.UptimeService) *ProxyHostHandler {
|
||||
return &ProxyHostHandler{
|
||||
service: services.NewProxyHostService(db),
|
||||
caddyManager: caddyManager,
|
||||
notificationService: ns,
|
||||
uptimeService: uptimeService,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -215,6 +217,19 @@ func (h *ProxyHostHandler) Delete(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// check if we should also delete associated uptime monitors (query param: delete_uptime=true)
|
||||
deleteUptime := c.DefaultQuery("delete_uptime", "false") == "true"
|
||||
|
||||
if deleteUptime && h.uptimeService != nil {
|
||||
// Find all monitors referencing this proxy host and delete each
|
||||
var monitors []models.UptimeMonitor
|
||||
if err := h.uptimeService.DB.Where("proxy_host_id = ?", host.ID).Find(&monitors).Error; err == nil {
|
||||
for _, m := range monitors {
|
||||
_ = h.uptimeService.DeleteMonitor(m.ID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if err := h.service.Delete(host.ID); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
|
||||
@@ -29,7 +29,7 @@ func setupTestRouter(t *testing.T) (*gin.Engine, *gorm.DB) {
|
||||
require.NoError(t, db.AutoMigrate(&models.ProxyHost{}, &models.Location{}))
|
||||
|
||||
ns := services.NewNotificationService(db)
|
||||
h := NewProxyHostHandler(db, nil, ns)
|
||||
h := NewProxyHostHandler(db, nil, ns, nil)
|
||||
r := gin.New()
|
||||
api := r.Group("/api/v1")
|
||||
h.RegisterRoutes(api)
|
||||
@@ -97,6 +97,47 @@ func TestProxyHostLifecycle(t *testing.T) {
|
||||
require.Equal(t, http.StatusNotFound, getResp2.Code)
|
||||
}
|
||||
|
||||
func TestProxyHostDelete_WithUptimeCleanup(t *testing.T) {
|
||||
// Setup DB and router with uptime service
|
||||
dsn := "file:test-delete-uptime?mode=memory&cache=shared"
|
||||
db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{})
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, db.AutoMigrate(&models.ProxyHost{}, &models.Location{}, &models.UptimeMonitor{}, &models.UptimeHeartbeat{}))
|
||||
|
||||
ns := services.NewNotificationService(db)
|
||||
us := services.NewUptimeService(db, ns)
|
||||
h := NewProxyHostHandler(db, nil, ns, us)
|
||||
|
||||
r := gin.New()
|
||||
api := r.Group("/api/v1")
|
||||
h.RegisterRoutes(api)
|
||||
|
||||
// Create host and monitor
|
||||
host := models.ProxyHost{UUID: "ph-delete-1", Name: "Del Host", DomainNames: "del.test", ForwardHost: "127.0.0.1", ForwardPort: 80}
|
||||
db.Create(&host)
|
||||
monitor := models.UptimeMonitor{ID: "ut-mon-1", ProxyHostID: &host.ID, Name: "linked", Type: "http", URL: "http://del.test"}
|
||||
db.Create(&monitor)
|
||||
|
||||
// Ensure monitor exists
|
||||
var count int64
|
||||
db.Model(&models.UptimeMonitor{}).Where("proxy_host_id = ?", host.ID).Count(&count)
|
||||
require.Equal(t, int64(1), count)
|
||||
|
||||
// Delete host with delete_uptime=true
|
||||
req := httptest.NewRequest(http.MethodDelete, "/api/v1/proxy-hosts/"+host.UUID+"?delete_uptime=true", nil)
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
require.Equal(t, http.StatusOK, w.Code)
|
||||
|
||||
// Host should be deleted
|
||||
var ph models.ProxyHost
|
||||
require.Error(t, db.First(&ph, "uuid = ?", host.UUID).Error)
|
||||
|
||||
// Monitor should also be deleted
|
||||
db.Model(&models.UptimeMonitor{}).Where("proxy_host_id = ?", host.ID).Count(&count)
|
||||
require.Equal(t, int64(0), count)
|
||||
}
|
||||
|
||||
func TestProxyHostErrors(t *testing.T) {
|
||||
// Mock Caddy Admin API that fails
|
||||
caddyServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -117,7 +158,7 @@ func TestProxyHostErrors(t *testing.T) {
|
||||
|
||||
// Setup Handler
|
||||
ns := services.NewNotificationService(db)
|
||||
h := NewProxyHostHandler(db, manager, ns)
|
||||
h := NewProxyHostHandler(db, manager, ns, nil)
|
||||
r := gin.New()
|
||||
api := r.Group("/api/v1")
|
||||
h.RegisterRoutes(api)
|
||||
@@ -304,7 +345,7 @@ func TestProxyHostWithCaddyIntegration(t *testing.T) {
|
||||
|
||||
// Setup Handler
|
||||
ns := services.NewNotificationService(db)
|
||||
h := NewProxyHostHandler(db, manager, ns)
|
||||
h := NewProxyHostHandler(db, manager, ns, nil)
|
||||
r := gin.New()
|
||||
api := r.Group("/api/v1")
|
||||
h.RegisterRoutes(api)
|
||||
|
||||
@@ -53,3 +53,21 @@ func (h *UptimeHandler) Update(c *gin.Context) {
|
||||
|
||||
c.JSON(http.StatusOK, monitor)
|
||||
}
|
||||
|
||||
func (h *UptimeHandler) Sync(c *gin.Context) {
|
||||
if err := h.service.SyncMonitors(); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to sync monitors"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Sync started"})
|
||||
}
|
||||
|
||||
// Delete removes a monitor and its associated data
|
||||
func (h *UptimeHandler) Delete(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
if err := h.service.DeleteMonitor(id); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete monitor"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Monitor deleted"})
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@ func setupUptimeHandlerTest(t *testing.T) (*gin.Engine, *gorm.DB) {
|
||||
t.Helper()
|
||||
db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{})
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, db.AutoMigrate(&models.UptimeMonitor{}, &models.UptimeHeartbeat{}, &models.NotificationProvider{}, &models.Notification{}))
|
||||
require.NoError(t, db.AutoMigrate(&models.UptimeMonitor{}, &models.UptimeHeartbeat{}, &models.UptimeHost{}, &models.RemoteServer{}, &models.NotificationProvider{}, &models.Notification{}, &models.ProxyHost{}))
|
||||
|
||||
ns := services.NewNotificationService(db)
|
||||
service := services.NewUptimeService(db, ns)
|
||||
@@ -33,8 +33,10 @@ func setupUptimeHandlerTest(t *testing.T) (*gin.Engine, *gorm.DB) {
|
||||
api := r.Group("/api/v1")
|
||||
uptime := api.Group("/uptime")
|
||||
uptime.GET("", handler.List)
|
||||
uptime.GET("/:id/history", handler.GetHistory)
|
||||
uptime.PUT("/:id", handler.Update)
|
||||
uptime.GET(":id/history", handler.GetHistory)
|
||||
uptime.PUT(":id", handler.Update)
|
||||
uptime.DELETE(":id", handler.Delete)
|
||||
uptime.POST("/sync", handler.Sync)
|
||||
|
||||
return r, db
|
||||
}
|
||||
@@ -160,3 +162,58 @@ func TestUptimeHandler_Update(t *testing.T) {
|
||||
assert.Equal(t, http.StatusInternalServerError, w.Code)
|
||||
})
|
||||
}
|
||||
|
||||
func TestUptimeHandler_DeleteAndSync(t *testing.T) {
|
||||
t.Run("delete monitor", func(t *testing.T) {
|
||||
r, db := setupUptimeHandlerTest(t)
|
||||
|
||||
monitor := models.UptimeMonitor{ID: "mon-delete", Name: "ToDelete", Type: "http", URL: "http://example.com"}
|
||||
db.Create(&monitor)
|
||||
|
||||
req, _ := http.NewRequest("DELETE", "/api/v1/uptime/mon-delete", nil)
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusOK, w.Code)
|
||||
var m models.UptimeMonitor
|
||||
require.Error(t, db.First(&m, "id = ?", "mon-delete").Error)
|
||||
})
|
||||
|
||||
t.Run("sync creates monitor for proxy host", func(t *testing.T) {
|
||||
r, db := setupUptimeHandlerTest(t)
|
||||
|
||||
// Create a proxy host to be synced to an uptime monitor
|
||||
host := models.ProxyHost{UUID: "ph-up-1", Name: "Test Host", DomainNames: "sync.example.com", ForwardHost: "127.0.0.1", ForwardPort: 80, Enabled: true}
|
||||
db.Create(&host)
|
||||
|
||||
req, _ := http.NewRequest("POST", "/api/v1/uptime/sync", nil)
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusOK, w.Code)
|
||||
var monitors []models.UptimeMonitor
|
||||
db.Where("proxy_host_id = ?", host.ID).Find(&monitors)
|
||||
assert.Len(t, monitors, 1)
|
||||
assert.Equal(t, "Test Host", monitors[0].Name)
|
||||
})
|
||||
|
||||
t.Run("update enabled via PUT", func(t *testing.T) {
|
||||
r, db := setupUptimeHandlerTest(t)
|
||||
|
||||
monitor := models.UptimeMonitor{ID: "mon-enable", Name: "ToToggle", Type: "http", URL: "http://example.com", Enabled: true}
|
||||
db.Create(&monitor)
|
||||
|
||||
updates := map[string]interface{}{"enabled": false}
|
||||
body, _ := json.Marshal(updates)
|
||||
req, _ := http.NewRequest("PUT", "/api/v1/uptime/mon-enable", bytes.NewBuffer(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusOK, w.Code)
|
||||
var result models.UptimeMonitor
|
||||
err := json.Unmarshal(w.Body.Bytes(), &result)
|
||||
require.NoError(t, err)
|
||||
assert.False(t, result.Enabled)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -87,6 +87,9 @@ func Register(router *gin.Engine, db *gorm.DB, cfg config.Config) error {
|
||||
api.POST("/auth/login", authHandler.Login)
|
||||
api.POST("/auth/register", authHandler.Register)
|
||||
|
||||
// Uptime Service - define early so it can be used during route registration
|
||||
uptimeService := services.NewUptimeService(db, notificationService)
|
||||
|
||||
protected := api.Group("/")
|
||||
protected.Use(authMiddleware)
|
||||
{
|
||||
@@ -158,6 +161,8 @@ func Register(router *gin.Engine, db *gorm.DB, cfg config.Config) error {
|
||||
protected.GET("/uptime/monitors", uptimeHandler.List)
|
||||
protected.GET("/uptime/monitors/:id/history", uptimeHandler.GetHistory)
|
||||
protected.PUT("/uptime/monitors/:id", uptimeHandler.Update)
|
||||
protected.DELETE("/uptime/monitors/:id", uptimeHandler.Delete)
|
||||
protected.POST("/uptime/sync", uptimeHandler.Sync)
|
||||
|
||||
// Notification Providers
|
||||
notificationProviderHandler := handlers.NewNotificationProviderHandler(notificationService)
|
||||
@@ -214,7 +219,7 @@ func Register(router *gin.Engine, db *gorm.DB, cfg config.Config) error {
|
||||
caddyClient := caddy.NewClient(cfg.CaddyAdminAPI)
|
||||
caddyManager := caddy.NewManager(caddyClient, db, cfg.CaddyConfigDir, cfg.FrontendDir, cfg.ACMEStaging)
|
||||
|
||||
proxyHostHandler := handlers.NewProxyHostHandler(db, caddyManager, notificationService)
|
||||
proxyHostHandler := handlers.NewProxyHostHandler(db, caddyManager, notificationService, uptimeService)
|
||||
proxyHostHandler.RegisterRoutes(api)
|
||||
|
||||
remoteServerHandler := handlers.NewRemoteServerHandler(remoteServerService, notificationService)
|
||||
|
||||
@@ -821,6 +821,9 @@ func (s *UptimeService) UpdateMonitor(id string, updates map[string]interface{})
|
||||
if val, ok := updates["interval"]; ok {
|
||||
allowedUpdates["interval"] = val
|
||||
}
|
||||
if val, ok := updates["enabled"]; ok {
|
||||
allowedUpdates["enabled"] = val
|
||||
}
|
||||
// Add other fields as needed, but be careful not to overwrite SyncMonitors logic
|
||||
|
||||
if err := s.DB.Model(&monitor).Updates(allowedUpdates).Error; err != nil {
|
||||
@@ -829,3 +832,27 @@ func (s *UptimeService) UpdateMonitor(id string, updates map[string]interface{})
|
||||
|
||||
return &monitor, nil
|
||||
}
|
||||
|
||||
// DeleteMonitor removes a monitor and its heartbeats, and optionally cleans up the parent UptimeHost.
|
||||
func (s *UptimeService) DeleteMonitor(id string) error {
|
||||
// Find monitor
|
||||
var monitor models.UptimeMonitor
|
||||
if err := s.DB.First(&monitor, "id = ?", id).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Delete heartbeats
|
||||
if err := s.DB.Where("monitor_id = ?", id).Delete(&models.UptimeHeartbeat{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Delete the monitor
|
||||
if err := s.DB.Delete(&monitor).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// If no other monitors reference the uptime host, we don't automatically delete the host.
|
||||
// Leave host cleanup to a manual process or separate endpoint.
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
57
backend/internal/services/uptime_service_unit_test.go
Normal file
57
backend/internal/services/uptime_service_unit_test.go
Normal file
@@ -0,0 +1,57 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/require"
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/Wikid82/charon/backend/internal/models"
|
||||
)
|
||||
|
||||
func setupUnitTestDB(t *testing.T) *gorm.DB {
|
||||
t.Helper()
|
||||
db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{})
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, db.AutoMigrate(&models.UptimeMonitor{}, &models.UptimeHeartbeat{}, &models.UptimeHost{}))
|
||||
return db
|
||||
}
|
||||
|
||||
func TestUpdateMonitorEnabled_Unit(t *testing.T) {
|
||||
db := setupUnitTestDB(t)
|
||||
svc := NewUptimeService(db, nil)
|
||||
|
||||
monitor := models.UptimeMonitor{ID: uuid.New().String(), Name: "unit-test", URL: "http://example.com", Interval: 60, Enabled: true}
|
||||
require.NoError(t, db.Create(&monitor).Error)
|
||||
|
||||
r, err := svc.UpdateMonitor(monitor.ID, map[string]interface{}{"enabled": false})
|
||||
require.NoError(t, err)
|
||||
require.False(t, r.Enabled)
|
||||
|
||||
var m models.UptimeMonitor
|
||||
require.NoError(t, db.First(&m, "id = ?", monitor.ID).Error)
|
||||
require.False(t, m.Enabled)
|
||||
}
|
||||
|
||||
func TestDeleteMonitorDeletesHeartbeats_Unit(t *testing.T) {
|
||||
db := setupUnitTestDB(t)
|
||||
svc := NewUptimeService(db, nil)
|
||||
|
||||
monitor := models.UptimeMonitor{ID: uuid.New().String(), Name: "unit-delete", URL: "http://example.com", Interval: 60, Enabled: true}
|
||||
require.NoError(t, db.Create(&monitor).Error)
|
||||
|
||||
hb := models.UptimeHeartbeat{MonitorID: monitor.ID, Status: "up", Latency: 10, CreatedAt: time.Now()}
|
||||
require.NoError(t, db.Create(&hb).Error)
|
||||
|
||||
require.NoError(t, svc.DeleteMonitor(monitor.ID))
|
||||
|
||||
var m models.UptimeMonitor
|
||||
require.Error(t, db.First(&m, "id = ?", monitor.ID).Error)
|
||||
|
||||
var count int64
|
||||
db.Model(&models.UptimeHeartbeat{}).Where("monitor_id = ?", monitor.ID).Count(&count)
|
||||
require.Equal(t, int64(0), count)
|
||||
}
|
||||
53
docs/issues/ACL-testing-tasks.md
Normal file
53
docs/issues/ACL-testing-tasks.md
Normal file
@@ -0,0 +1,53 @@
|
||||
Tasks
|
||||
|
||||
Repository: Wikid82/Charon
|
||||
Branch: feature/beta-release
|
||||
|
||||
Purpose
|
||||
-------
|
||||
Create a tracked issue and sub-tasks to validate ACL-related changes introduced on the `feature/beta-release` branch. This file records the scope, test steps, and sub-issues so we can open a GitHub issue later or link this file in the issue body.
|
||||
|
||||
Top-level checklist
|
||||
- [ ] Open GitHub Issue "ACL: Test and validate ACL changes (feature/beta-release)" and link this file
|
||||
- [ ] Assign owner and target date
|
||||
|
||||
Sub-tasks (suggested GitHub issue checklist items)
|
||||
1) Unit & Service Tests
|
||||
- [ ] Add/verify unit tests for `internal/services/access_list_service.go` CRUD + validation
|
||||
- [ ] Add tests for `internal/api/handlers/access_list_handler.go` endpoints (create/list/get/update/delete)
|
||||
- Acceptance: all handler tests pass and coverage for `internal/api/handlers` rises by at least 3%.
|
||||
|
||||
2) Integration Tests
|
||||
- [ ] Test ACL interactions with proxy hosts: ensure blocked/allowed behavior when ACLs applied to hosts
|
||||
- [ ] Test ACL import via Caddy import workflow (multi-site) — ensure imported ACLs attach correctly
|
||||
- Acceptance: end-to-end requests are blocked/allowed per ACL rules in an integration harness.
|
||||
|
||||
3) UI & API Validation
|
||||
- [ ] Validate frontend UI toggles for ACL enable/disable reflect DB state
|
||||
- [ ] Verify API endpoints that toggle ACL mode return correct status and persist in `settings`
|
||||
- Acceptance: toggles update DB and the UI shows consistent state after refresh.
|
||||
|
||||
4) Security & Edge Cases
|
||||
- [ ] Test denied webhook payloads / WAF interactions when ACLs are present
|
||||
- [ ] Confirm rate-limit and CrowdSec interactions do not conflict with ACL rules
|
||||
- Acceptance: no regressions found; documented edge cases.
|
||||
|
||||
5) Documentation & Release Notes
|
||||
- [ ] Update `docs/features.md` with any behavior changes
|
||||
- [ ] Add a short note in release notes describing ACL test coverage and migration steps
|
||||
|
||||
Manual Test Steps (quick guide)
|
||||
- Set up local environment:
|
||||
1. `cd backend && go run ./cmd/api` (or use docker compose)
|
||||
2. Run frontend dev server: `cd frontend && npm run dev`
|
||||
- Create an ACL via API or UI; attach it to a Proxy Host; verify request behavior.
|
||||
- Import Caddyfiles (single & multi-site) with ACL directives and validate mapping.
|
||||
|
||||
Issue metadata (suggested)
|
||||
- Title: ACL: Test and validate ACL changes (feature/beta-release)
|
||||
- Labels: testing, needs-triage, acl, regression
|
||||
- Assignees: @<owner-placeholder>
|
||||
- Milestone: to be set
|
||||
|
||||
Notes
|
||||
- Keep this file as the canonical checklist and paste into the GitHub issue body when opening the issue.
|
||||
34
frontend/src/api/__tests__/backups.test.ts
Normal file
34
frontend/src/api/__tests__/backups.test.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import client from '../../api/client'
|
||||
import { getBackups, createBackup, restoreBackup, deleteBackup } from '../backups'
|
||||
|
||||
describe('backups api', () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('getBackups returns list', async () => {
|
||||
const mockData = [{ filename: 'b1.zip', size: 123, time: '2025-01-01T00:00:00Z' }]
|
||||
vi.spyOn(client, 'get').mockResolvedValueOnce({ data: mockData })
|
||||
const res = await getBackups()
|
||||
expect(res).toEqual(mockData)
|
||||
})
|
||||
|
||||
it('createBackup returns filename', async () => {
|
||||
vi.spyOn(client, 'post').mockResolvedValueOnce({ data: { filename: 'b2.zip' } })
|
||||
const res = await createBackup()
|
||||
expect(res).toEqual({ filename: 'b2.zip' })
|
||||
})
|
||||
|
||||
it('restoreBackup posts to restore endpoint', async () => {
|
||||
const spy = vi.spyOn(client, 'post').mockResolvedValueOnce({})
|
||||
await restoreBackup('b3.zip')
|
||||
expect(spy).toHaveBeenCalledWith('/backups/b3.zip/restore')
|
||||
})
|
||||
|
||||
it('deleteBackup deletes backup', async () => {
|
||||
const spy = vi.spyOn(client, 'delete').mockResolvedValueOnce({})
|
||||
await deleteBackup('b3.zip')
|
||||
expect(spy).toHaveBeenCalledWith('/backups/b3.zip')
|
||||
})
|
||||
})
|
||||
@@ -15,12 +15,12 @@ describe('featureFlags API', () => {
|
||||
it('fetches feature flags', async () => {
|
||||
const flags = await getFeatureFlags()
|
||||
expect(flags['feature.global.enabled']).toBe(true)
|
||||
expect((client.get as any)).toHaveBeenCalled()
|
||||
expect(vi.mocked(client.get)).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('updates feature flags', async () => {
|
||||
const resp = await updateFeatureFlags({ 'feature.global.enabled': false })
|
||||
expect(resp).toEqual({ status: 'ok' })
|
||||
expect((client.put as any)).toHaveBeenCalledWith('/feature-flags', { 'feature.global.enabled': false })
|
||||
expect(vi.mocked(client.put)).toHaveBeenCalledWith('/feature-flags', { 'feature.global.enabled': false })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -40,12 +40,17 @@ export const testProvider = async (provider: Partial<NotificationProvider>) => {
|
||||
};
|
||||
|
||||
export const getTemplates = async () => {
|
||||
const response = await client.get('/notifications/templates');
|
||||
const response = await client.get<NotificationTemplate[]>('/notifications/templates');
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const previewProvider = async (provider: Partial<NotificationProvider>, data?: Record<string, any>) => {
|
||||
const payload: any = { ...provider };
|
||||
export interface NotificationTemplate {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export const previewProvider = async (provider: Partial<NotificationProvider>, data?: Record<string, unknown>) => {
|
||||
const payload: Record<string, unknown> = { ...provider } as Record<string, unknown>;
|
||||
if (data) payload.data = data;
|
||||
const response = await client.post('/notifications/providers/preview', payload);
|
||||
return response.data;
|
||||
@@ -80,8 +85,8 @@ export const deleteExternalTemplate = async (id: string) => {
|
||||
await client.delete(`/notifications/external-templates/${id}`);
|
||||
};
|
||||
|
||||
export const previewExternalTemplate = async (templateId?: string, template?: string, data?: Record<string, any>) => {
|
||||
const payload: any = {};
|
||||
export const previewExternalTemplate = async (templateId?: string, template?: string, data?: Record<string, unknown>) => {
|
||||
const payload: Record<string, unknown> = {};
|
||||
if (templateId) payload.template_id = templateId;
|
||||
if (template) payload.template = template;
|
||||
if (data) payload.data = data;
|
||||
|
||||
@@ -64,8 +64,9 @@ export const updateProxyHost = async (uuid: string, host: Partial<ProxyHost>): P
|
||||
return data;
|
||||
};
|
||||
|
||||
export const deleteProxyHost = async (uuid: string): Promise<void> => {
|
||||
await client.delete(`/proxy-hosts/${uuid}`);
|
||||
export const deleteProxyHost = async (uuid: string, deleteUptime?: boolean): Promise<void> => {
|
||||
const url = `/proxy-hosts/${uuid}${deleteUptime ? '?delete_uptime=true' : ''}`
|
||||
await client.delete(url);
|
||||
};
|
||||
|
||||
export const testProxyHostConnection = async (host: string, port: number): Promise<void> => {
|
||||
|
||||
@@ -2,6 +2,7 @@ import client from './client';
|
||||
|
||||
export interface UptimeMonitor {
|
||||
id: string;
|
||||
upstream_host?: string;
|
||||
proxy_host_id?: number;
|
||||
remote_server_id?: number;
|
||||
name: string;
|
||||
@@ -10,7 +11,7 @@ export interface UptimeMonitor {
|
||||
interval: number;
|
||||
enabled: boolean;
|
||||
status: string;
|
||||
last_check: string;
|
||||
last_check?: string | null;
|
||||
latency: number;
|
||||
max_retries: number;
|
||||
}
|
||||
@@ -38,3 +39,13 @@ export const updateMonitor = async (id: string, data: Partial<UptimeMonitor>) =>
|
||||
const response = await client.put<UptimeMonitor>(`/uptime/monitors/${id}`, data);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const deleteMonitor = async (id: string) => {
|
||||
const response = await client.delete<void>(`/uptime/monitors/${id}`);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export async function syncMonitors(body?: { interval?: number; max_retries?: number }) {
|
||||
const res = await client.post('/uptime/sync', body || {});
|
||||
return res.data;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState } from 'react'
|
||||
import React, { useState } from 'react'
|
||||
import { AlertTriangle, CheckCircle2 } from 'lucide-react'
|
||||
|
||||
interface HostPreview {
|
||||
@@ -150,15 +150,15 @@ export default function ImportReviewTable({ hosts, conflicts, conflictDetails, e
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-800">
|
||||
{hosts.map((h, idx) => {
|
||||
{hosts.map((h) => {
|
||||
const domain = h.domain_names
|
||||
const hasConflict = conflicts.includes(domain)
|
||||
const isExpanded = expandedRows.has(domain)
|
||||
const details = conflictDetails?.[domain]
|
||||
|
||||
return (
|
||||
<>
|
||||
<tr key={`${domain}-${idx}`} className="hover:bg-gray-900/50">
|
||||
<React.Fragment key={domain}>
|
||||
<tr className="hover:bg-gray-900/50">
|
||||
<td className="px-6 py-4">
|
||||
<input
|
||||
type="text"
|
||||
@@ -218,7 +218,7 @@ export default function ImportReviewTable({ hosts, conflicts, conflictDetails, e
|
||||
</tr>
|
||||
|
||||
{hasConflict && isExpanded && details && (
|
||||
<tr key={`${domain}-details`} className="bg-gray-900/30">
|
||||
<tr className="bg-gray-900/30">
|
||||
<td colSpan={4} className="px-6 py-4">
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-6">
|
||||
@@ -316,7 +316,7 @@ export default function ImportReviewTable({ hosts, conflicts, conflictDetails, e
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</>
|
||||
</React.Fragment>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
|
||||
@@ -32,8 +32,9 @@ export default function ImportSitesModal({ visible, onClose, onUploaded }: Props
|
||||
setLoading(false)
|
||||
if (onUploaded) onUploaded()
|
||||
onClose()
|
||||
} catch (err: any) {
|
||||
setError(err?.message || 'Upload failed')
|
||||
} catch (err: unknown) {
|
||||
const msg = err instanceof Error ? err.message : String(err)
|
||||
setError(msg || 'Upload failed')
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { CircleHelp, AlertCircle, Check, X, Loader2, Copy, Info } from 'lucide-react'
|
||||
import { toast } from 'react-hot-toast'
|
||||
import type { ProxyHost, ApplicationPreset } from '../api/proxyHosts'
|
||||
import { testProxyHostConnection } from '../api/proxyHosts'
|
||||
import { syncMonitors } from '../api/uptime'
|
||||
import { useRemoteServers } from '../hooks/useRemoteServers'
|
||||
import { useDomains } from '../hooks/useDomains'
|
||||
import { useCertificates } from '../hooks/useCertificates'
|
||||
@@ -85,7 +87,8 @@ interface ProxyHostFormProps {
|
||||
}
|
||||
|
||||
export default function ProxyHostForm({ host, onSubmit, onCancel }: ProxyHostFormProps) {
|
||||
const [formData, setFormData] = useState({
|
||||
type ProxyHostFormState = Partial<ProxyHost> & { addUptime?: boolean; uptimeInterval?: number; uptimeMaxRetries?: number }
|
||||
const [formData, setFormData] = useState<ProxyHostFormState>({
|
||||
name: host?.name || '',
|
||||
domain_names: host?.domain_names || '',
|
||||
forward_scheme: host?.forward_scheme || 'http',
|
||||
@@ -144,7 +147,7 @@ export default function ProxyHostForm({ host, onSubmit, onCancel }: ProxyHostFor
|
||||
|
||||
// Get the external URL for this proxy host
|
||||
const getExternalUrl = () => {
|
||||
const domain = formData.domain_names.split(',')[0]?.trim()
|
||||
const domain = (formData.domain_names ?? '').split(',')[0]?.trim()
|
||||
if (!domain) return ''
|
||||
return `https://${domain}:443`
|
||||
}
|
||||
@@ -269,28 +272,38 @@ export default function ProxyHostForm({ host, onSubmit, onCancel }: ProxyHostFor
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [nameError, setNameError] = useState<string | null>(null)
|
||||
const [addUptime, setAddUptime] = useState(false)
|
||||
const [uptimeInterval, setUptimeInterval] = useState(60)
|
||||
const [uptimeMaxRetries, setUptimeMaxRetries] = useState(3)
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
setNameError(null)
|
||||
|
||||
// Validate name is required
|
||||
if (!formData.name.trim()) {
|
||||
setNameError('Name is required')
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await onSubmit(formData)
|
||||
} catch (err: unknown) {
|
||||
console.error("Submit error:", err)
|
||||
// Extract error message from axios response if available
|
||||
const errorObj = err as { response?: { data?: { error?: string } }; message?: string }
|
||||
const message = errorObj.response?.data?.error || errorObj.message || 'Failed to save proxy host'
|
||||
const payload = { ...formData }
|
||||
// strip temporary uptime-only flags from payload by destructuring
|
||||
const { addUptime: _addUptime, uptimeInterval: _uptimeInterval, uptimeMaxRetries: _uptimeMaxRetries, ...payloadWithoutUptime } = payload as ProxyHostFormState
|
||||
void _addUptime; void _uptimeInterval; void _uptimeMaxRetries;
|
||||
const res = await onSubmit(payloadWithoutUptime)
|
||||
|
||||
// if user asked to add uptime, request server to sync monitors
|
||||
if (addUptime) {
|
||||
try {
|
||||
await syncMonitors({ interval: uptimeInterval, max_retries: uptimeMaxRetries })
|
||||
toast.success('Requested uptime monitor creation')
|
||||
} catch (err: unknown) {
|
||||
const msg = err instanceof Error ? err.message : String(err)
|
||||
toast.error(msg || 'Failed to request uptime creation')
|
||||
}
|
||||
}
|
||||
|
||||
onCancel()
|
||||
return res
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Failed to save host'
|
||||
setError(message)
|
||||
toast.error(message)
|
||||
throw err
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
@@ -555,7 +568,10 @@ export default function ProxyHostForm({ host, onSubmit, onCancel }: ProxyHostFor
|
||||
min="1"
|
||||
max="65535"
|
||||
value={formData.forward_port}
|
||||
onChange={e => setFormData({ ...formData, forward_port: parseInt(e.target.value) })}
|
||||
onChange={e => {
|
||||
const v = parseInt(e.target.value)
|
||||
setFormData({ ...formData, forward_port: Number.isNaN(v) ? 0 : v })
|
||||
}}
|
||||
className="w-full bg-gray-900 border border-gray-700 rounded-lg px-4 py-2 text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
@@ -875,6 +891,44 @@ export default function ProxyHostForm({ host, onSubmit, onCancel }: ProxyHostFor
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Uptime option */}
|
||||
<div className="border-t border-gray-800 pt-4">
|
||||
<label className="flex items-center gap-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={addUptime}
|
||||
onChange={e => setAddUptime(e.target.checked)}
|
||||
className="w-4 h-4 text-blue-600 bg-gray-900 border-gray-700 rounded focus:ring-blue-500"
|
||||
/>
|
||||
<span className="text-sm text-gray-300">Add Uptime monitoring for this host</span>
|
||||
</label>
|
||||
|
||||
{addUptime && (
|
||||
<div className="grid grid-cols-2 gap-4 mt-3">
|
||||
<div>
|
||||
<label className="block text-sm text-gray-400 mb-1">Check Interval (seconds)</label>
|
||||
<input
|
||||
type="number"
|
||||
min={10}
|
||||
value={uptimeInterval}
|
||||
onChange={e => setUptimeInterval(parseInt(e.target.value || '60'))}
|
||||
className="w-full bg-gray-900 border border-gray-700 rounded-lg px-3 py-2 text-white"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm text-gray-400 mb-1">Max Retries</label>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
value={uptimeMaxRetries}
|
||||
onChange={e => setUptimeMaxRetries(parseInt(e.target.value || '3'))}
|
||||
className="w-full bg-gray-900 border border-gray-700 rounded-lg px-3 py-2 text-white"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex gap-3 justify-end pt-4 border-t border-gray-800">
|
||||
<button
|
||||
|
||||
@@ -135,7 +135,10 @@ export default function RemoteServerForm({ server, onSubmit, onCancel }: Props)
|
||||
min={1}
|
||||
max={65535}
|
||||
value={formData.port}
|
||||
onChange={e => setFormData({ ...formData, port: parseInt(e.target.value) })}
|
||||
onChange={e => {
|
||||
const v = parseInt(e.target.value)
|
||||
setFormData({ ...formData, port: Number.isNaN(v) ? 0 : v })
|
||||
}}
|
||||
className="w-full bg-gray-900 border border-gray-700 rounded-lg px-4 py-2 text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import { render, screen, waitFor, fireEvent } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import ProxyHostForm from '../ProxyHostForm'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
|
||||
vi.mock('../../api/uptime', () => ({
|
||||
syncMonitors: vi.fn(() => Promise.resolve({})),
|
||||
}))
|
||||
|
||||
// Minimal hook mocks used by the component
|
||||
vi.mock('../../hooks/useRemoteServers', () => ({
|
||||
useRemoteServers: vi.fn(() => ({
|
||||
servers: [],
|
||||
isLoading: false,
|
||||
error: null,
|
||||
createRemoteServer: vi.fn(),
|
||||
updateRemoteServer: vi.fn(),
|
||||
deleteRemoteServer: vi.fn(),
|
||||
})),
|
||||
}))
|
||||
|
||||
vi.mock('../../hooks/useDocker', () => ({
|
||||
useDocker: vi.fn(() => ({ containers: [], isLoading: false, error: null, refetch: vi.fn() })),
|
||||
}))
|
||||
|
||||
vi.mock('../../hooks/useDomains', () => ({
|
||||
useDomains: vi.fn(() => ({ domains: [], createDomain: vi.fn().mockResolvedValue({}), isLoading: false, error: null })),
|
||||
}))
|
||||
|
||||
vi.mock('../../hooks/useCertificates', () => ({
|
||||
useCertificates: vi.fn(() => ({ certificates: [], isLoading: false, error: null })),
|
||||
}))
|
||||
|
||||
// stub global fetch for health endpoint
|
||||
vi.stubGlobal('fetch', vi.fn(() => Promise.resolve({ json: () => Promise.resolve({ internal_ip: '127.0.0.1' }) })))
|
||||
|
||||
describe('ProxyHostForm Add Uptime flow', () => {
|
||||
it('submits host and requests uptime sync when Add Uptime is checked', async () => {
|
||||
const onSubmit = vi.fn(() => Promise.resolve())
|
||||
const onCancel = vi.fn()
|
||||
|
||||
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } })
|
||||
|
||||
render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ProxyHostForm onSubmit={onSubmit} onCancel={onCancel} />
|
||||
</QueryClientProvider>
|
||||
)
|
||||
|
||||
// Fill required fields
|
||||
await userEvent.type(screen.getByPlaceholderText('My Service'), 'My Service')
|
||||
await userEvent.type(screen.getByPlaceholderText('example.com, www.example.com'), 'example.com')
|
||||
await userEvent.type(screen.getByLabelText(/^Host$/), '127.0.0.1')
|
||||
await userEvent.clear(screen.getByLabelText(/^Port$/))
|
||||
await userEvent.type(screen.getByLabelText(/^Port$/), '8080')
|
||||
|
||||
// Check Add Uptime
|
||||
const addUptimeCheckbox = screen.getByLabelText(/Add Uptime monitoring for this host/i)
|
||||
await userEvent.click(addUptimeCheckbox)
|
||||
|
||||
// Adjust uptime options — locate the container for the uptime inputs
|
||||
const uptimeCheckbox = screen.getByLabelText(/Add Uptime monitoring for this host/i)
|
||||
const uptimeContainer = uptimeCheckbox.closest('label')?.parentElement
|
||||
if (!uptimeContainer) throw new Error('Uptime container not found')
|
||||
|
||||
const { within } = await import('@testing-library/react')
|
||||
const spinbuttons = within(uptimeContainer).getAllByRole('spinbutton')
|
||||
// first spinbutton is interval, second is max retries
|
||||
fireEvent.change(spinbuttons[0], { target: { value: '30' } })
|
||||
fireEvent.change(spinbuttons[1], { target: { value: '2' } })
|
||||
|
||||
// Submit
|
||||
const submitBtn = document.querySelector('button[type="submit"]') as HTMLButtonElement
|
||||
if (!submitBtn) throw new Error('Submit button not found')
|
||||
await userEvent.click(submitBtn)
|
||||
|
||||
// wait for onSubmit to have been called
|
||||
await waitFor(() => expect(onSubmit).toHaveBeenCalled())
|
||||
|
||||
// Ensure uptime API was called with provided options
|
||||
const uptime = await import('../../api/uptime')
|
||||
await waitFor(() => expect(uptime.syncMonitors).toHaveBeenCalledWith({ interval: 30, max_retries: 2 }))
|
||||
|
||||
// Ensure onSubmit payload does not include temporary uptime keys
|
||||
const onSubmitMock = onSubmit as unknown as import('vitest').Mock
|
||||
const submittedPayload = onSubmitMock.mock.calls[0][0]
|
||||
expect(submittedPayload).not.toHaveProperty('addUptime')
|
||||
expect(submittedPayload).not.toHaveProperty('uptimeInterval')
|
||||
expect(submittedPayload).not.toHaveProperty('uptimeMaxRetries')
|
||||
})
|
||||
})
|
||||
@@ -4,6 +4,7 @@ import userEvent from '@testing-library/user-event'
|
||||
import { act } from 'react'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import ProxyHostForm from '../ProxyHostForm'
|
||||
import type { ProxyHost } from '../../api/proxyHosts'
|
||||
import { mockRemoteServers } from '../../test/mockData'
|
||||
|
||||
// Mock the hooks
|
||||
@@ -445,7 +446,7 @@ describe('ProxyHostForm', () => {
|
||||
})
|
||||
|
||||
it('auto-populates advanced_config when selecting plex preset and field empty', async () => {
|
||||
renderWithClient(
|
||||
await renderWithClientAct(
|
||||
<ProxyHostForm onSubmit={mockOnSubmit} onCancel={mockOnCancel} />
|
||||
)
|
||||
|
||||
@@ -488,7 +489,7 @@ describe('ProxyHostForm', () => {
|
||||
}
|
||||
|
||||
renderWithClient(
|
||||
<ProxyHostForm host={existingHost as any} onSubmit={mockOnSubmit} onCancel={mockOnCancel} />
|
||||
<ProxyHostForm host={existingHost as ProxyHost} onSubmit={mockOnSubmit} onCancel={mockOnCancel} />
|
||||
)
|
||||
|
||||
// Select Plex preset (should prompt since advanced_config is non-empty)
|
||||
@@ -531,7 +532,7 @@ describe('ProxyHostForm', () => {
|
||||
}
|
||||
|
||||
renderWithClient(
|
||||
<ProxyHostForm host={existingHost as any} onSubmit={mockOnSubmit} onCancel={mockOnCancel} />
|
||||
<ProxyHostForm host={existingHost as ProxyHost} onSubmit={mockOnSubmit} onCancel={mockOnCancel} />
|
||||
)
|
||||
|
||||
// The restore button should be visible
|
||||
@@ -609,7 +610,7 @@ describe('ProxyHostForm', () => {
|
||||
})
|
||||
|
||||
it('does not show config helper when preset is none', async () => {
|
||||
renderWithClient(
|
||||
await renderWithClientAct(
|
||||
<ProxyHostForm onSubmit={mockOnSubmit} onCancel={mockOnCancel} />
|
||||
)
|
||||
|
||||
|
||||
@@ -34,7 +34,8 @@ export function useProxyHosts() {
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (uuid: string) => deleteProxyHost(uuid),
|
||||
mutationFn: (opts: { uuid: string; deleteUptime?: boolean } | string) =>
|
||||
typeof opts === 'string' ? deleteProxyHost(opts) : (opts.deleteUptime !== undefined ? deleteProxyHost(opts.uuid, opts.deleteUptime) : deleteProxyHost(opts.uuid)),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: QUERY_KEY });
|
||||
},
|
||||
@@ -55,7 +56,7 @@ export function useProxyHosts() {
|
||||
error: query.error ? (query.error as Error).message : null,
|
||||
createHost: createMutation.mutateAsync,
|
||||
updateHost: (uuid: string, data: Partial<ProxyHost>) => updateMutation.mutateAsync({ uuid, data }),
|
||||
deleteHost: deleteMutation.mutateAsync,
|
||||
deleteHost: (uuid: string, deleteUptime?: boolean) => deleteMutation.mutateAsync(deleteUptime !== undefined ? { uuid, deleteUptime } : uuid),
|
||||
bulkUpdateACL: (hostUUIDs: string[], accessListID: number | null) =>
|
||||
bulkUpdateACLMutation.mutateAsync({ hostUUIDs, accessListID }),
|
||||
isCreating: createMutation.isPending,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState, type FC } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { getProviders, createProvider, updateProvider, deleteProvider, testProvider, getTemplates, previewProvider, NotificationProvider, getExternalTemplates, previewExternalTemplate, ExternalTemplate, createExternalTemplate, updateExternalTemplate, deleteExternalTemplate } from '../api/notifications';
|
||||
import { getProviders, createProvider, updateProvider, deleteProvider, testProvider, getTemplates, previewProvider, NotificationProvider, getExternalTemplates, previewExternalTemplate, ExternalTemplate, createExternalTemplate, updateExternalTemplate, deleteExternalTemplate, NotificationTemplate } from '../api/notifications';
|
||||
import { Card } from '../components/ui/Card';
|
||||
import { Button } from '../components/ui/Button';
|
||||
import { Bell, Plus, Trash2, Edit2, Send, Check, X, Loader2 } from 'lucide-react';
|
||||
@@ -59,8 +59,9 @@ const ProviderForm: FC<{
|
||||
const res = await previewProvider(formData as Partial<NotificationProvider>);
|
||||
if (res.parsed) setPreviewContent(JSON.stringify(res.parsed, null, 2)); else setPreviewContent(res.rendered);
|
||||
}
|
||||
} catch (err: any) {
|
||||
setPreviewError(err?.response?.data?.error || err?.message || 'Failed to generate preview');
|
||||
} catch (err: unknown) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
setPreviewError(msg || 'Failed to generate preview');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -133,11 +134,11 @@ const ProviderForm: FC<{
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">Template</label>
|
||||
<select {...register('template')} className="mt-1 block w-full rounded-md border-gray-300">
|
||||
{/* Built-in template options */}
|
||||
{builtins?.map((t: any) => (
|
||||
{builtins?.map((t: NotificationTemplate) => (
|
||||
<option key={t.id} value={t.id}>{t.name}</option>
|
||||
))}
|
||||
{/* External saved templates (id values are UUIDs) */}
|
||||
{externalTemplates?.map((t: any) => (
|
||||
{externalTemplates?.map((t: ExternalTemplate) => (
|
||||
<option key={t.id} value={t.id}>{t.name}</option>
|
||||
))}
|
||||
</select>
|
||||
@@ -244,8 +245,9 @@ const TemplateForm: FC<{
|
||||
try {
|
||||
const res = await previewExternalTemplate(undefined, form.config, { Title: 'Preview Title', Message: 'Preview Message', Time: new Date().toISOString(), EventType: 'preview' });
|
||||
if (res.parsed) setPreview(JSON.stringify(res.parsed, null, 2)); else setPreview(res.rendered);
|
||||
} catch (err: any) {
|
||||
setPreviewErr(err?.response?.data?.error || err?.message || 'Preview failed');
|
||||
} catch (err: unknown) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
setPreviewErr(msg || 'Preview failed');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -379,7 +381,7 @@ const Notifications: FC = () => {
|
||||
{editingTemplateId !== null && (
|
||||
<Card className="p-4">
|
||||
<TemplateForm
|
||||
initialData={externalTemplates?.find((t: any) => t.id === editingTemplateId) as Partial<ExternalTemplate>}
|
||||
initialData={externalTemplates?.find((t: ExternalTemplate) => t.id === editingTemplateId) as Partial<ExternalTemplate>}
|
||||
onClose={() => setEditingTemplateId(null)}
|
||||
onSubmit={(data) => {
|
||||
if (editingTemplateId) updateTemplateMutation.mutate({ id: editingTemplateId, data });
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useState, useMemo } from 'react'
|
||||
import { Loader2, ExternalLink, AlertTriangle, ChevronUp, ChevronDown, CheckSquare, Square, Trash2 } from 'lucide-react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useProxyHosts } from '../hooks/useProxyHosts'
|
||||
import { getMonitors, type UptimeMonitor } from '../api/uptime'
|
||||
import { useCertificates } from '../hooks/useCertificates'
|
||||
import { useAccessLists } from '../hooks/useAccessLists'
|
||||
import { getSettings } from '../api/settings'
|
||||
@@ -12,6 +13,10 @@ import type { AccessList } from '../api/accessLists'
|
||||
import ProxyHostForm from '../components/ProxyHostForm'
|
||||
import { Switch } from '../components/ui/Switch'
|
||||
import { toast } from 'react-hot-toast'
|
||||
import { formatSettingLabel, settingHelpText, applyBulkSettingsToHosts } from '../utils/proxyHostsHelpers'
|
||||
|
||||
// Helper functions extracted for unit testing and reuse
|
||||
// Helpers moved to ../utils/proxyHostsHelpers to keep component files component-only for fast refresh
|
||||
|
||||
type SortColumn = 'name' | 'domain' | 'forward'
|
||||
type SortDirection = 'asc' | 'desc'
|
||||
@@ -26,11 +31,20 @@ export default function ProxyHosts() {
|
||||
const [sortDirection, setSortDirection] = useState<SortDirection>('asc')
|
||||
const [selectedHosts, setSelectedHosts] = useState<Set<string>>(new Set())
|
||||
const [showBulkACLModal, setShowBulkACLModal] = useState(false)
|
||||
const [showBulkApplyModal, setShowBulkApplyModal] = useState(false)
|
||||
const [showBulkDeleteModal, setShowBulkDeleteModal] = useState(false)
|
||||
const [isCreatingBackup, setIsCreatingBackup] = useState(false)
|
||||
const [selectedACLs, setSelectedACLs] = useState<Set<number>>(new Set())
|
||||
const [bulkACLAction, setBulkACLAction] = useState<'apply' | 'remove'>('apply')
|
||||
const [applyProgress, setApplyProgress] = useState<{ current: number; total: number } | null>(null)
|
||||
const [bulkApplySettings, setBulkApplySettings] = useState<Record<string, { apply: boolean; value: boolean }>>({
|
||||
ssl_forced: { apply: false, value: true },
|
||||
http2_support: { apply: false, value: true },
|
||||
hsts_enabled: { apply: false, value: true },
|
||||
hsts_subdomains: { apply: false, value: true },
|
||||
block_exploits: { apply: false, value: true },
|
||||
websocket_support: { apply: false, value: true },
|
||||
})
|
||||
|
||||
const { data: settings } = useQuery({
|
||||
queryKey: ['settings'],
|
||||
@@ -80,6 +94,12 @@ export default function ProxyHosts() {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
// local usage now relies on the exported settingHelpText helper
|
||||
|
||||
// local usage now relies on exported settingKeyToField helper
|
||||
|
||||
const handleAdd = () => {
|
||||
setEditingHost(undefined)
|
||||
setShowForm(true)
|
||||
@@ -101,12 +121,29 @@ export default function ProxyHosts() {
|
||||
}
|
||||
|
||||
const handleDelete = async (uuid: string) => {
|
||||
if (confirm('Are you sure you want to delete this proxy host?')) {
|
||||
const host = hosts.find(h => h.uuid === uuid)
|
||||
if (!host) return
|
||||
|
||||
if (!confirm('Are you sure you want to delete this proxy host?')) return
|
||||
|
||||
try {
|
||||
// See if there are uptime monitors associated with this host (match by upstream_host / forward_host)
|
||||
let associatedMonitors: UptimeMonitor[] = []
|
||||
try {
|
||||
await deleteHost(uuid)
|
||||
} catch (err) {
|
||||
alert(err instanceof Error ? err.message : 'Failed to delete')
|
||||
const monitors = await getMonitors()
|
||||
associatedMonitors = monitors.filter(m => m.upstream_host === host.forward_host || (m.proxy_host_id && m.proxy_host_id === (host as unknown as { id?: number }).id))
|
||||
} catch {
|
||||
// ignore errors fetching uptime data; continue with host deletion
|
||||
}
|
||||
|
||||
if (associatedMonitors.length > 0) {
|
||||
const deleteUptime = confirm('This proxy host has uptime monitors associated with it. Delete the monitors as well?')
|
||||
await deleteHost(uuid, deleteUptime)
|
||||
} else {
|
||||
await deleteHost(uuid)
|
||||
}
|
||||
} catch (err) {
|
||||
alert(err instanceof Error ? err.message : 'Failed to delete')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -202,6 +239,12 @@ export default function ProxyHosts() {
|
||||
<span className="text-gray-400 text-sm">
|
||||
{selectedHosts.size} {selectedHosts.size === hosts.length && '(all)'} selected
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setShowBulkApplyModal(true)}
|
||||
className="px-4 py-2 bg-indigo-700 hover:bg-indigo-600 text-white rounded-lg font-medium transition-colors"
|
||||
>
|
||||
Bulk Apply
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowBulkACLModal(true)}
|
||||
className="px-4 py-2 bg-gray-700 hover:bg-gray-600 text-white rounded-lg font-medium transition-colors"
|
||||
@@ -233,6 +276,106 @@ export default function ProxyHosts() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Bulk Apply Modal */}
|
||||
{showBulkApplyModal && (
|
||||
<div
|
||||
className="fixed inset-0 bg-black/50 flex items-center justify-center z-50"
|
||||
onClick={() => setShowBulkApplyModal(false)}
|
||||
>
|
||||
<div
|
||||
className="bg-dark-card border border-gray-800 rounded-lg p-6 max-w-md w-full mx-4 max-h-[80vh] overflow-hidden flex flex-col"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<h2 className="text-xl font-bold text-white mb-4">Bulk Apply Settings</h2>
|
||||
<div className="space-y-4 flex-1 overflow-hidden flex flex-col">
|
||||
<p className="text-sm text-gray-400">
|
||||
Applying settings to <span className="text-blue-400 font-medium">{selectedHosts.size}</span> selected host(s)
|
||||
</p>
|
||||
|
||||
<div className="flex-1 overflow-y-auto border border-gray-700 rounded-lg p-3 space-y-3">
|
||||
{Object.entries(bulkApplySettings).map(([key, cfg]) => (
|
||||
<div key={key} className="flex items-center justify-between gap-3 p-2 bg-gray-900/30 rounded">
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={cfg.apply}
|
||||
onChange={(e) => setBulkApplySettings(prev => ({ ...prev, [key]: { ...prev[key], apply: e.target.checked } }))}
|
||||
className="w-4 h-4 rounded border-gray-600 text-blue-500 bg-gray-700"
|
||||
/>
|
||||
<div>
|
||||
<div className="text-white font-medium">{formatSettingLabel(key)}</div>
|
||||
<div className="text-xs text-gray-400">{settingHelpText(key)}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-xs text-gray-400">Set:</span>
|
||||
<Switch
|
||||
checked={cfg.value}
|
||||
onCheckedChange={(v: boolean) => setBulkApplySettings(prev => ({ ...prev, [key]: { ...prev[key], value: v } }))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{applyProgress && (
|
||||
<div className="border border-blue-800/50 rounded-lg bg-blue-900/20 p-4">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<Loader2 className="w-5 h-5 animate-spin text-blue-400" />
|
||||
<span className="text-blue-300 font-medium">
|
||||
Applying settings... ({applyProgress.current}/{applyProgress.total})
|
||||
</span>
|
||||
</div>
|
||||
<div className="w-full bg-gray-700 rounded-full h-2">
|
||||
<div
|
||||
className="bg-blue-500 h-2 rounded-full transition-all duration-300"
|
||||
style={{ width: `${(applyProgress.current / applyProgress.total) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowBulkApplyModal(false)
|
||||
}}
|
||||
className="px-4 py-2 bg-gray-700 hover:bg-gray-600 text-white rounded-lg font-medium transition-colors"
|
||||
disabled={applyProgress !== null}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={async () => {
|
||||
const keysToApply = Object.keys(bulkApplySettings).filter(k => bulkApplySettings[k].apply)
|
||||
if (keysToApply.length === 0) return
|
||||
|
||||
const hostUUIDs = Array.from(selectedHosts)
|
||||
const result = await applyBulkSettingsToHosts({ hosts, hostUUIDs, keysToApply, bulkApplySettings, updateHost, setApplyProgress })
|
||||
|
||||
if (result.errors > 0) {
|
||||
toast.error(`Applied settings with ${result.errors} error(s)`)
|
||||
} else {
|
||||
toast.success(`Applied settings to ${hostUUIDs.length} host(s)`)
|
||||
}
|
||||
|
||||
setSelectedHosts(new Set())
|
||||
setShowBulkApplyModal(false)
|
||||
}}
|
||||
disabled={applyProgress !== null || Object.values(bulkApplySettings).every(s => !s.apply)}
|
||||
className="px-4 py-2 bg-blue-600 hover:bg-blue-500 text-white rounded-lg font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{(applyProgress !== null) && <Loader2 className="w-4 h-4 animate-spin mr-2" />}
|
||||
Apply
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="bg-dark-card rounded-lg border border-gray-800 overflow-hidden">
|
||||
{loading ? (
|
||||
<div className="text-center text-gray-400 py-12">Loading...</div>
|
||||
@@ -242,11 +385,12 @@ export default function ProxyHosts() {
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<table className="w-full table-fixed min-w-0">
|
||||
<thead className="bg-gray-900 border-b border-gray-800">
|
||||
<tr>
|
||||
<th
|
||||
onClick={() => handleSort('name')}
|
||||
style={{ width: '20%' }}
|
||||
className="px-6 py-3 text-left text-xs font-medium text-gray-400 uppercase tracking-wider cursor-pointer hover:text-gray-200 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-1">
|
||||
@@ -256,6 +400,7 @@ export default function ProxyHosts() {
|
||||
</th>
|
||||
<th
|
||||
onClick={() => handleSort('domain')}
|
||||
style={{ width: '26%' }}
|
||||
className="px-6 py-3 text-left text-xs font-medium text-gray-400 uppercase tracking-wider cursor-pointer hover:text-gray-200 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-1">
|
||||
@@ -265,6 +410,7 @@ export default function ProxyHosts() {
|
||||
</th>
|
||||
<th
|
||||
onClick={() => handleSort('forward')}
|
||||
style={{ width: '18%' }}
|
||||
className="px-6 py-3 text-left text-xs font-medium text-gray-400 uppercase tracking-wider cursor-pointer hover:text-gray-200 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-1">
|
||||
@@ -272,16 +418,16 @@ export default function ProxyHosts() {
|
||||
<SortIcon column="forward" />
|
||||
</div>
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-400 uppercase tracking-wider">
|
||||
<th style={{ width: '8%' }} className="px-6 py-3 text-center text-xs font-medium text-gray-400 uppercase tracking-wider">
|
||||
SSL
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-400 uppercase tracking-wider">
|
||||
<th style={{ width: '10%' }} className="px-6 py-3 text-center text-xs font-medium text-gray-400 uppercase tracking-wider">
|
||||
Status
|
||||
</th>
|
||||
<th className="px-6 py-3 text-right text-xs font-medium text-gray-400 uppercase tracking-wider">
|
||||
<th style={{ width: '12%' }} className="px-6 py-3 text-right text-xs font-medium text-gray-400 uppercase tracking-wider">
|
||||
Actions
|
||||
</th>
|
||||
<th className="px-6 py-3 text-center text-xs font-medium text-gray-400 uppercase tracking-wider">
|
||||
<th style={{ width: '6%' }} className="px-6 py-3 text-center text-xs font-medium text-gray-400 uppercase tracking-wider">
|
||||
<button
|
||||
onClick={toggleSelectAll}
|
||||
role="checkbox"
|
||||
@@ -302,37 +448,40 @@ export default function ProxyHosts() {
|
||||
{sortedHosts.map((host) => (
|
||||
<tr key={host.uuid} className="hover:bg-gray-900/50">
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="text-sm font-medium text-white">
|
||||
{host.name || <span className="text-gray-500 italic">Unnamed</span>}
|
||||
</div>
|
||||
<div className="text-sm font-medium text-white max-w-full truncate">
|
||||
{host.name || <span className="text-gray-500 italic">Unnamed</span>}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="text-sm font-medium text-white">
|
||||
{host.domain_names.split(',').map((domain, i) => {
|
||||
const url = `${host.ssl_forced ? 'https' : 'http'}://${domain.trim()}`
|
||||
return (
|
||||
<div key={i} className="flex items-center gap-1">
|
||||
<a
|
||||
href={url}
|
||||
target={linkBehavior === 'same_tab' ? '_self' : '_blank'}
|
||||
rel="noopener noreferrer"
|
||||
onClick={(e) => handleDomainClick(e, url)}
|
||||
className="hover:text-blue-400 hover:underline flex items-center gap-1"
|
||||
>
|
||||
{domain.trim()}
|
||||
<ExternalLink size={12} className="opacity-50" />
|
||||
</a>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<div className="text-sm font-medium text-white">
|
||||
{host.domain_names.split(',').map((domain, i) => {
|
||||
const d = domain.trim()
|
||||
const url = `${host.ssl_forced ? 'https' : 'http'}://${d}`
|
||||
return (
|
||||
<div key={i} className="flex items-center gap-1">
|
||||
<a
|
||||
href={url}
|
||||
title={url}
|
||||
target={linkBehavior === 'same_tab' ? '_self' : '_blank'}
|
||||
rel="noopener noreferrer"
|
||||
onClick={(e) => handleDomainClick(e, url)}
|
||||
className="hover:text-blue-400 hover:underline flex items-center gap-1 truncate block max-w-full"
|
||||
style={{ maxWidth: '100%' }}
|
||||
>
|
||||
<span className="truncate block max-w-[40ch]">{d}</span>
|
||||
<ExternalLink size={12} className="opacity-50" />
|
||||
</a>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="text-sm text-gray-300">
|
||||
{host.forward_scheme}://{host.forward_host}:{host.forward_port}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<td className="px-6 py-4 whitespace-nowrap text-center">
|
||||
{(() => {
|
||||
// Get the primary domain to look up cert status (case-insensitive)
|
||||
const primaryDomain = host.domain_names.split(',')[0]?.trim().toLowerCase()
|
||||
@@ -386,7 +535,7 @@ export default function ProxyHosts() {
|
||||
)
|
||||
})()}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<td className="px-6 py-4 whitespace-nowrap text-center">
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch
|
||||
checked={host.enabled}
|
||||
|
||||
@@ -100,8 +100,9 @@ export default function SystemSettings() {
|
||||
refetchFlags()
|
||||
toast.success('Feature flag updated')
|
||||
},
|
||||
onError: (err: any) => {
|
||||
toast.error(`Failed to update flag: ${err?.message || err}`)
|
||||
onError: (err: unknown) => {
|
||||
const msg = err instanceof Error ? err.message : String(err)
|
||||
toast.error(`Failed to update flag: ${msg}`)
|
||||
},
|
||||
})
|
||||
|
||||
@@ -119,13 +120,13 @@ export default function SystemSettings() {
|
||||
|
||||
useEffect(() => { fetchCrowdsecStatus() }, [])
|
||||
|
||||
const startMutation = useMutation({ mutationFn: () => startCrowdsec(), onSuccess: () => fetchCrowdsecStatus(), onError: (e:any) => toast.error(String(e)) })
|
||||
const stopMutation = useMutation({ mutationFn: () => stopCrowdsec(), onSuccess: () => fetchCrowdsecStatus(), onError: (e:any) => toast.error(String(e)) })
|
||||
const startMutation = useMutation({ mutationFn: () => startCrowdsec(), onSuccess: () => fetchCrowdsecStatus(), onError: (e: unknown) => toast.error(String(e)) })
|
||||
const stopMutation = useMutation({ mutationFn: () => stopCrowdsec(), onSuccess: () => fetchCrowdsecStatus(), onError: (e: unknown) => toast.error(String(e)) })
|
||||
|
||||
const importMutation = useMutation({
|
||||
mutationFn: async (file: File) => importCrowdsecConfig(file),
|
||||
onSuccess: () => { toast.success('CrowdSec config imported'); fetchCrowdsecStatus() },
|
||||
onError: (e:any) => toast.error(String(e)),
|
||||
onError: (e: unknown) => toast.error(String(e)),
|
||||
})
|
||||
|
||||
const handleCrowdsecUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { useMemo, useState, type FC, type FormEvent } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { getMonitors, getMonitorHistory, updateMonitor, UptimeMonitor } from '../api/uptime';
|
||||
import { Activity, ArrowUp, ArrowDown, Settings, X } from 'lucide-react';
|
||||
import { getMonitors, getMonitorHistory, updateMonitor, deleteMonitor, UptimeMonitor } from '../api/uptime';
|
||||
import { Activity, ArrowUp, ArrowDown, Settings, X, Pause } from 'lucide-react';
|
||||
import { toast } from 'react-hot-toast'
|
||||
import { formatDistanceToNow } from 'date-fns';
|
||||
|
||||
const MonitorCard: FC<{ monitor: UptimeMonitor; onEdit: (monitor: UptimeMonitor) => void }> = ({ monitor, onEdit }) => {
|
||||
@@ -11,27 +12,110 @@ const MonitorCard: FC<{ monitor: UptimeMonitor; onEdit: (monitor: UptimeMonitor)
|
||||
refetchInterval: 60000,
|
||||
});
|
||||
|
||||
const isUp = monitor.status === 'up';
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: async (id: string) => {
|
||||
return await deleteMonitor(id)
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['monitors'] })
|
||||
toast.success('Monitor deleted')
|
||||
},
|
||||
onError: (err: unknown) => {
|
||||
toast.error(err instanceof Error ? err.message : 'Failed to delete monitor')
|
||||
}
|
||||
})
|
||||
|
||||
const toggleMutation = useMutation({
|
||||
mutationFn: async ({ id, enabled }: { id: string; enabled: boolean }) => {
|
||||
return await updateMonitor(id, { enabled })
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['monitors'] })
|
||||
},
|
||||
onError: (err: unknown) => {
|
||||
toast.error(err instanceof Error ? err.message : 'Failed to update monitor')
|
||||
}
|
||||
})
|
||||
|
||||
const [showMenu, setShowMenu] = useState(false)
|
||||
|
||||
// Determine current status from most recent heartbeat when available
|
||||
const latestBeat = history && history.length > 0
|
||||
? history.reduce((a, b) => new Date(a.created_at) > new Date(b.created_at) ? a : b)
|
||||
: null
|
||||
|
||||
const isUp = latestBeat ? latestBeat.status === 'up' : monitor.status === 'up';
|
||||
const isPaused = monitor.enabled === false;
|
||||
|
||||
return (
|
||||
<div className={`bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-4 border-l-4 ${isUp ? 'border-l-green-500' : 'border-l-red-500'}`}>
|
||||
<div className={`bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-4 border-l-4 ${isPaused ? 'border-l-yellow-400' : isUp ? 'border-l-green-500' : 'border-l-red-500'}`}>
|
||||
{/* Top Row: Name (left), Badge (center-right), Settings (right) */}
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="font-semibold text-lg text-gray-900 dark:text-white">{monitor.name}</h3>
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="font-semibold text-lg text-gray-900 dark:text-white flex-1 min-w-0 truncate">{monitor.name}</h3>
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
<div className={`flex items-center justify-center px-3 py-1 rounded-full text-sm font-medium min-w-[90px] ${
|
||||
isUp ? 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200' : 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200'
|
||||
isPaused
|
||||
? 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200'
|
||||
: isUp
|
||||
? 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200'
|
||||
: 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200'
|
||||
}`}>
|
||||
{isUp ? <ArrowUp className="w-4 h-4 mr-1" /> : <ArrowDown className="w-4 h-4 mr-1" />}
|
||||
{monitor.status.toUpperCase()}
|
||||
{isPaused ? <Pause className="w-4 h-4 mr-1" /> : isUp ? <ArrowUp className="w-4 h-4 mr-1" /> : <ArrowDown className="w-4 h-4 mr-1" />}
|
||||
{isPaused ? 'PAUSED' : monitor.status.toUpperCase()}
|
||||
</div>
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => setShowMenu(prev => !prev)}
|
||||
className="p-1 text-gray-400 hover:text-gray-200 transition-colors"
|
||||
title="Monitor settings"
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={showMenu}
|
||||
>
|
||||
<Settings size={16} />
|
||||
</button>
|
||||
|
||||
{showMenu && (
|
||||
<div className="absolute right-0 mt-2 w-48 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded shadow-lg z-20">
|
||||
<button
|
||||
onClick={() => { setShowMenu(false); onEdit(monitor) }}
|
||||
className="w-full text-left px-4 py-2 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-900"
|
||||
>
|
||||
Configure
|
||||
</button>
|
||||
<button
|
||||
onClick={async () => {
|
||||
setShowMenu(false)
|
||||
const confirmDelete = confirm('Delete this monitor? This cannot be undone.')
|
||||
if (!confirmDelete) return
|
||||
try {
|
||||
await deleteMutation.mutateAsync(monitor.id)
|
||||
} catch {
|
||||
// handled in onError
|
||||
}
|
||||
}}
|
||||
className="w-full text-left px-4 py-2 text-sm text-red-600 hover:bg-gray-100 dark:hover:bg-gray-900"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
<button
|
||||
onClick={async () => {
|
||||
setShowMenu(false)
|
||||
try {
|
||||
await toggleMutation.mutateAsync({ id: monitor.id, enabled: !monitor.enabled })
|
||||
toast.success(`${monitor.enabled ? 'Monitoring disabled' : 'Monitoring enabled'}`)
|
||||
} catch {
|
||||
// handled in onError
|
||||
}
|
||||
}}
|
||||
className="w-full text-left px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 dark:hover:bg-gray-900"
|
||||
>
|
||||
{monitor.enabled ? 'Disable Monitoring' : 'Enable Monitoring'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => onEdit(monitor)}
|
||||
className="p-1 text-gray-400 hover:text-gray-200 transition-colors"
|
||||
title="Configure Monitor"
|
||||
>
|
||||
<Settings size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -61,7 +145,7 @@ const MonitorCard: FC<{ monitor: UptimeMonitor; onEdit: (monitor: UptimeMonitor)
|
||||
</div>
|
||||
|
||||
{/* Heartbeat Bar (Last 60 checks / 1 Hour) */}
|
||||
<div className="flex gap-[2px] h-8 items-end" title="Last 60 checks (1 Hour)">
|
||||
<div className="flex gap-[2px] h-8 items-end relative" title="Last 60 checks (1 Hour)">
|
||||
{/* Fill with empty bars if not enough history to keep alignment right-aligned */}
|
||||
{Array.from({ length: Math.max(0, 60 - (history?.length || 0)) }).map((_, i) => (
|
||||
<div key={`empty-${i}`} className="flex-1 bg-gray-100 dark:bg-gray-700 rounded-sm h-full opacity-50" />
|
||||
@@ -128,7 +212,10 @@ const EditMonitorModal: FC<{ monitor: UptimeMonitor; onClose: () => void }> = ({
|
||||
min="1"
|
||||
max="10"
|
||||
value={maxRetries}
|
||||
onChange={(e) => setMaxRetries(parseInt(e.target.value))}
|
||||
onChange={(e) => {
|
||||
const v = parseInt(e.target.value)
|
||||
setMaxRetries(Number.isNaN(v) ? 0 : v)
|
||||
}}
|
||||
className="w-full bg-gray-900 border border-gray-700 rounded-lg px-4 py-2 text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
@@ -145,7 +232,10 @@ const EditMonitorModal: FC<{ monitor: UptimeMonitor; onClose: () => void }> = ({
|
||||
min="10"
|
||||
max="3600"
|
||||
value={interval}
|
||||
onChange={(e) => setInterval(parseInt(e.target.value))}
|
||||
onChange={(e) => {
|
||||
const v = parseInt(e.target.value)
|
||||
setInterval(Number.isNaN(v) ? 0 : v)
|
||||
}}
|
||||
className="w-full bg-gray-900 border border-gray-700 rounded-lg px-4 py-2 text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,80 +1,63 @@
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import { vi, describe, it, expect, beforeEach } from 'vitest';
|
||||
import Login from '../Login';
|
||||
import * as setupApi from '../../api/setup';
|
||||
|
||||
// Mock AuthContext so useAuth works in tests
|
||||
vi.mock('../../hooks/useAuth', () => ({
|
||||
useAuth: () => ({
|
||||
login: vi.fn(),
|
||||
logout: vi.fn(),
|
||||
isAuthenticated: false,
|
||||
isLoading: false,
|
||||
user: null,
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock API client
|
||||
vi.mock('../../api/client', () => ({
|
||||
default: {
|
||||
post: vi.fn().mockResolvedValue({ data: {} }),
|
||||
get: vi.fn().mockResolvedValue({ data: {} }),
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock react-router-dom
|
||||
const mockNavigate = vi.fn();
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
// Mock react-router-dom useNavigate at module level
|
||||
const mockNavigate = vi.fn()
|
||||
vi.mock('react-router-dom', async () => {
|
||||
const actual = await vi.importActual('react-router-dom');
|
||||
const actual = await vi.importActual('react-router-dom')
|
||||
return {
|
||||
...actual,
|
||||
useNavigate: () => mockNavigate,
|
||||
};
|
||||
});
|
||||
}
|
||||
})
|
||||
|
||||
// Mock the API module
|
||||
vi.mock('../../api/setup', () => ({
|
||||
getSetupStatus: vi.fn(),
|
||||
performSetup: vi.fn(),
|
||||
}));
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import Login from '../Login'
|
||||
import * as setupApi from '../../api/setup'
|
||||
import client from '../../api/client'
|
||||
import * as authHook from '../../hooks/useAuth'
|
||||
import type { AuthContextType } from '../../context/AuthContextValue'
|
||||
import { toast } from '../../utils/toast'
|
||||
import { MemoryRouter } from 'react-router-dom'
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
vi.mock('../../api/setup')
|
||||
vi.mock('../../hooks/useAuth')
|
||||
|
||||
const renderWithProviders = (ui: React.ReactNode) => {
|
||||
return render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<MemoryRouter>
|
||||
{ui}
|
||||
</MemoryRouter>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
};
|
||||
describe('<Login />', () => {
|
||||
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } })
|
||||
const renderWithProviders = (ui: React.ReactNode) => (
|
||||
render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<MemoryRouter>{ui}</MemoryRouter>
|
||||
</QueryClientProvider>
|
||||
)
|
||||
)
|
||||
|
||||
describe('Login Page', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
queryClient.clear();
|
||||
});
|
||||
vi.restoreAllMocks()
|
||||
vi.spyOn(authHook, 'useAuth').mockReturnValue({ login: vi.fn() } as unknown as AuthContextType)
|
||||
})
|
||||
|
||||
it('renders login form and logo when setup is not required', async () => {
|
||||
vi.mocked(setupApi.getSetupStatus).mockResolvedValue({ setupRequired: false });
|
||||
|
||||
renderWithProviders(<Login />);
|
||||
|
||||
// The page will redirect to setup if setup is required; for our test we mock it as not required
|
||||
it('navigates to /setup when setup is required', async () => {
|
||||
vi.spyOn(setupApi, 'getSetupStatus').mockResolvedValue({ setupRequired: true })
|
||||
renderWithProviders(<Login />)
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('button', { name: 'Sign In' })).toBeTruthy();
|
||||
});
|
||||
expect(mockNavigate).toHaveBeenCalledWith('/setup')
|
||||
})
|
||||
})
|
||||
|
||||
// Verify logo is present
|
||||
expect(screen.getAllByAltText('Charon').length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
it('shows error toast when login fails', async () => {
|
||||
vi.spyOn(setupApi, 'getSetupStatus').mockResolvedValue({ setupRequired: false })
|
||||
const postSpy = vi.spyOn(client, 'post').mockRejectedValueOnce({ response: { data: { error: 'Bad creds' } } })
|
||||
const toastSpy = vi.spyOn(toast, 'error')
|
||||
renderWithProviders(<Login />)
|
||||
// Fill and submit
|
||||
const email = screen.getByPlaceholderText(/admin@example.com/i)
|
||||
const pass = screen.getByPlaceholderText(/••••••••/i)
|
||||
fireEvent.change(email, { target: { value: 'a@b.com' } })
|
||||
fireEvent.change(pass, { target: { value: 'pw' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: /Sign In/i }))
|
||||
// Wait for the promise chain
|
||||
await waitFor(() => expect(postSpy).toHaveBeenCalled())
|
||||
expect(toastSpy).toHaveBeenCalledWith('Bad creds')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import { vi, describe, it, expect, beforeEach } from 'vitest';
|
||||
import ProxyHosts from '../ProxyHosts';
|
||||
import * as proxyHostsApi from '../../api/proxyHosts';
|
||||
import * as certificatesApi from '../../api/certificates';
|
||||
import type { ProxyHost } from '../../api/proxyHosts'
|
||||
import type { Certificate } from '../../api/certificates'
|
||||
import * as accessListsApi from '../../api/accessLists';
|
||||
import type { AccessList } from '../../api/accessLists'
|
||||
import * as settingsApi from '../../api/settings';
|
||||
import { createMockProxyHost } from '../../testUtils/createMockProxyHost';
|
||||
|
||||
vi.mock('react-hot-toast', () => ({ toast: { success: vi.fn(), error: vi.fn(), loading: vi.fn(), dismiss: vi.fn() } }));
|
||||
vi.mock('../../api/proxyHosts', () => ({ getProxyHosts: vi.fn(), createProxyHost: vi.fn(), updateProxyHost: vi.fn(), deleteProxyHost: vi.fn(), bulkUpdateACL: vi.fn(), testProxyHostConnection: vi.fn() }));
|
||||
vi.mock('../../api/certificates', () => ({ getCertificates: vi.fn() }));
|
||||
vi.mock('../../api/accessLists', () => ({ accessListsApi: { list: vi.fn() } }));
|
||||
vi.mock('../../api/settings', () => ({ getSettings: vi.fn() }));
|
||||
|
||||
const hosts = [
|
||||
createMockProxyHost({ uuid: 'h1', name: 'Host 1', domain_names: 'one.example.com' }),
|
||||
createMockProxyHost({ uuid: 'h2', name: 'Host 2', domain_names: 'two.example.com' }),
|
||||
];
|
||||
|
||||
const createQueryClient = () => new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: 0 }, mutations: { retry: false } } });
|
||||
const renderWithProviders = (ui: React.ReactNode) => {
|
||||
const queryClient = createQueryClient();
|
||||
return render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<MemoryRouter>{ui}</MemoryRouter>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
};
|
||||
|
||||
describe('ProxyHosts - Bulk Apply all settings coverage', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(proxyHostsApi.getProxyHosts).mockResolvedValue(hosts as ProxyHost[]);
|
||||
vi.mocked(certificatesApi.getCertificates).mockResolvedValue([] as Certificate[]);
|
||||
vi.mocked(accessListsApi.accessListsApi.list).mockResolvedValue([] as AccessList[]);
|
||||
vi.mocked(settingsApi.getSettings).mockResolvedValue({} as Record<string, string>);
|
||||
});
|
||||
|
||||
it('renders all bulk apply setting labels and allows toggling', async () => {
|
||||
renderWithProviders(<ProxyHosts />);
|
||||
|
||||
await waitFor(() => expect(screen.getByText('Host 1')).toBeTruthy());
|
||||
|
||||
// select all
|
||||
const headerCheckbox = screen.getAllByRole('checkbox')[0];
|
||||
await userEvent.click(headerCheckbox);
|
||||
|
||||
// open Bulk Apply
|
||||
await waitFor(() => expect(screen.getByText('Bulk Apply')).toBeTruthy());
|
||||
await userEvent.click(screen.getByText('Bulk Apply'));
|
||||
await waitFor(() => expect(screen.getByText('Bulk Apply Settings')).toBeTruthy());
|
||||
|
||||
const labels = [
|
||||
'Force SSL',
|
||||
'HTTP/2 Support',
|
||||
'HSTS Enabled',
|
||||
'HSTS Subdomains',
|
||||
'Block Exploits',
|
||||
'Websockets Support',
|
||||
];
|
||||
|
||||
for (const lbl of labels) {
|
||||
expect(screen.getByText(lbl)).toBeTruthy();
|
||||
// find close checkbox and click its apply checkbox (the first input in the label area)
|
||||
const el = screen.getByText(lbl) as HTMLElement;
|
||||
let container: HTMLElement | null = el;
|
||||
while (container && !container.querySelector('input[type="checkbox"]')) container = container.parentElement;
|
||||
const cb = container?.querySelector('input[type="checkbox"]') as HTMLElement | null;
|
||||
if (cb) await userEvent.click(cb);
|
||||
}
|
||||
|
||||
// After toggling at least one, Apply should be enabled
|
||||
const modalRoot = screen.getByText('Bulk Apply Settings').closest('div');
|
||||
const { within } = await import('@testing-library/react');
|
||||
const applyBtn = modalRoot ? within(modalRoot).getByRole('button', { name: /^Apply$/i }) : screen.getByRole('button', { name: /^Apply$/i });
|
||||
expect(applyBtn).toBeTruthy();
|
||||
// Cancel to close
|
||||
await userEvent.click(modalRoot ? within(modalRoot).getByRole('button', { name: /Cancel/i }) : screen.getByRole('button', { name: /Cancel/i }));
|
||||
await waitFor(() => expect(screen.queryByText('Bulk Apply Settings')).toBeNull());
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,88 @@
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { MemoryRouter } from 'react-router-dom'
|
||||
import { vi, describe, it, expect, beforeEach } from 'vitest'
|
||||
import ProxyHosts from '../ProxyHosts'
|
||||
import * as proxyHostsApi from '../../api/proxyHosts'
|
||||
import * as certificatesApi from '../../api/certificates'
|
||||
import * as accessListsApi from '../../api/accessLists'
|
||||
import * as settingsApi from '../../api/settings'
|
||||
import type { Certificate } from '../../api/certificates'
|
||||
import type { AccessList } from '../../api/accessLists'
|
||||
import { createMockProxyHost } from '../../testUtils/createMockProxyHost'
|
||||
import type { ProxyHost } from '../../api/proxyHosts'
|
||||
|
||||
vi.mock('react-hot-toast', () => ({ toast: { success: vi.fn(), error: vi.fn(), loading: vi.fn(), dismiss: vi.fn() } }))
|
||||
vi.mock('../../api/proxyHosts', () => ({ getProxyHosts: vi.fn(), createProxyHost: vi.fn(), updateProxyHost: vi.fn(), deleteProxyHost: vi.fn(), bulkUpdateACL: vi.fn(), testProxyHostConnection: vi.fn() }))
|
||||
vi.mock('../../api/certificates', () => ({ getCertificates: vi.fn() }))
|
||||
vi.mock('../../api/accessLists', () => ({ accessListsApi: { list: vi.fn() } }))
|
||||
vi.mock('../../api/settings', () => ({ getSettings: vi.fn() }))
|
||||
|
||||
const hosts = [
|
||||
createMockProxyHost({ uuid: 'p1', name: 'Progress 1', domain_names: 'p1.example.com' }),
|
||||
createMockProxyHost({ uuid: 'p2', name: 'Progress 2', domain_names: 'p2.example.com' }),
|
||||
]
|
||||
|
||||
const createQueryClient = () => new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: 0 }, mutations: { retry: false } } })
|
||||
const renderWithProviders = (ui: React.ReactNode) => {
|
||||
const queryClient = createQueryClient()
|
||||
return render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<MemoryRouter>{ui}</MemoryRouter>
|
||||
</QueryClientProvider>
|
||||
)
|
||||
}
|
||||
|
||||
describe('ProxyHosts - Bulk Apply progress UI', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.mocked(proxyHostsApi.getProxyHosts).mockResolvedValue(hosts as ProxyHost[])
|
||||
vi.mocked(certificatesApi.getCertificates).mockResolvedValue([] as Certificate[])
|
||||
vi.mocked(accessListsApi.accessListsApi.list).mockResolvedValue([] as AccessList[])
|
||||
vi.mocked(settingsApi.getSettings).mockResolvedValue({} as Record<string, string>)
|
||||
})
|
||||
|
||||
it('shows applying progress while updateProxyHost resolves', async () => {
|
||||
// Make updateProxyHost return controllable promises so we can assert the progress UI
|
||||
const updateMock = vi.mocked(proxyHostsApi.updateProxyHost)
|
||||
const resolvers: Array<(v: ProxyHost) => void> = []
|
||||
updateMock.mockImplementation(() => new Promise((res: (v: ProxyHost) => void) => { resolvers.push(res) }))
|
||||
renderWithProviders(<ProxyHosts />)
|
||||
await waitFor(() => expect(screen.getByText('Progress 1')).toBeTruthy())
|
||||
|
||||
// Select all
|
||||
const selectAll = screen.getAllByRole('checkbox')[0]
|
||||
await userEvent.click(selectAll)
|
||||
|
||||
// Open Bulk Apply
|
||||
await userEvent.click(screen.getByText('Bulk Apply'))
|
||||
await waitFor(() => expect(screen.getByText('Bulk Apply Settings')).toBeTruthy())
|
||||
|
||||
// Enable one setting (Force SSL)
|
||||
const forceLabel = screen.getByText(/Force SSL/i) as HTMLElement
|
||||
let forceContainer: HTMLElement | null = forceLabel
|
||||
while (forceContainer && !forceContainer.querySelector('input[type="checkbox"]')) forceContainer = forceContainer.parentElement
|
||||
const forceCheckbox = forceContainer ? (forceContainer.querySelector('input[type="checkbox"]') as HTMLElement | null) : null
|
||||
if (forceCheckbox) await userEvent.click(forceCheckbox as HTMLElement)
|
||||
|
||||
// Click Apply and assert progress UI appears
|
||||
const modalRoot = screen.getByText('Bulk Apply Settings').closest('div')
|
||||
const { within } = await import('@testing-library/react')
|
||||
const applyButton = modalRoot ? within(modalRoot).getByRole('button', { name: /^Apply$/i }) : screen.getByRole('button', { name: /^Apply$/i })
|
||||
await userEvent.click(applyButton)
|
||||
|
||||
// During the small delay the progress text should appear (there are two matching nodes)
|
||||
await waitFor(() => expect(screen.getAllByText(/Applying settings/i).length).toBeGreaterThan(0))
|
||||
|
||||
// Resolve both pending update promises to finish the operation
|
||||
resolvers.forEach(r => r(hosts[0]))
|
||||
// Ensure subsequent tests aren't blocked by the special mock: make updateProxyHost resolve normally
|
||||
updateMock.mockImplementation(() => Promise.resolve(hosts[0] as ProxyHost))
|
||||
|
||||
// Wait for updates to complete
|
||||
await waitFor(() => expect(updateMock).toHaveBeenCalledTimes(2))
|
||||
})
|
||||
})
|
||||
|
||||
export {}
|
||||
130
frontend/src/pages/__tests__/ProxyHosts-bulk-apply.test.tsx
Normal file
130
frontend/src/pages/__tests__/ProxyHosts-bulk-apply.test.tsx
Normal file
@@ -0,0 +1,130 @@
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import { vi, describe, it, expect, beforeEach } from 'vitest';
|
||||
import ProxyHosts from '../ProxyHosts';
|
||||
import * as proxyHostsApi from '../../api/proxyHosts';
|
||||
import * as certificatesApi from '../../api/certificates';
|
||||
import type { Certificate } from '../../api/certificates'
|
||||
import type { ProxyHost } from '../../api/proxyHosts'
|
||||
import * as accessListsApi from '../../api/accessLists';
|
||||
import type { AccessList } from '../../api/accessLists'
|
||||
import * as settingsApi from '../../api/settings';
|
||||
import { createMockProxyHost } from '../../testUtils/createMockProxyHost';
|
||||
|
||||
// Mock toast
|
||||
vi.mock('react-hot-toast', () => ({
|
||||
toast: { success: vi.fn(), error: vi.fn(), loading: vi.fn(), dismiss: vi.fn() },
|
||||
}));
|
||||
|
||||
vi.mock('../../api/proxyHosts', () => ({
|
||||
getProxyHosts: vi.fn(),
|
||||
createProxyHost: vi.fn(),
|
||||
updateProxyHost: vi.fn(),
|
||||
deleteProxyHost: vi.fn(),
|
||||
bulkUpdateACL: vi.fn(),
|
||||
testProxyHostConnection: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../api/certificates', () => ({ getCertificates: vi.fn() }));
|
||||
vi.mock('../../api/accessLists', () => ({ accessListsApi: { list: vi.fn() } }));
|
||||
vi.mock('../../api/settings', () => ({ getSettings: vi.fn() }));
|
||||
|
||||
const mockProxyHosts = [
|
||||
createMockProxyHost({ uuid: 'host-1', name: 'Test Host 1', domain_names: 'test1.example.com', forward_host: '192.168.1.10' }),
|
||||
createMockProxyHost({ uuid: 'host-2', name: 'Test Host 2', domain_names: 'test2.example.com', forward_host: '192.168.1.20' }),
|
||||
];
|
||||
|
||||
const createQueryClient = () => new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: 0 }, mutations: { retry: false } } });
|
||||
|
||||
const renderWithProviders = (ui: React.ReactNode) => {
|
||||
const queryClient = createQueryClient();
|
||||
return render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<MemoryRouter>{ui}</MemoryRouter>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
};
|
||||
|
||||
describe('ProxyHosts - Bulk Apply Settings', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(proxyHostsApi.getProxyHosts).mockResolvedValue(mockProxyHosts as ProxyHost[]);
|
||||
vi.mocked(certificatesApi.getCertificates).mockResolvedValue([] as Certificate[]);
|
||||
vi.mocked(accessListsApi.accessListsApi.list).mockResolvedValue([] as AccessList[]);
|
||||
vi.mocked(settingsApi.getSettings).mockResolvedValue({} as Record<string, string>);
|
||||
});
|
||||
|
||||
it('shows Bulk Apply button when hosts selected and opens modal', async () => {
|
||||
renderWithProviders(<ProxyHosts />);
|
||||
|
||||
await waitFor(() => expect(screen.getByText('Test Host 1')).toBeTruthy());
|
||||
|
||||
// Select first host using select-all checkbox
|
||||
const selectAll = screen.getAllByRole('checkbox')[0];
|
||||
await userEvent.click(selectAll);
|
||||
|
||||
// Bulk Apply button should appear
|
||||
await waitFor(() => expect(screen.getByText('Bulk Apply')).toBeTruthy());
|
||||
|
||||
// Open modal
|
||||
await userEvent.click(screen.getByText('Bulk Apply'));
|
||||
await waitFor(() => expect(screen.getByText('Bulk Apply Settings')).toBeTruthy());
|
||||
});
|
||||
|
||||
it('applies selected settings to all selected hosts by calling updateProxyHost merged payload', async () => {
|
||||
const updateMock = vi.mocked(proxyHostsApi.updateProxyHost);
|
||||
updateMock.mockResolvedValue(mockProxyHosts[0] as ProxyHost);
|
||||
|
||||
renderWithProviders(<ProxyHosts />);
|
||||
await waitFor(() => expect(screen.getByText('Test Host 1')).toBeTruthy());
|
||||
|
||||
// Select hosts
|
||||
const selectAll = screen.getAllByRole('checkbox')[0];
|
||||
await userEvent.click(selectAll);
|
||||
await waitFor(() => expect(screen.getByText('Bulk Apply')).toBeTruthy());
|
||||
|
||||
// Open Bulk Apply modal
|
||||
await userEvent.click(screen.getByText('Bulk Apply'));
|
||||
await waitFor(() => expect(screen.getByText('Bulk Apply Settings')).toBeTruthy());
|
||||
|
||||
// Enable first setting checkbox (Force SSL)
|
||||
// Enable first setting checkbox (Force SSL) - locate by text then find the checkbox inside its container
|
||||
const forceLabel = screen.getByText(/Force SSL/i) as HTMLElement;
|
||||
let forceContainer: HTMLElement | null = forceLabel;
|
||||
while (forceContainer && !forceContainer.querySelector('input[type="checkbox"]')) {
|
||||
forceContainer = forceContainer.parentElement
|
||||
}
|
||||
const forceCheckbox = forceContainer ? (forceContainer.querySelector('input[type="checkbox"]') as HTMLElement | null) : null;
|
||||
if (forceCheckbox) await userEvent.click(forceCheckbox as HTMLElement);
|
||||
|
||||
// Click Apply (scope to modal to avoid matching header 'Bulk Apply' button)
|
||||
const modalRoot = screen.getByText('Bulk Apply Settings').closest('div');
|
||||
const { within } = await import('@testing-library/react');
|
||||
const applyButton = modalRoot ? within(modalRoot).getByRole('button', { name: /^Apply$/i }) : screen.getByRole('button', { name: /^Apply$/i });
|
||||
await userEvent.click(applyButton);
|
||||
|
||||
// Should call updateProxyHost for each selected host with merged payload containing ssl_forced
|
||||
await waitFor(() => {
|
||||
expect(updateMock).toHaveBeenCalled();
|
||||
const calls = updateMock.mock.calls;
|
||||
expect(calls.length).toBe(2);
|
||||
expect(calls[0][1]).toHaveProperty('ssl_forced');
|
||||
expect(calls[1][1]).toHaveProperty('ssl_forced');
|
||||
});
|
||||
});
|
||||
|
||||
it('cancels bulk apply modal when Cancel clicked', async () => {
|
||||
renderWithProviders(<ProxyHosts />);
|
||||
await waitFor(() => expect(screen.getByText('Test Host 1')).toBeTruthy());
|
||||
const selectAll = screen.getAllByRole('checkbox')[0];
|
||||
await userEvent.click(selectAll);
|
||||
await waitFor(() => expect(screen.getByText('Bulk Apply')).toBeTruthy());
|
||||
await userEvent.click(screen.getByText('Bulk Apply'));
|
||||
await waitFor(() => expect(screen.getByText('Bulk Apply Settings')).toBeTruthy());
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Cancel' }));
|
||||
await waitFor(() => expect(screen.queryByText('Bulk Apply Settings')).toBeNull());
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,173 @@
|
||||
import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest'
|
||||
import { render, screen, waitFor, within } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { act } from 'react'
|
||||
import type { ProxyHost } from '../../api/proxyHosts'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
|
||||
// We'll use per-test module mocks via `vi.doMock` and dynamic imports to avoid
|
||||
// leaking mocks into other tests. Each test creates its own QueryClient.
|
||||
|
||||
describe('ProxyHosts page - coverage targets (isolated)', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules()
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
const renderPage = async () => {
|
||||
// Dynamic mocks
|
||||
const mockUpdateHost = vi.fn()
|
||||
|
||||
vi.doMock('react-hot-toast', () => ({ toast: { success: vi.fn(), error: vi.fn(), loading: vi.fn(), dismiss: vi.fn() } }))
|
||||
|
||||
vi.doMock('../../hooks/useProxyHosts', () => ({
|
||||
useProxyHosts: vi.fn(() => ({
|
||||
hosts: [
|
||||
{
|
||||
uuid: 'host-1',
|
||||
name: 'StagingHost',
|
||||
domain_names: 'staging.example.com',
|
||||
forward_scheme: 'http',
|
||||
forward_host: '10.0.0.1',
|
||||
forward_port: 80,
|
||||
ssl_forced: true,
|
||||
websocket_support: true,
|
||||
certificate: undefined,
|
||||
enabled: true,
|
||||
created_at: '2025-01-01',
|
||||
updated_at: '2025-01-01',
|
||||
},
|
||||
{
|
||||
uuid: 'host-2',
|
||||
name: 'CustomCertHost',
|
||||
domain_names: 'custom.example.com',
|
||||
forward_scheme: 'http',
|
||||
forward_host: '10.0.0.2',
|
||||
forward_port: 8080,
|
||||
ssl_forced: false,
|
||||
websocket_support: false,
|
||||
certificate: { provider: 'custom', name: 'ACME-CUSTOM' },
|
||||
enabled: false,
|
||||
created_at: '2025-01-01',
|
||||
updated_at: '2025-01-01',
|
||||
}
|
||||
],
|
||||
loading: false,
|
||||
isFetching: false,
|
||||
error: null,
|
||||
createHost: vi.fn(),
|
||||
updateHost: (uuid: string, data: Partial<ProxyHost>) => mockUpdateHost(uuid, data),
|
||||
deleteHost: vi.fn(),
|
||||
bulkUpdateACL: vi.fn(),
|
||||
isBulkUpdating: false,
|
||||
}))
|
||||
}))
|
||||
|
||||
vi.doMock('../../hooks/useCertificates', () => ({
|
||||
useCertificates: vi.fn(() => ({
|
||||
certificates: [
|
||||
{ id: 1, name: 'StagingCert', domain: 'staging.example.com', status: 'untrusted', provider: 'letsencrypt-staging' }
|
||||
],
|
||||
isLoading: false,
|
||||
error: null,
|
||||
}))
|
||||
}))
|
||||
|
||||
vi.doMock('../../hooks/useAccessLists', () => ({ useAccessLists: vi.fn(() => ({ data: [] })) }))
|
||||
|
||||
vi.doMock('../../api/settings', () => ({ getSettings: vi.fn(() => Promise.resolve({ 'ui.domain_link_behavior': 'new_window' })) }))
|
||||
|
||||
// Import page after mocks are in place
|
||||
const { default: ProxyHosts } = await import('../ProxyHosts')
|
||||
|
||||
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } })
|
||||
const wrapper = (ui: React.ReactNode) => (
|
||||
<QueryClientProvider client={qc}>{ui}</QueryClientProvider>
|
||||
)
|
||||
|
||||
return { ProxyHosts, mockUpdateHost, wrapper }
|
||||
}
|
||||
|
||||
it('renders SSL staging badge, websocket badge and custom cert text', async () => {
|
||||
const { ProxyHosts } = await renderPage()
|
||||
|
||||
render(
|
||||
<QueryClientProvider client={new QueryClient({ defaultOptions: { queries: { retry: false } } })}>
|
||||
<ProxyHosts />
|
||||
</QueryClientProvider>
|
||||
)
|
||||
|
||||
await waitFor(() => expect(screen.getByText('StagingHost')).toBeInTheDocument())
|
||||
|
||||
expect(screen.getByText(/SSL \(Staging\)/)).toBeInTheDocument()
|
||||
expect(screen.getByText('WS')).toBeInTheDocument()
|
||||
expect(screen.getByText('ACME-CUSTOM (Custom)')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('opens domain link in new window when linkBehavior is new_window', async () => {
|
||||
const { ProxyHosts } = await renderPage()
|
||||
|
||||
const openSpy = vi.spyOn(window, 'open').mockImplementation(() => null)
|
||||
|
||||
render(
|
||||
<QueryClientProvider client={new QueryClient({ defaultOptions: { queries: { retry: false } } })}>
|
||||
<ProxyHosts />
|
||||
</QueryClientProvider>
|
||||
)
|
||||
|
||||
await waitFor(() => expect(screen.getByText('staging.example.com')).toBeInTheDocument())
|
||||
const link = screen.getByText('staging.example.com').closest('a') as HTMLAnchorElement
|
||||
await act(async () => {
|
||||
await userEvent.click(link!)
|
||||
})
|
||||
|
||||
expect(openSpy).toHaveBeenCalled()
|
||||
openSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('bulk apply merges host data and calls updateHost', async () => {
|
||||
const { ProxyHosts, mockUpdateHost } = await renderPage()
|
||||
|
||||
render(
|
||||
<QueryClientProvider client={new QueryClient({ defaultOptions: { queries: { retry: false } } })}>
|
||||
<ProxyHosts />
|
||||
</QueryClientProvider>
|
||||
)
|
||||
|
||||
await waitFor(() => expect(screen.getByText('StagingHost')).toBeInTheDocument())
|
||||
|
||||
const selectBtn1 = screen.getByLabelText('Select StagingHost')
|
||||
const selectBtn2 = screen.getByLabelText('Select CustomCertHost')
|
||||
await userEvent.click(selectBtn1)
|
||||
await userEvent.click(selectBtn2)
|
||||
|
||||
const bulkBtn = screen.getByText('Bulk Apply')
|
||||
await userEvent.click(bulkBtn)
|
||||
|
||||
const modal = screen.getByText('Bulk Apply Settings').closest('div')!
|
||||
const modalWithin = within(modal)
|
||||
|
||||
const checkboxes = modal.querySelectorAll('input[type="checkbox"]')
|
||||
expect(checkboxes.length).toBeGreaterThan(0)
|
||||
await userEvent.click(checkboxes[0])
|
||||
|
||||
const applyBtn = modalWithin.getByRole('button', { name: /Apply/ })
|
||||
await userEvent.click(applyBtn)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUpdateHost).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
const calls = vi.mocked(mockUpdateHost).mock.calls
|
||||
expect(calls.length).toBeGreaterThanOrEqual(1)
|
||||
const [calledUuid, calledData] = calls[0]
|
||||
expect(typeof calledUuid).toBe('string')
|
||||
expect(Object.prototype.hasOwnProperty.call(calledData, 'ssl_forced')).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
export {}
|
||||
@@ -4,16 +4,20 @@ import { act } from 'react'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { MemoryRouter } from 'react-router-dom'
|
||||
import { vi, describe, it, expect, beforeEach } from 'vitest'
|
||||
import type { ProxyHost } from '../../api/proxyHosts'
|
||||
import ProxyHosts from '../ProxyHosts'
|
||||
import { formatSettingLabel, settingHelpText, settingKeyToField, applyBulkSettingsToHosts } from '../../utils/proxyHostsHelpers'
|
||||
import * as proxyHostsApi from '../../api/proxyHosts'
|
||||
import * as certificatesApi from '../../api/certificates'
|
||||
import * as accessListsApi from '../../api/accessLists'
|
||||
import type { AccessList } from '../../api/accessLists'
|
||||
import * as settingsApi from '../../api/settings'
|
||||
import * as uptimeApi from '../../api/uptime'
|
||||
// Certificate type not required in this spec
|
||||
import type { UptimeMonitor } from '../../api/uptime'
|
||||
// toast is mocked in other tests; not used here
|
||||
|
||||
vi.mock('react-hot-toast', () => ({
|
||||
toast: { success: vi.fn(), error: vi.fn(), loading: vi.fn(), dismiss: vi.fn() },
|
||||
}))
|
||||
vi.mock('react-hot-toast', () => ({ toast: { success: vi.fn(), error: vi.fn(), loading: vi.fn(), dismiss: vi.fn() } }))
|
||||
|
||||
vi.mock('../../api/proxyHosts', () => ({
|
||||
getProxyHosts: vi.fn(),
|
||||
@@ -28,6 +32,7 @@ vi.mock('../../api/certificates', () => ({ getCertificates: vi.fn() }))
|
||||
vi.mock('../../api/accessLists', () => ({ accessListsApi: { list: vi.fn() } }))
|
||||
vi.mock('../../api/settings', () => ({ getSettings: vi.fn() }))
|
||||
vi.mock('../../api/backups', () => ({ createBackup: vi.fn() }))
|
||||
vi.mock('../../api/uptime', () => ({ getMonitors: vi.fn() }))
|
||||
|
||||
const createQueryClient = () => new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: 0 }, mutations: { retry: false } } })
|
||||
|
||||
@@ -42,7 +47,7 @@ const renderWithProviders = (ui: React.ReactNode) => {
|
||||
|
||||
import { createMockProxyHost } from '../../testUtils/createMockProxyHost'
|
||||
|
||||
const baseHost = (overrides: any = {}) => createMockProxyHost(overrides)
|
||||
const baseHost = (overrides: Partial<ProxyHost> = {}) => createMockProxyHost(overrides)
|
||||
|
||||
describe('ProxyHosts - Coverage enhancements', () => {
|
||||
beforeEach(() => vi.clearAllMocks())
|
||||
@@ -79,7 +84,7 @@ describe('ProxyHosts - Coverage enhancements', () => {
|
||||
certificate: null,
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
} as any)
|
||||
} as ProxyHost)
|
||||
vi.mocked(certificatesApi.getCertificates).mockResolvedValue([])
|
||||
vi.mocked(accessListsApi.accessListsApi.list).mockResolvedValue([])
|
||||
vi.mocked(settingsApi.getSettings).mockResolvedValue({})
|
||||
@@ -151,7 +156,7 @@ describe('ProxyHosts - Coverage enhancements', () => {
|
||||
vi.mocked(certificatesApi.getCertificates).mockResolvedValue([])
|
||||
vi.mocked(accessListsApi.accessListsApi.list).mockResolvedValue([
|
||||
{ id: 1, uuid: 'acl-1', name: 'List1', description: 'List 1', type: 'whitelist' as const, ip_rules: '[]', country_codes: '', local_network_only: false, enabled: true, created_at: '2025-01-01', updated_at: '2025-01-01' },
|
||||
] as any)
|
||||
] as AccessList[])
|
||||
vi.mocked(settingsApi.getSettings).mockResolvedValue({})
|
||||
vi.mocked(proxyHostsApi.bulkUpdateACL).mockRejectedValue(new Error('Bad things'))
|
||||
|
||||
@@ -251,7 +256,7 @@ describe('ProxyHosts - Coverage enhancements', () => {
|
||||
vi.mocked(certificatesApi.getCertificates).mockResolvedValue([])
|
||||
vi.mocked(accessListsApi.accessListsApi.list).mockResolvedValue([
|
||||
{ id: 1, uuid: 'acl-1', name: 'List1', description: 'List 1', type: 'whitelist', enabled: true, ip_rules: '[]', country_codes: '', local_network_only: false, created_at: '2025-01-01', updated_at: '2025-01-01' },
|
||||
] as any)
|
||||
] as AccessList[])
|
||||
vi.mocked(settingsApi.getSettings).mockResolvedValue({})
|
||||
|
||||
renderWithProviders(<ProxyHosts />)
|
||||
@@ -275,7 +280,7 @@ describe('ProxyHosts - Coverage enhancements', () => {
|
||||
vi.mocked(certificatesApi.getCertificates).mockResolvedValue([])
|
||||
vi.mocked(accessListsApi.accessListsApi.list).mockResolvedValue([
|
||||
{ id: 1, uuid: 'acl-1', name: 'List1', description: 'List 1', type: 'whitelist' as const, ip_rules: '[]', country_codes: '', local_network_only: false, enabled: true, created_at: '2025-01-01', updated_at: '2025-01-01' },
|
||||
] as any)
|
||||
] as AccessList[])
|
||||
vi.mocked(settingsApi.getSettings).mockResolvedValue({})
|
||||
|
||||
renderWithProviders(<ProxyHosts />)
|
||||
@@ -303,7 +308,7 @@ describe('ProxyHosts - Coverage enhancements', () => {
|
||||
vi.mocked(certificatesApi.getCertificates).mockResolvedValue([])
|
||||
vi.mocked(accessListsApi.accessListsApi.list).mockResolvedValue([
|
||||
{ id: 1, uuid: 'acl-1', name: 'List1', description: 'List 1', type: 'whitelist' as const, ip_rules: '[]', country_codes: '', local_network_only: false, enabled: true, created_at: '2025-01-01', updated_at: '2025-01-01' },
|
||||
] as any)
|
||||
] as AccessList[])
|
||||
vi.mocked(settingsApi.getSettings).mockResolvedValue({})
|
||||
vi.mocked(proxyHostsApi.bulkUpdateACL).mockResolvedValue({ updated: 2, errors: [] })
|
||||
|
||||
@@ -331,7 +336,7 @@ describe('ProxyHosts - Coverage enhancements', () => {
|
||||
vi.mocked(certificatesApi.getCertificates).mockResolvedValue([])
|
||||
vi.mocked(accessListsApi.accessListsApi.list).mockResolvedValue([
|
||||
{ id: 1, uuid: 'acl-1', name: 'List1', description: 'List 1', type: 'whitelist' as const, ip_rules: '[]', country_codes: '', local_network_only: false, enabled: true, created_at: '2025-01-01', updated_at: '2025-01-01' },
|
||||
] as any)
|
||||
] as AccessList[])
|
||||
vi.mocked(settingsApi.getSettings).mockResolvedValue({})
|
||||
|
||||
renderWithProviders(<ProxyHosts />)
|
||||
@@ -360,7 +365,7 @@ describe('ProxyHosts - Coverage enhancements', () => {
|
||||
vi.mocked(certificatesApi.getCertificates).mockResolvedValue([])
|
||||
vi.mocked(accessListsApi.accessListsApi.list).mockResolvedValue([
|
||||
{ id: 1, uuid: 'acl-1', name: 'List1', description: 'List 1', type: 'whitelist' as const, ip_rules: '[]', country_codes: '', local_network_only: false, enabled: true, created_at: '2025-01-01', updated_at: '2025-01-01' },
|
||||
] as any)
|
||||
] as AccessList[])
|
||||
vi.mocked(settingsApi.getSettings).mockResolvedValue({})
|
||||
vi.mocked(proxyHostsApi.bulkUpdateACL).mockResolvedValue({ updated: 1, errors: [{ uuid: 's2', error: 'Bad' }] })
|
||||
|
||||
@@ -386,7 +391,7 @@ describe('ProxyHosts - Coverage enhancements', () => {
|
||||
vi.mocked(certificatesApi.getCertificates).mockResolvedValue([])
|
||||
vi.mocked(accessListsApi.accessListsApi.list).mockResolvedValue([
|
||||
{ id: 1, uuid: 'acl-1', name: 'List1', description: 'List 1', type: 'whitelist' as const, ip_rules: '[]', country_codes: '', local_network_only: false, enabled: true, created_at: '2025-01-01', updated_at: '2025-01-01' },
|
||||
] as any)
|
||||
] as AccessList[])
|
||||
vi.mocked(settingsApi.getSettings).mockResolvedValue({})
|
||||
vi.mocked(proxyHostsApi.bulkUpdateACL).mockRejectedValue(new Error('Bulk fail'))
|
||||
|
||||
@@ -459,7 +464,7 @@ describe('ProxyHosts - Coverage enhancements', () => {
|
||||
})
|
||||
|
||||
it('renders SSL states: custom, staging, letsencrypt variations', async () => {
|
||||
const hostCustom = baseHost({ uuid: 'c1', name: 'Custom', domain_names: 'custom.com', ssl_forced: true, certificate: { provider: 'custom', name: 'CustomCert' } })
|
||||
const hostCustom = baseHost({ uuid: 'c1', name: 'Custom', domain_names: 'custom.com', ssl_forced: true, certificate: { id: 123, uuid: 'cert-1', name: 'CustomCert', provider: 'custom', domains: 'custom.com', expires_at: '2026-01-01' } })
|
||||
const hostStaging = baseHost({ uuid: 's1', name: 'Staging', domain_names: 'staging.com', ssl_forced: true })
|
||||
const hostAuto = baseHost({ uuid: 'a1', name: 'Auto', domain_names: 'auto.com', ssl_forced: true })
|
||||
const hostLets = baseHost({ uuid: 'l1', name: 'Lets', domain_names: 'lets.com', ssl_forced: true })
|
||||
@@ -542,6 +547,87 @@ describe('ProxyHosts - Coverage enhancements', () => {
|
||||
confirmSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('deletes associated uptime monitors when confirmed', async () => {
|
||||
const host = baseHost({ uuid: 'del2', name: 'Del2', forward_host: '127.0.0.5' })
|
||||
vi.mocked(proxyHostsApi.getProxyHosts).mockResolvedValue([host])
|
||||
vi.mocked(proxyHostsApi.deleteProxyHost).mockResolvedValue()
|
||||
vi.mocked(certificatesApi.getCertificates).mockResolvedValue([])
|
||||
vi.mocked(accessListsApi.accessListsApi.list).mockResolvedValue([])
|
||||
vi.mocked(settingsApi.getSettings).mockResolvedValue({})
|
||||
// uptime monitors associated with host
|
||||
vi.mocked(uptimeApi.getMonitors).mockResolvedValue([{ id: 'm1', name: 'm1', url: 'http://example', type: 'http', interval: 60, enabled: true, status: 'up', last_check: new Date().toISOString(), latency: 10, max_retries: 3, upstream_host: '127.0.0.5' } as UptimeMonitor])
|
||||
|
||||
const confirmSpy = vi.spyOn(window, 'confirm').mockImplementation(() => true)
|
||||
renderWithProviders(<ProxyHosts />)
|
||||
await waitFor(() => expect(screen.getByText('Del2')).toBeTruthy())
|
||||
const row = screen.getByText('Del2').closest('tr') as HTMLTableRowElement
|
||||
const delButton = within(row).getByText('Delete')
|
||||
await userEvent.click(delButton)
|
||||
// Should call delete with deleteUptime true
|
||||
await waitFor(() => expect(proxyHostsApi.deleteProxyHost).toHaveBeenCalledWith('del2', true))
|
||||
confirmSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('ignores uptime API errors and deletes host without deleting uptime', async () => {
|
||||
const host = baseHost({ uuid: 'del3', name: 'Del3', forward_host: '127.0.0.6' })
|
||||
vi.mocked(proxyHostsApi.getProxyHosts).mockResolvedValue([host])
|
||||
vi.mocked(proxyHostsApi.deleteProxyHost).mockResolvedValue()
|
||||
vi.mocked(certificatesApi.getCertificates).mockResolvedValue([])
|
||||
vi.mocked(accessListsApi.accessListsApi.list).mockResolvedValue([])
|
||||
vi.mocked(settingsApi.getSettings).mockResolvedValue({})
|
||||
// Make getMonitors throw
|
||||
vi.mocked(uptimeApi.getMonitors).mockRejectedValue(new Error('OOPS'))
|
||||
|
||||
const confirmSpy = vi.spyOn(window, 'confirm').mockImplementation(() => true)
|
||||
renderWithProviders(<ProxyHosts />)
|
||||
await waitFor(() => expect(screen.getByText('Del3')).toBeTruthy())
|
||||
const row = screen.getByText('Del3').closest('tr') as HTMLTableRowElement
|
||||
const delButton = within(row).getByText('Delete')
|
||||
await userEvent.click(delButton)
|
||||
// Should call delete without second param
|
||||
await waitFor(() => expect(proxyHostsApi.deleteProxyHost).toHaveBeenCalledWith('del3'))
|
||||
confirmSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('applies bulk settings sequentially with progress and updates hosts', async () => {
|
||||
vi.mocked(proxyHostsApi.getProxyHosts).mockResolvedValue([
|
||||
baseHost({ uuid: 'host-1', name: 'H1' }),
|
||||
baseHost({ uuid: 'host-2', name: 'H2' }),
|
||||
])
|
||||
vi.mocked(certificatesApi.getCertificates).mockResolvedValue([])
|
||||
vi.mocked(accessListsApi.accessListsApi.list).mockResolvedValue([])
|
||||
vi.mocked(settingsApi.getSettings).mockResolvedValue({})
|
||||
vi.mocked(proxyHostsApi.updateProxyHost).mockResolvedValue({} as ProxyHost)
|
||||
|
||||
renderWithProviders(<ProxyHosts />)
|
||||
await waitFor(() => expect(screen.getByText('H1')).toBeTruthy())
|
||||
|
||||
// Select both hosts
|
||||
const headerCheckbox = screen.getAllByRole('checkbox')[0]
|
||||
await userEvent.click(headerCheckbox)
|
||||
|
||||
// Open Bulk Apply modal
|
||||
await userEvent.click(screen.getByText('Bulk Apply'))
|
||||
await waitFor(() => expect(screen.getByText('Bulk Apply Settings')).toBeTruthy())
|
||||
|
||||
// In the modal, find Force SSL row and enable apply and set value true
|
||||
const forceLabel = screen.getByText('Force SSL')
|
||||
const rowEl = forceLabel.closest('.p-2') as HTMLElement || forceLabel.closest('div') as HTMLElement
|
||||
// Use within to find checkboxes within this row for robust selection
|
||||
const rowCheckboxes = within(rowEl).getAllByRole('checkbox', { hidden: true })
|
||||
if (rowCheckboxes.length >= 1) await userEvent.click(rowCheckboxes[0])
|
||||
|
||||
// Click Apply in the modal (narrow to modal scope)
|
||||
const modal = screen.getByText('Bulk Apply Settings').closest('div') as HTMLElement
|
||||
const applyBtn = within(modal).getByRole('button', { name: /Apply/i })
|
||||
await userEvent.click(applyBtn)
|
||||
|
||||
// Expect updateProxyHost called for each host with ssl_forced true included in payload
|
||||
await waitFor(() => expect(proxyHostsApi.updateProxyHost).toHaveBeenCalledTimes(2))
|
||||
const calls = vi.mocked(proxyHostsApi.updateProxyHost).mock.calls
|
||||
expect(calls.some(call => call[1] && (call[1] as Partial<ProxyHost>).ssl_forced === true)).toBeTruthy()
|
||||
})
|
||||
|
||||
it('shows Unnamed when name missing', async () => {
|
||||
const hostNoName = baseHost({ uuid: 'n1', name: '', domain_names: 'no-name.com' })
|
||||
vi.mocked(proxyHostsApi.getProxyHosts).mockResolvedValue([hostNoName])
|
||||
@@ -668,7 +754,7 @@ describe('ProxyHosts - Coverage enhancements', () => {
|
||||
vi.mocked(accessListsApi.accessListsApi.list).mockResolvedValue([
|
||||
{ id: 1, uuid: 'acl-a1', name: 'A1', description: 'A1', type: 'whitelist' as const, ip_rules: '[]', country_codes: '', local_network_only: false, enabled: true, created_at: '2025-01-01', updated_at: '2025-01-01' },
|
||||
{ id: 2, uuid: 'acl-a2', name: 'A2', description: 'A2', type: 'whitelist' as const, ip_rules: '[]', country_codes: '', local_network_only: false, enabled: true, created_at: '2025-01-01', updated_at: '2025-01-01' },
|
||||
] as any)
|
||||
] as AccessList[])
|
||||
vi.mocked(settingsApi.getSettings).mockResolvedValue({})
|
||||
vi.mocked(proxyHostsApi.bulkUpdateACL).mockResolvedValue({ updated: 2, errors: [] })
|
||||
renderWithProviders(<ProxyHosts />)
|
||||
@@ -707,7 +793,7 @@ describe('ProxyHosts - Coverage enhancements', () => {
|
||||
vi.mocked(accessListsApi.accessListsApi.list).mockResolvedValue([
|
||||
{ id: 1, uuid: 'acl-1', name: 'List1', description: 'List 1', type: 'whitelist' as const, ip_rules: '[]', country_codes: '', local_network_only: false, enabled: true, created_at: '2025-01-01', updated_at: '2025-01-01' },
|
||||
{ id: 2, uuid: 'acl-2', name: 'List2', description: 'List 2', type: 'whitelist' as const, ip_rules: '[]', country_codes: '', local_network_only: false, enabled: true, created_at: '2025-01-01', updated_at: '2025-01-01' },
|
||||
] as any)
|
||||
] as AccessList[])
|
||||
vi.mocked(settingsApi.getSettings).mockResolvedValue({})
|
||||
|
||||
renderWithProviders(<ProxyHosts />)
|
||||
@@ -744,7 +830,7 @@ describe('ProxyHosts - Coverage enhancements', () => {
|
||||
vi.mocked(accessListsApi.accessListsApi.list).mockResolvedValue([
|
||||
{ id: 1, uuid: 'acl-disable1', name: 'Disabled1', description: 'Disabled 1', type: 'blacklist' as const, ip_rules: '[]', country_codes: '', local_network_only: false, enabled: false, created_at: '2025-01-01', updated_at: '2025-01-01' },
|
||||
{ id: 2, uuid: 'acl-disable2', name: 'Disabled2', description: 'Disabled 2', type: 'whitelist' as const, ip_rules: '[]', country_codes: '', local_network_only: false, enabled: false, created_at: '2025-01-01', updated_at: '2025-01-01' },
|
||||
] as any)
|
||||
] as AccessList[])
|
||||
vi.mocked(settingsApi.getSettings).mockResolvedValue({})
|
||||
|
||||
renderWithProviders(<ProxyHosts />)
|
||||
@@ -758,6 +844,120 @@ describe('ProxyHosts - Coverage enhancements', () => {
|
||||
// Should show the 'No enabled access lists available' message
|
||||
await waitFor(() => expect(screen.getByText('No enabled access lists available')).toBeTruthy())
|
||||
})
|
||||
|
||||
it('formatSettingLabel, settingHelpText and settingKeyToField return expected values and defaults', () => {
|
||||
expect(formatSettingLabel('ssl_forced')).toBe('Force SSL')
|
||||
expect(formatSettingLabel('http2_support')).toBe('HTTP/2 Support')
|
||||
expect(formatSettingLabel('hsts_enabled')).toBe('HSTS Enabled')
|
||||
expect(formatSettingLabel('hsts_subdomains')).toBe('HSTS Subdomains')
|
||||
expect(formatSettingLabel('block_exploits')).toBe('Block Exploits')
|
||||
expect(formatSettingLabel('websocket_support')).toBe('Websockets Support')
|
||||
expect(formatSettingLabel('unknown_key')).toBe('unknown_key')
|
||||
|
||||
expect(settingHelpText('ssl_forced')).toContain('Redirect all HTTP traffic')
|
||||
expect(settingHelpText('http2_support')).toContain('Enable HTTP/2')
|
||||
expect(settingHelpText('hsts_enabled')).toContain('Send HSTS header')
|
||||
expect(settingHelpText('hsts_subdomains')).toContain('Include subdomains')
|
||||
expect(settingHelpText('block_exploits')).toContain('Add common exploit-mitigation')
|
||||
expect(settingHelpText('websocket_support')).toContain('Enable websocket proxying')
|
||||
expect(settingHelpText('unknown_key')).toBe('')
|
||||
|
||||
expect(settingKeyToField('ssl_forced')).toBe('ssl_forced')
|
||||
expect(settingKeyToField('http2_support')).toBe('http2_support')
|
||||
expect(settingKeyToField('hsts_enabled')).toBe('hsts_enabled')
|
||||
expect(settingKeyToField('hsts_subdomains')).toBe('hsts_subdomains')
|
||||
expect(settingKeyToField('block_exploits')).toBe('block_exploits')
|
||||
expect(settingKeyToField('websocket_support')).toBe('websocket_support')
|
||||
expect(settingKeyToField('unknown_key')).toBe('unknown_key')
|
||||
})
|
||||
|
||||
it('closes bulk apply modal when clicking backdrop', async () => {
|
||||
vi.mocked(proxyHostsApi.getProxyHosts).mockResolvedValue([
|
||||
baseHost({ uuid: 's1', name: 'S1' }),
|
||||
])
|
||||
vi.mocked(certificatesApi.getCertificates).mockResolvedValue([])
|
||||
vi.mocked(accessListsApi.accessListsApi.list).mockResolvedValue([])
|
||||
vi.mocked(settingsApi.getSettings).mockResolvedValue({})
|
||||
|
||||
renderWithProviders(<ProxyHosts />)
|
||||
await waitFor(() => expect(screen.getByText('S1')).toBeTruthy())
|
||||
const headerCheckbox = screen.getAllByRole('checkbox')[0]
|
||||
const user = userEvent.setup()
|
||||
await user.click(headerCheckbox)
|
||||
await user.click(screen.getByText('Bulk Apply'))
|
||||
await waitFor(() => expect(screen.getByText('Bulk Apply Settings')).toBeTruthy())
|
||||
// click backdrop
|
||||
const overlay = document.querySelector('.fixed.inset-0')
|
||||
if (overlay) await user.click(overlay)
|
||||
await waitFor(() => expect(screen.queryByText('Bulk Apply Settings')).toBeNull())
|
||||
})
|
||||
|
||||
it('shows toast error when updateHost rejects during bulk apply', async () => {
|
||||
const h1 = baseHost({ uuid: 'host-1', name: 'H1' })
|
||||
const h2 = baseHost({ uuid: 'host-2', name: 'H2' })
|
||||
vi.mocked(proxyHostsApi.getProxyHosts).mockResolvedValue([h1, h2])
|
||||
vi.mocked(certificatesApi.getCertificates).mockResolvedValue([])
|
||||
vi.mocked(accessListsApi.accessListsApi.list).mockResolvedValue([])
|
||||
vi.mocked(settingsApi.getSettings).mockResolvedValue({})
|
||||
// mock updateProxyHost to fail for host-2
|
||||
vi.mocked(proxyHostsApi.updateProxyHost).mockImplementation(async (uuid: string) => {
|
||||
if (uuid === 'host-2') throw new Error('update fail')
|
||||
const result = baseHost({ uuid })
|
||||
return result
|
||||
})
|
||||
|
||||
renderWithProviders(<ProxyHosts />)
|
||||
await waitFor(() => expect(screen.getByText('H1')).toBeTruthy())
|
||||
// select both
|
||||
const headerCheckbox = screen.getAllByRole('checkbox')[0]
|
||||
const user = userEvent.setup()
|
||||
await user.click(headerCheckbox)
|
||||
// Open Bulk Apply
|
||||
await user.click(screen.getByText('Bulk Apply'))
|
||||
await waitFor(() => expect(screen.getByText('Bulk Apply Settings')).toBeTruthy())
|
||||
// enable Force SSL apply + set switch
|
||||
const forceLabel = screen.getByText('Force SSL')
|
||||
const rowEl = forceLabel.closest('.p-2') as HTMLElement || forceLabel.closest('div') as HTMLElement
|
||||
// click apply checkbox and toggle switch reliably
|
||||
const rowChecks = within(rowEl).getAllByRole('checkbox', { hidden: true })
|
||||
if (rowChecks[0]) await user.click(rowChecks[0])
|
||||
if (rowChecks[1]) await user.click(rowChecks[1])
|
||||
// click Apply
|
||||
const modal = screen.getByText('Bulk Apply Settings').closest('div') as HTMLElement
|
||||
const applyBtn = within(modal).getByRole('button', { name: /Apply/i })
|
||||
await user.click(applyBtn)
|
||||
|
||||
const toast = (await import('react-hot-toast')).toast
|
||||
await waitFor(() => expect(toast.error).toHaveBeenCalled())
|
||||
})
|
||||
|
||||
it('applyBulkSettingsToHosts returns error when host is not found and reports progress', async () => {
|
||||
const hosts: ProxyHost[] = [] // no hosts
|
||||
const hostUUIDs = ['missing-1']
|
||||
const keysToApply = ['ssl_forced']
|
||||
const bulkApplySettings: Record<string, { apply: boolean; value: boolean }> = { ssl_forced: { apply: true, value: true } }
|
||||
const updateHost = vi.fn().mockResolvedValue({})
|
||||
const setApplyProgress = vi.fn()
|
||||
|
||||
const result = await applyBulkSettingsToHosts({ hosts, hostUUIDs, keysToApply, bulkApplySettings, updateHost, setApplyProgress })
|
||||
expect(result.errors).toBe(1)
|
||||
expect(setApplyProgress).toHaveBeenCalled()
|
||||
expect(updateHost).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('applyBulkSettingsToHosts handles updateHost rejection and counts errors', async () => {
|
||||
const h1 = baseHost({ uuid: 'h1', name: 'H1' })
|
||||
const hosts = [h1]
|
||||
const hostUUIDs = ['h1']
|
||||
const keysToApply = ['ssl_forced']
|
||||
const bulkApplySettings: Record<string, { apply: boolean; value: boolean }> = { ssl_forced: { apply: true, value: true } }
|
||||
const updateHost = vi.fn().mockRejectedValue(new Error('fail'))
|
||||
const setApplyProgress = vi.fn()
|
||||
|
||||
const result = await applyBulkSettingsToHosts({ hosts, hostUUIDs, keysToApply, bulkApplySettings, updateHost, setApplyProgress })
|
||||
expect(result.errors).toBe(1)
|
||||
expect(updateHost).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
export {}
|
||||
|
||||
@@ -3,6 +3,7 @@ import userEvent from '@testing-library/user-event'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { MemoryRouter } from 'react-router-dom'
|
||||
import { vi, describe, it, expect, beforeEach } from 'vitest'
|
||||
import type { ProxyHost, BulkUpdateACLResponse } from '../../api/proxyHosts'
|
||||
import ProxyHosts from '../ProxyHosts'
|
||||
import * as proxyHostsApi from '../../api/proxyHosts'
|
||||
import * as certificatesApi from '../../api/certificates'
|
||||
@@ -37,7 +38,7 @@ const renderWithProviders = (ui: React.ReactNode) => {
|
||||
)
|
||||
}
|
||||
|
||||
const baseHost = (overrides: any = {}) => ({
|
||||
const baseHost = (overrides: Partial<ProxyHost> = {}): ProxyHost => ({
|
||||
uuid: 'host-1',
|
||||
name: 'Host',
|
||||
domain_names: 'example.com',
|
||||
@@ -47,7 +48,17 @@ const baseHost = (overrides: any = {}) => ({
|
||||
enabled: true,
|
||||
ssl_forced: false,
|
||||
websocket_support: false,
|
||||
http2_support: false,
|
||||
hsts_enabled: false,
|
||||
hsts_subdomains: false,
|
||||
block_exploits: false,
|
||||
application: 'none',
|
||||
locations: [],
|
||||
certificate: null,
|
||||
certificate_id: null,
|
||||
access_list_id: null,
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
...overrides,
|
||||
})
|
||||
|
||||
@@ -68,9 +79,11 @@ describe('ProxyHosts progress apply', () => {
|
||||
vi.mocked(settingsApi.getSettings).mockResolvedValue({})
|
||||
|
||||
// Create controllable promises for bulkUpdateACL invocations
|
||||
const resolvers: Array<(value?: any) => void> = []
|
||||
vi.mocked(proxyHostsApi.bulkUpdateACL).mockImplementation((_hostUUIDs, _aclId) => {
|
||||
return new Promise((resolve) => { resolvers.push(resolve) })
|
||||
const resolvers: Array<(value: BulkUpdateACLResponse) => void> = []
|
||||
vi.mocked(proxyHostsApi.bulkUpdateACL).mockImplementation((...args: unknown[]) => {
|
||||
const [_hostUUIDs, _aclId] = args
|
||||
void _hostUUIDs; void _aclId
|
||||
return new Promise((resolve: (v: BulkUpdateACLResponse) => void) => { resolvers.push(resolve); })
|
||||
})
|
||||
|
||||
renderWithProviders(<ProxyHosts />)
|
||||
|
||||
51
frontend/src/pages/__tests__/Uptime.spec.tsx
Normal file
51
frontend/src/pages/__tests__/Uptime.spec.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { render, screen, waitFor, within } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import Uptime from '../Uptime'
|
||||
import * as uptimeApi from '../../api/uptime'
|
||||
|
||||
vi.mock('react-hot-toast', () => ({ toast: { success: vi.fn(), error: vi.fn(), loading: vi.fn(), dismiss: vi.fn() } }))
|
||||
vi.mock('../../api/uptime')
|
||||
|
||||
const createQueryClient = () => new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } })
|
||||
|
||||
const renderWithProviders = (ui: React.ReactNode) => {
|
||||
const qc = createQueryClient()
|
||||
return render(
|
||||
<QueryClientProvider client={qc}>
|
||||
{ui}
|
||||
</QueryClientProvider>
|
||||
)
|
||||
}
|
||||
|
||||
describe('Uptime page', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('renders no monitors message', async () => {
|
||||
vi.mocked(uptimeApi.getMonitors).mockResolvedValue([])
|
||||
renderWithProviders(<Uptime />)
|
||||
expect(await screen.findByText(/No monitors found/i)).toBeTruthy()
|
||||
})
|
||||
|
||||
it('calls updateMonitor when toggling monitoring', async () => {
|
||||
const monitor = {
|
||||
id: 'm1', name: 'Test Monitor', url: 'http://example.com', type: 'http', interval: 60, enabled: true,
|
||||
status: 'up', last_check: new Date().toISOString(), latency: 10, max_retries: 3, proxy_host_id: 1,
|
||||
}
|
||||
vi.mocked(uptimeApi.getMonitors).mockResolvedValue([monitor])
|
||||
vi.mocked(uptimeApi.getMonitorHistory).mockResolvedValue([])
|
||||
vi.mocked(uptimeApi.updateMonitor).mockResolvedValue({ ...monitor, enabled: false })
|
||||
|
||||
renderWithProviders(<Uptime />)
|
||||
await waitFor(() => expect(screen.getByText('Test Monitor')).toBeInTheDocument())
|
||||
const card = screen.getByText('Test Monitor').closest('div') as HTMLElement
|
||||
const settingsBtn = within(card).getByTitle('Monitor settings')
|
||||
await userEvent.click(settingsBtn)
|
||||
const toggleBtn = within(card).getByText('Disable Monitoring')
|
||||
await userEvent.click(toggleBtn)
|
||||
await waitFor(() => expect(uptimeApi.updateMonitor).toHaveBeenCalledWith('m1', { enabled: false }))
|
||||
})
|
||||
})
|
||||
@@ -1,3 +1,9 @@
|
||||
// Ensure React's act environment flag is set for React 18+ to avoid warnings
|
||||
// This must be set before importing testing utilities.
|
||||
// See: https://github.com/facebook/react/issues/24560#issuecomment-1021997243
|
||||
declare global { var IS_REACT_ACT_ENVIRONMENT: boolean | undefined }
|
||||
globalThis.IS_REACT_ACT_ENVIRONMENT = true
|
||||
|
||||
import '@testing-library/jest-dom'
|
||||
import { cleanup } from '@testing-library/react'
|
||||
import { afterEach } from 'vitest'
|
||||
@@ -21,3 +27,23 @@ Object.defineProperty(window, 'matchMedia', {
|
||||
dispatchEvent: () => {},
|
||||
}),
|
||||
})
|
||||
|
||||
// Filter noisy React act environment warnings that can appear in some environments
|
||||
const _origConsoleError = console.error
|
||||
console.error = (...args: unknown[]) => {
|
||||
try {
|
||||
const msg = args[0]
|
||||
if (typeof msg === 'string') {
|
||||
if (
|
||||
msg.includes("The current testing environment is not configured to support act(") ||
|
||||
msg.includes('Test connection failed') ||
|
||||
msg.includes('Connection failed')
|
||||
) {
|
||||
return
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// fallthrough to original
|
||||
}
|
||||
_origConsoleError.apply(console, args)
|
||||
}
|
||||
|
||||
@@ -46,7 +46,8 @@ const hostB: ProxyHost = {
|
||||
|
||||
describe('compareHosts', () => {
|
||||
it('returns 0 for unknown sort column (default case)', () => {
|
||||
const res = compareHosts(hostA, hostB, 'unknown' as any, 'asc')
|
||||
const compareAny = compareHosts as unknown as (a: ProxyHost, b: ProxyHost, sortColumn: string, sortDirection: 'asc' | 'desc') => number
|
||||
const res = compareAny(hostA, hostB, 'unknown', 'asc')
|
||||
expect(res).toBe(0)
|
||||
})
|
||||
|
||||
|
||||
40
frontend/src/utils/__tests__/toast.test.ts
Normal file
40
frontend/src/utils/__tests__/toast.test.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { toast, toastCallbacks } from '../toast'
|
||||
|
||||
describe('toast util', () => {
|
||||
beforeEach(() => {
|
||||
// Ensure callbacks set is empty before each test
|
||||
toastCallbacks.clear()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
toastCallbacks.clear()
|
||||
})
|
||||
|
||||
it('calls registered callbacks for each toast type', () => {
|
||||
const mock = vi.fn()
|
||||
toastCallbacks.add(mock)
|
||||
|
||||
toast.success('ok')
|
||||
toast.error('bad')
|
||||
toast.info('info')
|
||||
toast.warning('warn')
|
||||
|
||||
expect(mock).toHaveBeenCalledTimes(4)
|
||||
expect(mock.mock.calls[0][0]).toMatchObject({ message: 'ok', type: 'success' })
|
||||
expect(mock.mock.calls[1][0]).toMatchObject({ message: 'bad', type: 'error' })
|
||||
expect(mock.mock.calls[2][0]).toMatchObject({ message: 'info', type: 'info' })
|
||||
expect(mock.mock.calls[3][0]).toMatchObject({ message: 'warn', type: 'warning' })
|
||||
})
|
||||
|
||||
it('provides incrementing ids', () => {
|
||||
const mock = vi.fn()
|
||||
toastCallbacks.add(mock)
|
||||
// send multiple messages
|
||||
toast.success('one')
|
||||
toast.success('two')
|
||||
const firstId = mock.mock.calls[0][0].id
|
||||
const secondId = mock.mock.calls[1][0].id
|
||||
expect(secondId).toBeGreaterThan(firstId)
|
||||
})
|
||||
})
|
||||
103
frontend/src/utils/proxyHostsHelpers.ts
Normal file
103
frontend/src/utils/proxyHostsHelpers.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
import type { ProxyHost } from '../api/proxyHosts'
|
||||
|
||||
export function formatSettingLabel(key: string) {
|
||||
switch (key) {
|
||||
case 'ssl_forced':
|
||||
return 'Force SSL'
|
||||
case 'http2_support':
|
||||
return 'HTTP/2 Support'
|
||||
case 'hsts_enabled':
|
||||
return 'HSTS Enabled'
|
||||
case 'hsts_subdomains':
|
||||
return 'HSTS Subdomains'
|
||||
case 'block_exploits':
|
||||
return 'Block Exploits'
|
||||
case 'websocket_support':
|
||||
return 'Websockets Support'
|
||||
default:
|
||||
return key
|
||||
}
|
||||
}
|
||||
|
||||
export function settingHelpText(key: string) {
|
||||
switch (key) {
|
||||
case 'ssl_forced':
|
||||
return 'Redirect all HTTP traffic to HTTPS.'
|
||||
case 'http2_support':
|
||||
return 'Enable HTTP/2 for improved performance.'
|
||||
case 'hsts_enabled':
|
||||
return 'Send HSTS header to enforce HTTPS.'
|
||||
case 'hsts_subdomains':
|
||||
return 'Include subdomains in HSTS policy.'
|
||||
case 'block_exploits':
|
||||
return 'Add common exploit-mitigation headers and rules.'
|
||||
case 'websocket_support':
|
||||
return 'Enable websocket proxying support.'
|
||||
default:
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
export function settingKeyToField(key: string) {
|
||||
switch (key) {
|
||||
case 'ssl_forced':
|
||||
return 'ssl_forced'
|
||||
case 'http2_support':
|
||||
return 'http2_support'
|
||||
case 'hsts_enabled':
|
||||
return 'hsts_enabled'
|
||||
case 'hsts_subdomains':
|
||||
return 'hsts_subdomains'
|
||||
case 'block_exploits':
|
||||
return 'block_exploits'
|
||||
case 'websocket_support':
|
||||
return 'websocket_support'
|
||||
default:
|
||||
return key
|
||||
}
|
||||
}
|
||||
|
||||
export async function applyBulkSettingsToHosts(options: {
|
||||
hosts: ProxyHost[]
|
||||
hostUUIDs: string[]
|
||||
keysToApply: string[]
|
||||
bulkApplySettings: Record<string, { apply: boolean; value: boolean }>
|
||||
updateHost: (uuid: string, data: Partial<ProxyHost>) => Promise<ProxyHost>
|
||||
setApplyProgress?: (p: { current: number; total: number } | null) => void
|
||||
}) {
|
||||
const { hosts, hostUUIDs, keysToApply, bulkApplySettings, updateHost, setApplyProgress } = options
|
||||
let completed = 0
|
||||
let errors = 0
|
||||
setApplyProgress?.({ current: 0, total: hostUUIDs.length })
|
||||
|
||||
for (const uuid of hostUUIDs) {
|
||||
const patch: Partial<ProxyHost> = {}
|
||||
for (const key of keysToApply) {
|
||||
const field = settingKeyToField(key) as keyof ProxyHost
|
||||
;(patch as unknown as Record<string, unknown>)[field as string] = bulkApplySettings[key].value
|
||||
}
|
||||
|
||||
const host = hosts.find(h => h.uuid === uuid)
|
||||
if (!host) {
|
||||
errors++
|
||||
completed++
|
||||
setApplyProgress?.({ current: completed, total: hostUUIDs.length })
|
||||
continue
|
||||
}
|
||||
|
||||
const merged: Partial<ProxyHost> = { ...host, ...patch }
|
||||
try {
|
||||
await updateHost(uuid, merged)
|
||||
} catch {
|
||||
errors++
|
||||
}
|
||||
|
||||
completed++
|
||||
setApplyProgress?.({ current: completed, total: hostUUIDs.length })
|
||||
}
|
||||
|
||||
setApplyProgress?.(null)
|
||||
return { errors, completed }
|
||||
}
|
||||
|
||||
export default {}
|
||||
Reference in New Issue
Block a user