From 1a8dfefa774ec56870bb3cfb331def07309383b9 Mon Sep 17 00:00:00 2001 From: "alexei.dolgolyov" Date: Mon, 30 Mar 2026 14:05:00 +0300 Subject: [PATCH] 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 --- internal/api/health.go | 45 +++++++-- internal/docker/diagnostics.go | 170 +++++++++++++++++++++++++++++++++ web/src/lib/api.ts | 5 +- web/src/lib/i18n/en.json | 4 +- web/src/lib/i18n/ru.json | 4 +- web/src/lib/types.ts | 10 ++ web/src/routes/+layout.svelte | 67 +++++++++++-- 7 files changed, 284 insertions(+), 21 deletions(-) create mode 100644 internal/docker/diagnostics.go diff --git a/internal/api/health.go b/internal/api/health.go index ab61b87..f033807 100644 --- a/internal/api/health.go +++ b/internal/api/health.go @@ -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, + }, }) } diff --git a/internal/docker/diagnostics.go b/internal/docker/diagnostics.go new file mode 100644 index 0000000..1043a24 --- /dev/null +++ b/internal/docker/diagnostics.go @@ -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 +} diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index 1120c67..43be503 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -3,6 +3,7 @@ import type { ContainerStats, Deploy, DeployLog, + DockerHealth, EventLogEntry, EventLogStats, InspectResult, @@ -267,8 +268,8 @@ export function listNpmCertificates(): Promise { // ── Health ────────────────────────────────────────────────────────── -export function getHealth(): Promise<{ docker: boolean }> { - return get<{ docker: boolean }>('/api/health'); +export function getHealth(): Promise<{ docker: DockerHealth }> { + return get<{ docker: DockerHealth }>('/api/health'); } // ── Auth ───────────────────────────────────────────────────────────── diff --git a/web/src/lib/i18n/en.json b/web/src/lib/i18n/en.json index 8218a4d..fb861f5 100644 --- a/web/src/lib/i18n/en.json +++ b/web/src/lib/i18n/en.json @@ -5,7 +5,9 @@ }, "health": { "connected": "connected", - "disconnected": "disconnected" + "disconnected": "disconnected", + "rawError": "Technical details", + "retryNow": "Retry now" }, "nav": { "dashboard": "Dashboard", diff --git a/web/src/lib/i18n/ru.json b/web/src/lib/i18n/ru.json index 1c1d2e4..4f129ee 100644 --- a/web/src/lib/i18n/ru.json +++ b/web/src/lib/i18n/ru.json @@ -5,7 +5,9 @@ }, "health": { "connected": "подключён", - "disconnected": "отключён" + "disconnected": "отключён", + "rawError": "Технические детали", + "retryNow": "Проверить сейчас" }, "nav": { "dashboard": "Панель", diff --git a/web/src/lib/types.ts b/web/src/lib/types.ts index d80dd0a..34b0dbb 100644 --- a/web/src/lib/types.ts +++ b/web/src/lib/types.ts @@ -172,6 +172,16 @@ export interface Volume { updated_at: string; } +/** Docker daemon health check result. */ +export interface DockerHealth { + connected: boolean; + error?: string; + category?: string; + hints?: string[]; + platform?: string; + checked_at?: string; +} + /** A persistent event log entry. */ export interface EventLogEntry { id: number; diff --git a/web/src/routes/+layout.svelte b/web/src/routes/+layout.svelte index 592bdd7..e2004e6 100644 --- a/web/src/routes/+layout.svelte +++ b/web/src/routes/+layout.svelte @@ -34,11 +34,16 @@ return pathname.startsWith(href); } + import type { DockerHealth } from '$lib/types'; + let sseConnection: SSEConnection | null = null; let sidebarOpen = $state(false); - let dockerConnected = $state(null); + let dockerHealth = $state(null); let healthChecked = $state(false); let healthInterval: ReturnType | null = null; + let hintsExpanded = $state(false); + + const dockerConnected = $derived(dockerHealth?.connected ?? false); // Hide sidebar and chrome on the login page. const isLoginPage = $derived($page.url.pathname === '/login'); @@ -91,9 +96,9 @@ async function checkHealth() { try { const h = await api.getHealth(); - dockerConnected = h.docker; + dockerHealth = h.docker; } catch { - dockerConnected = false; + dockerHealth = { connected: false }; } healthChecked = true; } @@ -181,14 +186,56 @@
{#if healthChecked} -
- - {#if dockerConnected} - +
+ + {#if !dockerConnected && hintsExpanded && dockerHealth?.hints?.length} +
+
    + {#each dockerHealth.hints as hint} +
  • + + {hint} +
  • + {/each} +
+ {#if dockerHealth.error} +
+ {$t('health.rawError')} + {dockerHealth.error} +
+ {/if} + +
+ {/if}
{/if}