feat: enhance idlewaker loading page design and add favicon handling in waker_http, removed unnecessary checkings

This commit is contained in:
yusing 2025-03-29 08:18:58 +08:00
parent 1c001ed9df
commit d105f866ff
8 changed files with 201 additions and 132 deletions

View file

@ -37,12 +37,14 @@ var (
clientMapMu sync.RWMutex clientMapMu sync.RWMutex
) )
var initClientCleanerOnce sync.Once
const ( const (
cleanInterval = 10 * time.Second cleanInterval = 10 * time.Second
clientTTLSecs = int64(10) clientTTLSecs = int64(10)
) )
func init() { func initClientCleaner() {
cleaner := task.RootTask("docker_clients_cleaner") cleaner := task.RootTask("docker_clients_cleaner")
go func() { go func() {
ticker := time.NewTicker(cleanInterval) ticker := time.NewTicker(cleanInterval)
@ -115,6 +117,8 @@ func (c *SharedClient) Close() {
// - Client: the Docker client connection. // - Client: the Docker client connection.
// - error: an error if the connection failed. // - error: an error if the connection failed.
func NewClient(host string) (*SharedClient, error) { func NewClient(host string) (*SharedClient, error) {
initClientCleanerOnce.Do(initClientCleaner)
clientMapMu.Lock() clientMapMu.Lock()
defer clientMapMu.Unlock() defer clientMapMu.Unlock()

View file

@ -18,33 +18,41 @@ type (
} }
) )
func (w *Watcher) ContainerID() string {
return w.route.ContainerInfo().ContainerID
}
func (w *Watcher) ContainerName() string {
return w.route.ContainerInfo().ContainerName
}
func (w *Watcher) containerStop(ctx context.Context) error { func (w *Watcher) containerStop(ctx context.Context) error {
return w.client.ContainerStop(ctx, w.ContainerID, container.StopOptions{ return w.client.ContainerStop(ctx, w.ContainerID(), container.StopOptions{
Signal: string(w.StopSignal), Signal: string(w.Config().StopSignal),
Timeout: &w.StopTimeout, Timeout: &w.Config().StopTimeout,
}) })
} }
func (w *Watcher) containerPause(ctx context.Context) error { func (w *Watcher) containerPause(ctx context.Context) error {
return w.client.ContainerPause(ctx, w.ContainerID) return w.client.ContainerPause(ctx, w.ContainerID())
} }
func (w *Watcher) containerKill(ctx context.Context) error { func (w *Watcher) containerKill(ctx context.Context) error {
return w.client.ContainerKill(ctx, w.ContainerID, string(w.StopSignal)) return w.client.ContainerKill(ctx, w.ContainerID(), string(w.Config().StopSignal))
} }
func (w *Watcher) containerUnpause(ctx context.Context) error { func (w *Watcher) containerUnpause(ctx context.Context) error {
return w.client.ContainerUnpause(ctx, w.ContainerID) return w.client.ContainerUnpause(ctx, w.ContainerID())
} }
func (w *Watcher) containerStart(ctx context.Context) error { func (w *Watcher) containerStart(ctx context.Context) error {
return w.client.ContainerStart(ctx, w.ContainerID, container.StartOptions{}) return w.client.ContainerStart(ctx, w.ContainerID(), container.StartOptions{})
} }
func (w *Watcher) containerStatus() (string, error) { func (w *Watcher) containerStatus() (string, error) {
ctx, cancel := context.WithTimeoutCause(w.task.Context(), dockerReqTimeout, errors.New("docker request timeout")) ctx, cancel := context.WithTimeoutCause(w.task.Context(), dockerReqTimeout, errors.New("docker request timeout"))
defer cancel() defer cancel()
json, err := w.client.ContainerInspect(ctx, w.ContainerID) json, err := w.client.ContainerInspect(ctx, w.ContainerID())
if err != nil { if err != nil {
return "", err return "", err
} }

View file

@ -1,10 +1,15 @@
<!DOCTYPE html> <!doctype html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>{{.Title}}</title> <title>{{.Title}}</title>
<style> <style>
/* size variables */
:root {
--dot-size: 12px;
--logo-size: 100px;
}
/* Global Styles */ /* Global Styles */
* { * {
box-sizing: border-box; box-sizing: border-box;
@ -12,58 +17,109 @@
padding: 0; padding: 0;
} }
body { body {
font-family: Inter, Arial, sans-serif; font-family:
"Inter",
-apple-system,
BlinkMacSystemFont,
"Segoe UI",
Roboto,
Oxygen,
Ubuntu,
Cantarell,
"Open Sans",
"Helvetica Neue",
sans-serif;
font-size: 16px; font-size: 16px;
line-height: 1.5; line-height: 1.5;
color: #fff; color: #f8f9fa;
background-color: #212121; background-color: #121212;
display: flex; display: flex;
flex-direction: column;
justify-content: center; justify-content: center;
align-items: center; align-items: center;
height: 100vh; height: 100vh;
margin: 0; margin: 0;
gap: 32px;
background: linear-gradient(135deg, #121212 0%, #1e1e1e 100%);
}
/* Container */
.container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 48px;
border-radius: 16px;
background-color: rgba(30, 30, 30, 0.6);
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.2);
backdrop-filter: blur(8px);
max-width: 90%;
transition: all 0.3s ease;
} }
/* Spinner Styles */ /* Spinner Styles */
.spinner { .loading-dots {
width: 120px; display: flex;
height: 120px;
border: 16px solid #333;
border-radius: 50%;
border-top: 16px solid #66d9ef;
animation: spin 2s linear infinite;
}
@keyframes spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
/* Error Styles */
.error {
display: inline-block;
text-align: center;
justify-content: center; justify-content: center;
align-items: center;
gap: 8px;
padding-top: 20px;
padding-bottom: 6px;
}
.dot {
width: var(--dot-size);
height: var(--dot-size);
background-color: #66d9ef;
border-radius: 50%;
animation: bounce 1.3s infinite ease-in-out;
}
.dot:nth-child(1) {
animation-delay: -0.32s;
}
.dot:nth-child(2) {
animation-delay: -0.16s;
}
@keyframes bounce {
0%,
80%,
100% {
transform: translateY(0);
}
40% {
transform: translateY(-10px);
} }
.error::before {
content: "\26A0"; /* Unicode for warning symbol */
font-size: 40px;
color: #ff9900;
} }
/* Message Styles */ /* Message Styles */
.message { .message {
font-size: 24px; font-size: 20px;
font-weight: bold; font-weight: 500;
padding-left: 32px;
text-align: center; text-align: center;
color: #f8f9fa;
max-width: 500px;
letter-spacing: 0.3px;
white-space: nowrap;
}
/* Logo */
.logo {
width: var(--logo-size);
height: var(--logo-size);
} }
</style> </style>
</head> </head>
<body> <body>
<div class="container">
<!-- icon handled by waker_http -->
<img class="logo" src="/favicon.ico" />
<div id="loading-dots" class="loading-dots">
<div class="dot"></div>
<div class="dot"></div>
<div class="dot"></div>
</div>
<div id="message" class="message">{{.Message}}</div>
</div>
<script> <script>
window.onload = async function () { window.onload = async function () {
let resp = await fetch(window.location.href, { let resp = await fetch(window.location.href, {
@ -74,15 +130,10 @@
if (resp.ok) { if (resp.ok) {
window.location.href = resp.url; window.location.href = resp.url;
} else { } else {
document.getElementById("message").innerText = document.getElementById("message").innerText = await resp.text();
await resp.text(); document.getElementById("loading-dots").remove();
document
.getElementById("spinner")
.classList.replace("spinner", "error");
} }
}; };
</script> </script>
<div id="spinner" class="spinner"></div>
<div id="message" class="message">{{.Message}}</div>
</body> </body>
</html> </html>

View file

@ -3,7 +3,6 @@ package idlewatcher
import ( import (
"bytes" "bytes"
_ "embed" _ "embed"
"strings"
"text/template" "text/template"
"github.com/yusing/go-proxy/internal/net/gphttp/httpheaders" "github.com/yusing/go-proxy/internal/net/gphttp/httpheaders"
@ -20,12 +19,12 @@ var loadingPage []byte
var loadingPageTmpl = template.Must(template.New("loading_page").Parse(string(loadingPage))) var loadingPageTmpl = template.Must(template.New("loading_page").Parse(string(loadingPage)))
func (w *Watcher) makeLoadingPageBody() []byte { func (w *Watcher) makeLoadingPageBody() []byte {
msg := w.ContainerName + " is starting..." msg := w.ContainerName() + " is starting..."
data := new(templateData) data := new(templateData)
data.CheckRedirectHeader = httpheaders.HeaderGoDoxyCheckRedirect data.CheckRedirectHeader = httpheaders.HeaderGoDoxyCheckRedirect
data.Title = w.ContainerName data.Title = w.route.HomepageItem().Name
data.Message = strings.ReplaceAll(msg, " ", "&ensp;") data.Message = msg
buf := bytes.NewBuffer(make([]byte, len(loadingPage)+len(data.Title)+len(data.Message)+len(httpheaders.HeaderGoDoxyCheckRedirect))) buf := bytes.NewBuffer(make([]byte, len(loadingPage)+len(data.Title)+len(data.Message)+len(httpheaders.HeaderGoDoxyCheckRedirect)))
err := loadingPageTmpl.Execute(buf, data) err := loadingPageTmpl.Execute(buf, data)

View file

@ -100,7 +100,7 @@ func (w *Watcher) Name() string {
// String implements health.HealthMonitor. // String implements health.HealthMonitor.
func (w *Watcher) String() string { func (w *Watcher) String() string {
return w.ContainerName return w.ContainerName()
} }
// Uptime implements health.HealthMonitor. // Uptime implements health.HealthMonitor.

View file

@ -7,6 +7,7 @@ import (
"strconv" "strconv"
"time" "time"
"github.com/yusing/go-proxy/internal/api/v1/favicon"
gphttp "github.com/yusing/go-proxy/internal/net/gphttp" gphttp "github.com/yusing/go-proxy/internal/net/gphttp"
"github.com/yusing/go-proxy/internal/net/gphttp/httpheaders" "github.com/yusing/go-proxy/internal/net/gphttp/httpheaders"
) )
@ -55,6 +56,10 @@ func (w *Watcher) cancelled(reqCtx context.Context, rw http.ResponseWriter) bool
} }
} }
func isFaviconPath(path string) bool {
return path == "/favicon.ico"
}
func (w *Watcher) wakeFromHTTP(rw http.ResponseWriter, r *http.Request) (shouldNext bool) { func (w *Watcher) wakeFromHTTP(rw http.ResponseWriter, r *http.Request) (shouldNext bool) {
w.resetIdleTimer() w.resetIdleTimer()
@ -63,8 +68,15 @@ func (w *Watcher) wakeFromHTTP(rw http.ResponseWriter, r *http.Request) (shouldN
return true return true
} }
// handle favicon request
if isFaviconPath(r.URL.Path) {
r.URL.RawQuery = "alias=" + w.route.TargetName()
favicon.GetFavIcon(rw, r)
return false
}
// Check if start endpoint is configured and request path matches // Check if start endpoint is configured and request path matches
if w.StartEndpoint != "" && r.URL.Path != w.StartEndpoint { if w.Config().StartEndpoint != "" && r.URL.Path != w.Config().StartEndpoint {
http.Error(rw, "Forbidden: Container can only be started via configured start endpoint", http.StatusForbidden) http.Error(rw, "Forbidden: Container can only be started via configured start endpoint", http.StatusForbidden)
return false return false
} }
@ -88,7 +100,7 @@ func (w *Watcher) wakeFromHTTP(rw http.ResponseWriter, r *http.Request) (shouldN
return false return false
} }
ctx, cancel := context.WithTimeoutCause(r.Context(), w.WakeTimeout, errors.New("wake timeout")) ctx, cancel := context.WithTimeoutCause(r.Context(), w.Config().WakeTimeout, errors.New("wake timeout"))
defer cancel() defer cancel()
if w.cancelled(ctx, rw) { if w.cancelled(ctx, rw) {

View file

@ -61,7 +61,7 @@ func (w *Watcher) wakeFromStream() error {
return wakeErr return wakeErr
} }
ctx, cancel := context.WithTimeoutCause(w.task.Context(), w.WakeTimeout, errors.New("wake timeout")) ctx, cancel := context.WithTimeoutCause(w.task.Context(), w.Config().WakeTimeout, errors.New("wake timeout"))
defer cancel() defer cancel()
for { for {

View file

@ -26,8 +26,8 @@ type (
zerolog.Logger zerolog.Logger
*waker *waker
*containerMeta
*idlewatcher.Config route route.Route
client *docker.SharedClient client *docker.SharedClient
state atomic.Value[*containerState] state atomic.Value[*containerState]
@ -52,11 +52,6 @@ const dockerReqTimeout = 3 * time.Second
func registerWatcher(parent task.Parent, route route.Route, waker *waker) (*Watcher, error) { func registerWatcher(parent task.Parent, route route.Route, waker *waker) (*Watcher, error) {
cfg := route.IdlewatcherConfig() cfg := route.IdlewatcherConfig()
if cfg.IdleTimeout == 0 {
panic(errShouldNotReachHere)
}
cont := route.ContainerInfo() cont := route.ContainerInfo()
key := cont.ContainerID key := cont.ContainerID
@ -79,11 +74,7 @@ func registerWatcher(parent task.Parent, route route.Route, waker *waker) (*Watc
// FIXME: possible race condition here // FIXME: possible race condition here
w.waker = waker w.waker = waker
w.containerMeta = &containerMeta{ w.route = route
ContainerID: cont.ContainerID,
ContainerName: cont.ContainerName,
}
w.Config = cfg
w.ticker.Reset(cfg.IdleTimeout) w.ticker.Reset(cfg.IdleTimeout)
if cont.Running { if cont.Running {
@ -112,6 +103,10 @@ func registerWatcher(parent task.Parent, route route.Route, waker *waker) (*Watc
return w, nil return w, nil
} }
func (w *Watcher) Config() *idlewatcher.Config {
return w.route.IdlewatcherConfig()
}
func (w *Watcher) Wake() error { func (w *Watcher) Wake() error {
return w.wakeIfStopped() return w.wakeIfStopped()
} }
@ -141,7 +136,7 @@ func (w *Watcher) wakeIfStopped() error {
return err return err
} }
ctx, cancel := context.WithTimeout(w.task.Context(), w.WakeTimeout) ctx, cancel := context.WithTimeout(w.task.Context(), w.Config().WakeTimeout)
defer cancel() defer cancel()
// !Hard coded here since theres no constants from Docker API // !Hard coded here since theres no constants from Docker API
@ -159,7 +154,7 @@ func (w *Watcher) wakeIfStopped() error {
func (w *Watcher) getStopCallback() StopCallback { func (w *Watcher) getStopCallback() StopCallback {
var cb func(context.Context) error var cb func(context.Context) error
switch w.StopMethod { switch w.Config().StopMethod {
case idlewatcher.StopMethodPause: case idlewatcher.StopMethodPause:
cb = w.containerPause cb = w.containerPause
case idlewatcher.StopMethodStop: case idlewatcher.StopMethodStop:
@ -170,7 +165,7 @@ func (w *Watcher) getStopCallback() StopCallback {
panic(errShouldNotReachHere) panic(errShouldNotReachHere)
} }
return func() error { return func() error {
ctx, cancel := context.WithTimeout(w.task.Context(), time.Duration(w.StopTimeout)*time.Second) ctx, cancel := context.WithTimeout(w.task.Context(), time.Duration(w.Config().StopTimeout)*time.Second)
defer cancel() defer cancel()
return cb(ctx) return cb(ctx)
} }
@ -178,19 +173,19 @@ func (w *Watcher) getStopCallback() StopCallback {
func (w *Watcher) resetIdleTimer() { func (w *Watcher) resetIdleTimer() {
w.Trace().Msg("reset idle timer") w.Trace().Msg("reset idle timer")
w.ticker.Reset(w.IdleTimeout) w.ticker.Reset(w.Config().IdleTimeout)
w.lastReset = time.Now() w.lastReset = time.Now()
} }
func (w *Watcher) expires() time.Time { func (w *Watcher) expires() time.Time {
return w.lastReset.Add(w.IdleTimeout) return w.lastReset.Add(w.Config().IdleTimeout)
} }
func (w *Watcher) getEventCh(ctx context.Context, dockerWatcher *watcher.DockerWatcher) (eventCh <-chan events.Event, errCh <-chan gperr.Error) { func (w *Watcher) getEventCh(ctx context.Context, dockerWatcher *watcher.DockerWatcher) (eventCh <-chan events.Event, errCh <-chan gperr.Error) {
eventCh, errCh = dockerWatcher.EventsWithOptions(ctx, watcher.DockerListOptions{ eventCh, errCh = dockerWatcher.EventsWithOptions(ctx, watcher.DockerListOptions{
Filters: watcher.NewDockerFilter( Filters: watcher.NewDockerFilter(
watcher.DockerFilterContainer, watcher.DockerFilterContainer,
watcher.DockerFilterContainerNameID(w.ContainerID), watcher.DockerFilterContainerNameID(w.route.ContainerInfo().ContainerID),
watcher.DockerFilterStart, watcher.DockerFilterStart,
watcher.DockerFilterStop, watcher.DockerFilterStop,
watcher.DockerFilterDie, watcher.DockerFilterDie,
@ -249,20 +244,20 @@ func (w *Watcher) watchUntilDestroy() (returnCause error) {
w.Error().Msg("unexpected docker event: " + e.String()) w.Error().Msg("unexpected docker event: " + e.String())
} }
// container name changed should also change the container id // container name changed should also change the container id
if w.ContainerName != e.ActorName { // if w.ContainerName != e.ActorName {
w.Debug().Msgf("renamed %s -> %s", w.ContainerName, e.ActorName) // w.Debug().Msgf("renamed %s -> %s", w.ContainerName, e.ActorName)
w.ContainerName = e.ActorName // w.ContainerName = e.ActorName
} // }
if w.ContainerID != e.ActorID { // if w.ContainerID != e.ActorID {
w.Debug().Msgf("id changed %s -> %s", w.ContainerID, e.ActorID) // w.Debug().Msgf("id changed %s -> %s", w.ContainerID, e.ActorID)
w.ContainerID = e.ActorID // w.ContainerID = e.ActorID
// recreate event stream // // recreate event stream
eventCancel() // eventCancel()
eventCtx, eventCancel = context.WithCancel(w.task.Context()) // eventCtx, eventCancel = context.WithCancel(w.task.Context())
defer eventCancel() // defer eventCancel()
dockerEventCh, dockerEventErrCh = w.getEventCh(eventCtx, dockerWatcher) // dockerEventCh, dockerEventErrCh = w.getEventCh(eventCtx, dockerWatcher)
} // }
case <-w.ticker.C: case <-w.ticker.C:
w.ticker.Stop() w.ticker.Stop()
if w.running() { if w.running() {
@ -274,7 +269,7 @@ func (w *Watcher) watchUntilDestroy() (returnCause error) {
if errors.Is(err, context.DeadlineExceeded) { if errors.Is(err, context.DeadlineExceeded) {
err = errors.New("timeout waiting for container to stop, please set a higher value for `stop_timeout`") err = errors.New("timeout waiting for container to stop, please set a higher value for `stop_timeout`")
} }
w.Err(err).Msgf("container stop with method %q failed", w.StopMethod) w.Err(err).Msgf("container stop with method %q failed", w.Config().StopMethod)
default: default:
w.Info().Str("reason", "idle timeout").Msg("container stopped") w.Info().Str("reason", "idle timeout").Msg("container stopped")
} }