mirror of
https://github.com/yusing/godoxy.git
synced 2025-05-20 04:42:33 +02:00
added a simple panel
This commit is contained in:
parent
47733ec05f
commit
12e23c3517
11 changed files with 342 additions and 113 deletions
|
@ -1,8 +1,9 @@
|
||||||
FROM alpine:latest
|
FROM alpine:latest
|
||||||
|
|
||||||
MAINTAINER yusing@6uo.me
|
LABEL maintainer="yusing@6uo.me"
|
||||||
|
|
||||||
COPY bin/go-proxy /usr/bin
|
COPY bin/go-proxy /usr/bin
|
||||||
|
COPY templates/ /app/templates
|
||||||
|
|
||||||
RUN chmod +rx /usr/bin/go-proxy
|
RUN chmod +rx /usr/bin/go-proxy
|
||||||
ENV DOCKER_HOST unix:///var/run/docker.sock
|
ENV DOCKER_HOST unix:///var/run/docker.sock
|
||||||
|
|
11
README.md
11
README.md
|
@ -9,6 +9,9 @@ Written in **Go** with *~220 loc*.
|
||||||
- subdomain matching **(domain name doesn't matter)**
|
- subdomain matching **(domain name doesn't matter)**
|
||||||
- path matching
|
- path matching
|
||||||
- Auto hot-reload when container start / die / stop.
|
- Auto hot-reload when container start / die / stop.
|
||||||
|
- Simple panel to see all reverse proxies and health (visit `https://go-proxy.yourdomain.com`)
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
## Why am I making this
|
## Why am I making this
|
||||||
|
|
||||||
|
@ -157,13 +160,9 @@ Transfer/sec: 1.98MB
|
||||||
|
|
||||||
2. Get dependencies with `go get`
|
2. Get dependencies with `go get`
|
||||||
|
|
||||||
3. build image with following commands
|
3. build binary with `sh build.sh`
|
||||||
|
|
||||||
```shell
|
4. start your container with `docker compose up -d`
|
||||||
mkdir -p bin
|
|
||||||
CGO_ENABLED=0 GOOS=<platform> go build -o bin/go-proxy
|
|
||||||
docker build -t <tag> .
|
|
||||||
```
|
|
||||||
|
|
||||||
## Getting SSL certs
|
## Getting SSL certs
|
||||||
|
|
||||||
|
|
2
build.sh
2
build.sh
|
@ -1,6 +1,6 @@
|
||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
mkdir -p bin
|
mkdir -p bin
|
||||||
CGO_ENABLED=0 GOOS=linux go build -o bin/go-proxy || exit 1
|
CGO_ENABLED=0 GOOS=linux go build -o bin/go-proxy src/go-proxy/*.go || exit 1
|
||||||
|
|
||||||
if [ "$1" = "up" ]; then
|
if [ "$1" = "up" ]; then
|
||||||
docker compose up -d --build app && \
|
docker compose up -d --build app && \
|
||||||
|
|
|
@ -12,3 +12,8 @@ services:
|
||||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||||
extra_hosts:
|
extra_hosts:
|
||||||
- host.docker.internal:host-gateway
|
- host.docker.internal:host-gateway
|
||||||
|
logging:
|
||||||
|
driver: 'json-file'
|
||||||
|
options:
|
||||||
|
max-file: '1'
|
||||||
|
max-size: 128k
|
||||||
|
|
3
go.mod
3
go.mod
|
@ -1,8 +1,9 @@
|
||||||
module go-proxy
|
module github.com/yusing/go-proxy
|
||||||
|
|
||||||
go 1.21.7
|
go 1.21.7
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
github.com/deckarep/golang-set/v2 v2.6.0
|
||||||
github.com/docker/docker v25.0.3+incompatible
|
github.com/docker/docker v25.0.3+incompatible
|
||||||
golang.org/x/text v0.14.0
|
golang.org/x/text v0.14.0
|
||||||
)
|
)
|
||||||
|
|
2
go.sum
2
go.sum
|
@ -8,6 +8,8 @@ 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/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo=
|
||||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
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=
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/deckarep/golang-set/v2 v2.6.0 h1:XfcQbWM1LlMB8BsJ8N9vW5ehnnPVIw0je80NsVHagjM=
|
||||||
|
github.com/deckarep/golang-set/v2 v2.6.0/go.mod h1:VAky9rY/yGXJOLEDv3OMci+7wtDpOF4IN+y82NBOac4=
|
||||||
github.com/distribution/reference v0.5.0 h1:/FUIFXtfc/x2gpa5/VGfiGLuOIdYa1t65IKK2OFGvA0=
|
github.com/distribution/reference v0.5.0 h1:/FUIFXtfc/x2gpa5/VGfiGLuOIdYa1t65IKK2OFGvA0=
|
||||||
github.com/distribution/reference v0.5.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
|
github.com/distribution/reference v0.5.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
|
||||||
github.com/docker/docker v25.0.3+incompatible h1:D5fy/lYmY7bvZa0XTZ5/UJPljor41F+vdyJG5luQLfQ=
|
github.com/docker/docker v25.0.3+incompatible h1:D5fy/lYmY7bvZa0XTZ5/UJPljor41F+vdyJG5luQLfQ=
|
||||||
|
|
BIN
screenshots/panel.png
Executable file
BIN
screenshots/panel.png
Executable file
Binary file not shown.
After Width: | Height: | Size: 100 KiB |
116
src/go-proxy/docker.go
Normal file
116
src/go-proxy/docker.go
Normal file
|
@ -0,0 +1,116 @@
|
||||||
|
package go_proxy
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"net/url"
|
||||||
|
"reflect"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
mapset "github.com/deckarep/golang-set/v2"
|
||||||
|
"github.com/docker/docker/api/types"
|
||||||
|
"github.com/docker/docker/api/types/container"
|
||||||
|
"github.com/docker/docker/client"
|
||||||
|
"golang.org/x/net/context"
|
||||||
|
"golang.org/x/text/cases"
|
||||||
|
"golang.org/x/text/language"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Config struct {
|
||||||
|
Scheme string
|
||||||
|
Host string
|
||||||
|
Port string
|
||||||
|
Path string
|
||||||
|
}
|
||||||
|
|
||||||
|
type Route struct {
|
||||||
|
Url *url.URL
|
||||||
|
Path string
|
||||||
|
}
|
||||||
|
|
||||||
|
var dockerClient *client.Client
|
||||||
|
var subdomainRouteMap map[string]mapset.Set[Route] // subdomain -> path
|
||||||
|
|
||||||
|
func buildContainerCfg(container types.Container) {
|
||||||
|
var aliases []string
|
||||||
|
|
||||||
|
container_name := strings.TrimPrefix(container.Names[0], "/")
|
||||||
|
aliases_label, ok := container.Labels["proxy.aliases"]
|
||||||
|
if !ok {
|
||||||
|
aliases = []string{container_name}
|
||||||
|
} else {
|
||||||
|
aliases = strings.Split(aliases_label, ",")
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, alias := range aliases {
|
||||||
|
config := NewConfig()
|
||||||
|
prefix := fmt.Sprintf("proxy.%s.", alias)
|
||||||
|
for label, value := range container.Labels {
|
||||||
|
if strings.HasPrefix(label, prefix) {
|
||||||
|
field := strings.TrimPrefix(label, prefix)
|
||||||
|
field = cases.Title(language.Und, cases.NoLower).String(field)
|
||||||
|
prop := reflect.ValueOf(&config).Elem().FieldByName(field)
|
||||||
|
prop.Set(reflect.ValueOf(value))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if config.Port == "" {
|
||||||
|
// usually the smaller port is the http one
|
||||||
|
// so make it the last one to be set (if 80 or 8080 are not exposed)
|
||||||
|
sort.Slice(container.Ports, func(i, j int) bool {
|
||||||
|
return container.Ports[i].PrivatePort > container.Ports[j].PrivatePort
|
||||||
|
})
|
||||||
|
for _, port := range container.Ports {
|
||||||
|
// set first, but keep trying
|
||||||
|
config.Port = fmt.Sprintf("%d", port.PrivatePort)
|
||||||
|
// until we find 80 or 8080
|
||||||
|
if port.PrivatePort == 80 || port.PrivatePort == 8080 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if config.Port == "" {
|
||||||
|
// no ports exposed or specified
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if config.Scheme == "" {
|
||||||
|
if strings.HasSuffix(config.Port, "443") {
|
||||||
|
config.Scheme = "https"
|
||||||
|
} else {
|
||||||
|
config.Scheme = "http"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if config.Scheme != "http" && config.Scheme != "https" {
|
||||||
|
log.Printf("%s: unsupported scheme: %s, using http", container_name, config.Scheme)
|
||||||
|
config.Scheme = "http"
|
||||||
|
}
|
||||||
|
if config.Host == "" {
|
||||||
|
if container.HostConfig.NetworkMode != "host" {
|
||||||
|
config.Host = container_name
|
||||||
|
} else {
|
||||||
|
config.Host = "host.docker.internal"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_, inMap := subdomainRouteMap[alias]
|
||||||
|
if !inMap {
|
||||||
|
subdomainRouteMap[alias] = mapset.NewSet[Route]()
|
||||||
|
}
|
||||||
|
url, err := url.Parse(fmt.Sprintf("%s://%s:%s", config.Scheme, config.Host, config.Port))
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
subdomainRouteMap[alias].Add(Route{Url: url, Path: config.Path})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildRoutes() {
|
||||||
|
subdomainRouteMap = make(map[string]mapset.Set[Route])
|
||||||
|
containerSlice, err := dockerClient.ContainerList(context.Background(), container.ListOptions{})
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, container := range containerSlice {
|
||||||
|
buildContainerCfg(container)
|
||||||
|
}
|
||||||
|
subdomainRouteMap["go-proxy"] = panelRoute
|
||||||
|
}
|
|
@ -1,4 +1,4 @@
|
||||||
package main
|
package go_proxy
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
@ -7,35 +7,19 @@ import (
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httputil"
|
"net/http/httputil"
|
||||||
"net/url"
|
"net/url"
|
||||||
"reflect"
|
|
||||||
"runtime"
|
"runtime"
|
||||||
"sort"
|
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/docker/docker/api/types"
|
"github.com/docker/docker/api/types"
|
||||||
"github.com/docker/docker/api/types/container"
|
|
||||||
"github.com/docker/docker/api/types/filters"
|
"github.com/docker/docker/api/types/filters"
|
||||||
"github.com/docker/docker/client"
|
"github.com/docker/docker/client"
|
||||||
"golang.org/x/net/context"
|
"golang.org/x/net/context"
|
||||||
"golang.org/x/text/cases"
|
|
||||||
"golang.org/x/text/language"
|
mapset "github.com/deckarep/golang-set/v2"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Config struct {
|
var panelRoute = mapset.NewSet(Route{Url: &url.URL{Scheme: "http", Host: "localhost:81", Path: "/"}, Path: "/"})
|
||||||
Scheme string
|
|
||||||
Host string
|
|
||||||
Port string
|
|
||||||
Path string
|
|
||||||
}
|
|
||||||
|
|
||||||
type Route struct {
|
|
||||||
Url url.URL
|
|
||||||
Path string
|
|
||||||
}
|
|
||||||
|
|
||||||
var dockerClient *client.Client
|
|
||||||
var subdomainRouteMap map[string][]Route // subdomain -> path
|
|
||||||
|
|
||||||
// TODO: default + per proxy
|
// TODO: default + per proxy
|
||||||
var transport = &http.Transport{
|
var transport = &http.Transport{
|
||||||
|
@ -95,6 +79,13 @@ func main() {
|
||||||
log.Fatal("HTTP server error", err)
|
log.Fatal("HTTP server error", err)
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
go func() {
|
||||||
|
log.Println("Starting HTTP panel on port 81")
|
||||||
|
err := http.ListenAndServe(":81", http.HandlerFunc(panelHandler))
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal("HTTP server error", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
log.Println("Starting HTTPS server on port 443")
|
log.Println("Starting HTTPS server on port 443")
|
||||||
err = http.ListenAndServeTLS(":443", "/certs/cert.crt", "/certs/priv.key", mux)
|
err = http.ListenAndServeTLS(":443", "/certs/cert.crt", "/certs/priv.key", mux)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
@ -122,13 +113,13 @@ func handler(w http.ResponseWriter, r *http.Request) {
|
||||||
http.Error(w, fmt.Sprintf("no matching route for subdomain %s", subdomain), http.StatusNotFound)
|
http.Error(w, fmt.Sprintf("no matching route for subdomain %s", subdomain), http.StatusNotFound)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
for _, route := range routeMap {
|
for route := range routeMap.Iter() {
|
||||||
if strings.HasPrefix(r.URL.Path, route.Path) {
|
if strings.HasPrefix(r.URL.Path, route.Path) {
|
||||||
realPath := strings.TrimPrefix(r.URL.Path, route.Path)
|
realPath := strings.TrimPrefix(r.URL.Path, route.Path)
|
||||||
origHost := r.Host
|
origHost := r.Host
|
||||||
r.URL.Path = realPath
|
r.URL.Path = realPath
|
||||||
log.Printf("[Route] %s -> %s%s ", origHost, route.Url.String(), route.Path)
|
log.Printf("[Route] %s -> %s%s ", origHost, route.Url.String(), route.Path)
|
||||||
proxyServer := httputil.NewSingleHostReverseProxy(&route.Url)
|
proxyServer := httputil.NewSingleHostReverseProxy(route.Url)
|
||||||
proxyServer.Transport = transport
|
proxyServer.Transport = transport
|
||||||
proxyServer.ServeHTTP(w, r)
|
proxyServer.ServeHTTP(w, r)
|
||||||
return
|
return
|
||||||
|
@ -136,85 +127,3 @@ func handler(w http.ResponseWriter, r *http.Request) {
|
||||||
}
|
}
|
||||||
http.Error(w, fmt.Sprintf("no matching route for path %s for subdomain %s", r.URL.Path, subdomain), http.StatusNotFound)
|
http.Error(w, fmt.Sprintf("no matching route for path %s for subdomain %s", r.URL.Path, subdomain), http.StatusNotFound)
|
||||||
}
|
}
|
||||||
|
|
||||||
func buildContainerCfg(container types.Container) {
|
|
||||||
var aliases []string
|
|
||||||
|
|
||||||
container_name := strings.TrimPrefix(container.Names[0], "/")
|
|
||||||
aliases_label, ok := container.Labels["proxy.aliases"]
|
|
||||||
if !ok {
|
|
||||||
aliases = []string{container_name}
|
|
||||||
} else {
|
|
||||||
aliases = strings.Split(aliases_label, ",")
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, alias := range aliases {
|
|
||||||
config := NewConfig()
|
|
||||||
prefix := fmt.Sprintf("proxy.%s.", alias)
|
|
||||||
for label, value := range container.Labels {
|
|
||||||
if strings.HasPrefix(label, prefix) {
|
|
||||||
field := strings.TrimPrefix(label, prefix)
|
|
||||||
field = cases.Title(language.Und, cases.NoLower).String(field)
|
|
||||||
prop := reflect.ValueOf(&config).Elem().FieldByName(field)
|
|
||||||
prop.Set(reflect.ValueOf(value))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if config.Port == "" {
|
|
||||||
// usually the smaller port is the http one
|
|
||||||
// so make it the last one to be set (if 80 or 8080 are not exposed)
|
|
||||||
sort.Slice(container.Ports, func(i, j int) bool {
|
|
||||||
return container.Ports[i].PrivatePort > container.Ports[j].PrivatePort
|
|
||||||
})
|
|
||||||
for _, port := range container.Ports {
|
|
||||||
// set first, but keep trying
|
|
||||||
config.Port = fmt.Sprintf("%d", port.PrivatePort)
|
|
||||||
// until we find 80 or 8080
|
|
||||||
if port.PrivatePort == 80 || port.PrivatePort == 8080 {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if config.Port == "" {
|
|
||||||
// no ports exposed or specified
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if config.Scheme == "" {
|
|
||||||
if strings.HasSuffix(config.Port, "443") {
|
|
||||||
config.Scheme = "https"
|
|
||||||
} else {
|
|
||||||
config.Scheme = "http"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if config.Scheme != "http" && config.Scheme != "https" {
|
|
||||||
log.Printf("%s: unsupported scheme: %s, using http", container_name, config.Scheme)
|
|
||||||
config.Scheme = "http"
|
|
||||||
}
|
|
||||||
if config.Host == "" {
|
|
||||||
if container.HostConfig.NetworkMode != "host" {
|
|
||||||
config.Host = container_name
|
|
||||||
} else {
|
|
||||||
config.Host = "host.docker.internal"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_, inMap := subdomainRouteMap[alias]
|
|
||||||
if !inMap {
|
|
||||||
subdomainRouteMap[alias] = make([]Route, 0)
|
|
||||||
}
|
|
||||||
url, err := url.Parse(fmt.Sprintf("%s://%s:%s", config.Scheme, config.Host, config.Port))
|
|
||||||
if err != nil {
|
|
||||||
log.Fatal(err)
|
|
||||||
}
|
|
||||||
route := Route{Url: *url, Path: config.Path}
|
|
||||||
subdomainRouteMap[alias] = append(subdomainRouteMap[alias], route)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
func buildRoutes() {
|
|
||||||
subdomainRouteMap = make(map[string][]Route)
|
|
||||||
containerSlice, err := dockerClient.ContainerList(context.Background(), container.ListOptions{})
|
|
||||||
if err != nil {
|
|
||||||
log.Fatal(err)
|
|
||||||
}
|
|
||||||
for _, container := range containerSlice {
|
|
||||||
buildContainerCfg(container)
|
|
||||||
}
|
|
||||||
}
|
|
80
src/go-proxy/panel.go
Normal file
80
src/go-proxy/panel.go
Normal file
|
@ -0,0 +1,80 @@
|
||||||
|
package go_proxy
|
||||||
|
|
||||||
|
import (
|
||||||
|
"html/template"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const templateFile = "/app/templates/panel.html"
|
||||||
|
|
||||||
|
var healthCheckHttpClient = &http.Client{
|
||||||
|
Timeout: 5 * time.Second,
|
||||||
|
Transport: &http.Transport{
|
||||||
|
DisableKeepAlives: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
func panelHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch r.URL.Path {
|
||||||
|
case "/":
|
||||||
|
panelIndex(w, r)
|
||||||
|
return
|
||||||
|
case "/checkhealth":
|
||||||
|
panelCheckTargetHealth(w, r)
|
||||||
|
return
|
||||||
|
default:
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func panelIndex(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodGet {
|
||||||
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
tmpl, err := template.ParseFiles(templateFile)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
err = tmpl.Execute(w, subdomainRouteMap)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func panelCheckTargetHealth(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodHead {
|
||||||
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
targetUrl := r.URL.Query().Get("target")
|
||||||
|
|
||||||
|
if targetUrl == "" {
|
||||||
|
http.Error(w, "target is required", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// try HEAD first
|
||||||
|
// if HEAD is not allowed, try GET
|
||||||
|
resp, err := healthCheckHttpClient.Head(targetUrl)
|
||||||
|
if err != nil && resp != nil && resp.StatusCode == http.StatusMethodNotAllowed {
|
||||||
|
_, err = healthCheckHttpClient.Get(targetUrl)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusServiceUnavailable)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusServiceUnavailable)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
}
|
116
templates/panel.html
Normal file
116
templates/panel.html
Normal file
|
@ -0,0 +1,116 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
background-color: #343a40;
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
table {
|
||||||
|
border-collapse: collapse;
|
||||||
|
border-spacing: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
tr {
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
table th:first-child {
|
||||||
|
border-radius: 10px 0 0 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
table th:last-child {
|
||||||
|
border-radius: 0 10px 10px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
table td:first-of-type {
|
||||||
|
border-top-left-radius: 10px;
|
||||||
|
border-bottom-left-radius: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
table td:last-of-type {
|
||||||
|
border-top-right-radius: 10px;
|
||||||
|
border-bottom-right-radius: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.health-circle {
|
||||||
|
height: 15px;
|
||||||
|
width: 15px;
|
||||||
|
background-color: #28a745;
|
||||||
|
border-radius: 50%;
|
||||||
|
margin: auto;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<title>Route Panel</title>
|
||||||
|
<script>
|
||||||
|
function checkHealth(url, cell) {
|
||||||
|
var xhttp = new XMLHttpRequest();
|
||||||
|
xhttp.onreadystatechange = function () {
|
||||||
|
if (this.readyState != 4) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (this.status === 200) {
|
||||||
|
cell.innerHTML = '<div class="health-circle"></div>'; // Green circle for healthy
|
||||||
|
} else {
|
||||||
|
cell.innerHTML = '<div class="health-circle" style="background-color: #dc3545;"></div>'; // Red circle for unhealthy
|
||||||
|
}
|
||||||
|
};
|
||||||
|
url = window.location.origin + '/checkhealth?target=' + encodeURIComponent(url);
|
||||||
|
xhttp.open("HEAD", url, true);
|
||||||
|
xhttp.send();
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateHealthStatus() {
|
||||||
|
let rows = document.querySelectorAll('tbody tr');
|
||||||
|
rows.forEach(row => {
|
||||||
|
let url = row.cells[2].textContent;
|
||||||
|
let cell = row.cells[3]; // Health column cell
|
||||||
|
checkHealth(url, cell);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener("DOMContentLoaded", () => {
|
||||||
|
updateHealthStatus();
|
||||||
|
|
||||||
|
// Update health status every 5 seconds
|
||||||
|
setInterval(updateHealthStatus, 5000);
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<h1 style="color: #ffffff;">Route Panel</h1>
|
||||||
|
<table class="table table-striped table-dark w-auto">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Subdomain</th>
|
||||||
|
<th>Path</th>
|
||||||
|
<th>URL</th>
|
||||||
|
<th>Health</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{{range $subdomain, $routes := .}}
|
||||||
|
{{range $route := $routes.Iter}}
|
||||||
|
<tr>
|
||||||
|
<td>{{$subdomain}}</td>
|
||||||
|
<td>{{$route.Path}}</td>
|
||||||
|
<td>{{$route.Url.String}}</td>
|
||||||
|
<td class="align-middle">
|
||||||
|
<div class="health-circle"></div>
|
||||||
|
</td> <!-- Health column -->
|
||||||
|
</tr>
|
||||||
|
{{end}}
|
||||||
|
{{end}}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
Loading…
Add table
Reference in a new issue