feat: Docker diagnostic hints on disconnection
- Classify Docker errors into categories (socket_not_found, connection_refused, permission_denied, timeout, tls_error) with platform-specific hints - Enrich GET /api/health with structured diagnostics (category, hints, platform) - Expandable hints panel in sidebar when Docker is disconnected - "Retry now" button for immediate re-check - Collapsible raw error details for advanced users
This commit is contained in:
+38
-7
@@ -4,22 +4,53 @@ import (
|
||||
"context"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/alexei/docker-watcher/internal/docker"
|
||||
)
|
||||
|
||||
// getHealth handles GET /api/health.
|
||||
// Returns connectivity status for Docker and other services.
|
||||
// Returns connectivity status for Docker with diagnostic hints on failure.
|
||||
func (s *Server) getHealth(w http.ResponseWriter, r *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
dockerOK := false
|
||||
if s.docker != nil {
|
||||
if err := s.docker.Ping(ctx); err == nil {
|
||||
dockerOK = true
|
||||
}
|
||||
now := time.Now().UTC().Format(time.RFC3339)
|
||||
|
||||
if s.docker == nil {
|
||||
diag := docker.Diagnose(nil, "")
|
||||
respondJSON(w, http.StatusOK, map[string]any{
|
||||
"docker": map[string]any{
|
||||
"connected": false,
|
||||
"error": "docker client not initialized",
|
||||
"category": diag.Category,
|
||||
"hints": diag.Hints,
|
||||
"platform": diag.Platform,
|
||||
"checked_at": now,
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
err := s.docker.Ping(ctx)
|
||||
if err == nil {
|
||||
respondJSON(w, http.StatusOK, map[string]any{
|
||||
"docker": map[string]any{
|
||||
"connected": true,
|
||||
"checked_at": now,
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
diag := docker.Diagnose(err, "")
|
||||
respondJSON(w, http.StatusOK, map[string]any{
|
||||
"docker": dockerOK,
|
||||
"docker": map[string]any{
|
||||
"connected": false,
|
||||
"error": err.Error(),
|
||||
"category": diag.Category,
|
||||
"hints": diag.Hints,
|
||||
"platform": diag.Platform,
|
||||
"checked_at": now,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
package docker
|
||||
|
||||
import (
|
||||
"runtime"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// DiagCategory classifies a Docker connectivity error.
|
||||
type DiagCategory string
|
||||
|
||||
const (
|
||||
DiagSocketNotFound DiagCategory = "socket_not_found"
|
||||
DiagConnectionRefused DiagCategory = "connection_refused"
|
||||
DiagPermissionDenied DiagCategory = "permission_denied"
|
||||
DiagTimeout DiagCategory = "timeout"
|
||||
DiagTLSError DiagCategory = "tls_error"
|
||||
DiagUnknown DiagCategory = "unknown"
|
||||
)
|
||||
|
||||
// Diagnostic holds a classified error with platform-specific hints.
|
||||
type Diagnostic struct {
|
||||
Category DiagCategory `json:"category"`
|
||||
Hints []string `json:"hints"`
|
||||
Platform string `json:"platform"`
|
||||
}
|
||||
|
||||
// Diagnose classifies a Docker Ping error and returns platform-aware hints.
|
||||
// Pure function — takes an error and platform, returns structured diagnostics.
|
||||
func Diagnose(err error, platform string) Diagnostic {
|
||||
if platform == "" {
|
||||
platform = runtime.GOOS
|
||||
}
|
||||
|
||||
msg := ""
|
||||
if err != nil {
|
||||
msg = strings.ToLower(err.Error())
|
||||
}
|
||||
|
||||
cat := classifyError(msg)
|
||||
|
||||
return Diagnostic{
|
||||
Category: cat,
|
||||
Hints: hintsFor(cat, platform),
|
||||
Platform: platform,
|
||||
}
|
||||
}
|
||||
|
||||
func classifyError(msg string) DiagCategory {
|
||||
switch {
|
||||
case containsAny(msg, "no such file or directory", "cannot find the file specified", "the system cannot find"):
|
||||
return DiagSocketNotFound
|
||||
case containsAny(msg, "connection refused"):
|
||||
return DiagConnectionRefused
|
||||
case containsAny(msg, "permission denied", "access is denied"):
|
||||
return DiagPermissionDenied
|
||||
case containsAny(msg, "context deadline exceeded", "i/o timeout", "timeout"):
|
||||
return DiagTimeout
|
||||
case containsAny(msg, "tls:", "certificate"):
|
||||
return DiagTLSError
|
||||
default:
|
||||
return DiagUnknown
|
||||
}
|
||||
}
|
||||
|
||||
func containsAny(s string, substrs ...string) bool {
|
||||
for _, sub := range substrs {
|
||||
if strings.Contains(s, sub) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
var hintTable = map[DiagCategory]map[string][]string{
|
||||
DiagSocketNotFound: {
|
||||
"windows": {
|
||||
"Docker Desktop does not appear to be running.",
|
||||
"Start Docker Desktop from the Start Menu or system tray.",
|
||||
"If using a custom socket path, check the DOCKER_HOST environment variable.",
|
||||
},
|
||||
"linux": {
|
||||
"Docker daemon is not running.",
|
||||
"Start it with: sudo systemctl start docker",
|
||||
"If using a custom socket path, check the DOCKER_HOST environment variable.",
|
||||
},
|
||||
"darwin": {
|
||||
"Docker Desktop does not appear to be running.",
|
||||
"Start it from Applications or run: open -a Docker",
|
||||
"If using a custom socket path, check the DOCKER_HOST environment variable.",
|
||||
},
|
||||
},
|
||||
DiagConnectionRefused: {
|
||||
"windows": {
|
||||
"Docker Desktop is starting up — wait ~30 seconds and retry.",
|
||||
"If it persists, restart Docker Desktop.",
|
||||
},
|
||||
"linux": {
|
||||
"Docker daemon is starting up.",
|
||||
"Check status with: sudo systemctl status docker",
|
||||
},
|
||||
"darwin": {
|
||||
"Docker Desktop is starting up.",
|
||||
"Check the whale icon in the menu bar for status.",
|
||||
},
|
||||
},
|
||||
DiagPermissionDenied: {
|
||||
"windows": {
|
||||
"Run the application as Administrator, or add your user to the docker-users group.",
|
||||
},
|
||||
"linux": {
|
||||
"Add your user to the docker group: sudo usermod -aG docker $USER",
|
||||
"Then log out and log back in for the change to take effect.",
|
||||
},
|
||||
"darwin": {
|
||||
"Check Docker Desktop settings under Resources > File Sharing.",
|
||||
},
|
||||
},
|
||||
DiagTimeout: {
|
||||
"windows": {
|
||||
"Docker Desktop may be overloaded or hanging.",
|
||||
"Try restarting Docker Desktop.",
|
||||
},
|
||||
"linux": {
|
||||
"Docker daemon may be overloaded.",
|
||||
"Check logs with: journalctl -u docker --no-pager -n 50",
|
||||
},
|
||||
"darwin": {
|
||||
"Docker Desktop may be unresponsive.",
|
||||
"Restart it from the menu bar whale icon.",
|
||||
},
|
||||
},
|
||||
DiagTLSError: {
|
||||
"windows": {
|
||||
"Check Docker TLS certificate configuration.",
|
||||
"Verify the DOCKER_TLS_VERIFY environment variable.",
|
||||
},
|
||||
"linux": {
|
||||
"Verify certificates in ~/.docker/ match the daemon configuration.",
|
||||
},
|
||||
"darwin": {
|
||||
"Check ~/.docker/ TLS configuration.",
|
||||
},
|
||||
},
|
||||
DiagUnknown: {
|
||||
"windows": {
|
||||
"An unexpected Docker error occurred.",
|
||||
"Try restarting Docker Desktop.",
|
||||
},
|
||||
"linux": {
|
||||
"An unexpected Docker error occurred.",
|
||||
"Check daemon status with: sudo systemctl status docker",
|
||||
},
|
||||
"darwin": {
|
||||
"An unexpected Docker error occurred.",
|
||||
"Try restarting Docker Desktop.",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
func hintsFor(cat DiagCategory, platform string) []string {
|
||||
catHints, ok := hintTable[cat]
|
||||
if !ok {
|
||||
catHints = hintTable[DiagUnknown]
|
||||
}
|
||||
hints, ok := catHints[platform]
|
||||
if !ok {
|
||||
hints = catHints["linux"] // fallback
|
||||
}
|
||||
return hints
|
||||
}
|
||||
Reference in New Issue
Block a user