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
)
var initClientCleanerOnce sync.Once
const (
cleanInterval = 10 * time.Second
clientTTLSecs = int64(10)
)
func init() {
func initClientCleaner() {
cleaner := task.RootTask("docker_clients_cleaner")
go func() {
ticker := time.NewTicker(cleanInterval)
@ -115,6 +117,8 @@ func (c *SharedClient) Close() {
// - Client: the Docker client connection.
// - error: an error if the connection failed.
func NewClient(host string) (*SharedClient, error) {
initClientCleanerOnce.Do(initClientCleaner)
clientMapMu.Lock()
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 {
return w.client.ContainerStop(ctx, w.ContainerID, container.StopOptions{
Signal: string(w.StopSignal),
Timeout: &w.StopTimeout,
return w.client.ContainerStop(ctx, w.ContainerID(), container.StopOptions{
Signal: string(w.Config().StopSignal),
Timeout: &w.Config().StopTimeout,
})
}
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 {
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 {
return w.client.ContainerUnpause(ctx, w.ContainerID)
return w.client.ContainerUnpause(ctx, w.ContainerID())
}
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) {
ctx, cancel := context.WithTimeoutCause(w.task.Context(), dockerReqTimeout, errors.New("docker request timeout"))
defer cancel()
json, err := w.client.ContainerInspect(ctx, w.ContainerID)
json, err := w.client.ContainerInspect(ctx, w.ContainerID())
if err != nil {
return "", err
}

View file

@ -1,88 +1,139 @@
<!DOCTYPE html>
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>{{.Title}}</title>
<style>
/* Global Styles */
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: Inter, Arial, sans-serif;
font-size: 16px;
line-height: 1.5;
color: #fff;
background-color: #212121;
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
margin: 0;
}
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>{{.Title}}</title>
<style>
/* size variables */
:root {
--dot-size: 12px;
--logo-size: 100px;
}
/* Global Styles */
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family:
"Inter",
-apple-system,
BlinkMacSystemFont,
"Segoe UI",
Roboto,
Oxygen,
Ubuntu,
Cantarell,
"Open Sans",
"Helvetica Neue",
sans-serif;
font-size: 16px;
line-height: 1.5;
color: #f8f9fa;
background-color: #121212;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
height: 100vh;
margin: 0;
gap: 32px;
background: linear-gradient(135deg, #121212 0%, #1e1e1e 100%);
}
/* Spinner Styles */
.spinner {
width: 120px;
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);
}
}
/* 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;
}
/* Error Styles */
.error {
display: inline-block;
text-align: center;
justify-content: center;
}
.error::before {
content: "\26A0"; /* Unicode for warning symbol */
font-size: 40px;
color: #ff9900;
}
/* Spinner Styles */
.loading-dots {
display: flex;
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);
}
}
/* Message Styles */
.message {
font-size: 24px;
font-weight: bold;
padding-left: 32px;
text-align: center;
}
</style>
</head>
<body>
<script>
window.onload = async function () {
let resp = await fetch(window.location.href, {
headers: {
"{{.CheckRedirectHeader}}": "1",
},
});
if (resp.ok) {
window.location.href = resp.url;
} else {
document.getElementById("message").innerText =
await resp.text();
document
.getElementById("spinner")
.classList.replace("spinner", "error");
}
};
</script>
<div id="spinner" class="spinner"></div>
<div id="message" class="message">{{.Message}}</div>
</body>
/* Message Styles */
.message {
font-size: 20px;
font-weight: 500;
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>
</head>
<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>
window.onload = async function () {
let resp = await fetch(window.location.href, {
headers: {
"{{.CheckRedirectHeader}}": "1",
},
});
if (resp.ok) {
window.location.href = resp.url;
} else {
document.getElementById("message").innerText = await resp.text();
document.getElementById("loading-dots").remove();
}
};
</script>
</body>
</html>

View file

@ -3,7 +3,6 @@ package idlewatcher
import (
"bytes"
_ "embed"
"strings"
"text/template"
"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)))
func (w *Watcher) makeLoadingPageBody() []byte {
msg := w.ContainerName + " is starting..."
msg := w.ContainerName() + " is starting..."
data := new(templateData)
data.CheckRedirectHeader = httpheaders.HeaderGoDoxyCheckRedirect
data.Title = w.ContainerName
data.Message = strings.ReplaceAll(msg, " ", "&ensp;")
data.Title = w.route.HomepageItem().Name
data.Message = msg
buf := bytes.NewBuffer(make([]byte, len(loadingPage)+len(data.Title)+len(data.Message)+len(httpheaders.HeaderGoDoxyCheckRedirect)))
err := loadingPageTmpl.Execute(buf, data)

View file

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

View file

@ -7,6 +7,7 @@ import (
"strconv"
"time"
"github.com/yusing/go-proxy/internal/api/v1/favicon"
gphttp "github.com/yusing/go-proxy/internal/net/gphttp"
"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) {
w.resetIdleTimer()
@ -63,8 +68,15 @@ func (w *Watcher) wakeFromHTTP(rw http.ResponseWriter, r *http.Request) (shouldN
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
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)
return false
}
@ -88,7 +100,7 @@ func (w *Watcher) wakeFromHTTP(rw http.ResponseWriter, r *http.Request) (shouldN
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()
if w.cancelled(ctx, rw) {

View file

@ -61,7 +61,7 @@ func (w *Watcher) wakeFromStream() error {
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()
for {

View file

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