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"
|
"context"
|
||||||
"net/http"
|
"net/http"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/alexei/docker-watcher/internal/docker"
|
||||||
)
|
)
|
||||||
|
|
||||||
// getHealth handles GET /api/health.
|
// 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) {
|
func (s *Server) getHealth(w http.ResponseWriter, r *http.Request) {
|
||||||
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
|
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
dockerOK := false
|
now := time.Now().UTC().Format(time.RFC3339)
|
||||||
if s.docker != nil {
|
|
||||||
if err := s.docker.Ping(ctx); err == nil {
|
if s.docker == nil {
|
||||||
dockerOK = true
|
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{
|
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
|
||||||
|
}
|
||||||
+3
-2
@@ -3,6 +3,7 @@ import type {
|
|||||||
ContainerStats,
|
ContainerStats,
|
||||||
Deploy,
|
Deploy,
|
||||||
DeployLog,
|
DeployLog,
|
||||||
|
DockerHealth,
|
||||||
EventLogEntry,
|
EventLogEntry,
|
||||||
EventLogStats,
|
EventLogStats,
|
||||||
InspectResult,
|
InspectResult,
|
||||||
@@ -267,8 +268,8 @@ export function listNpmCertificates(): Promise<NpmCertificate[]> {
|
|||||||
|
|
||||||
// ── Health ──────────────────────────────────────────────────────────
|
// ── Health ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export function getHealth(): Promise<{ docker: boolean }> {
|
export function getHealth(): Promise<{ docker: DockerHealth }> {
|
||||||
return get<{ docker: boolean }>('/api/health');
|
return get<{ docker: DockerHealth }>('/api/health');
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Auth ─────────────────────────────────────────────────────────────
|
// ── Auth ─────────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -5,7 +5,9 @@
|
|||||||
},
|
},
|
||||||
"health": {
|
"health": {
|
||||||
"connected": "connected",
|
"connected": "connected",
|
||||||
"disconnected": "disconnected"
|
"disconnected": "disconnected",
|
||||||
|
"rawError": "Technical details",
|
||||||
|
"retryNow": "Retry now"
|
||||||
},
|
},
|
||||||
"nav": {
|
"nav": {
|
||||||
"dashboard": "Dashboard",
|
"dashboard": "Dashboard",
|
||||||
|
|||||||
@@ -5,7 +5,9 @@
|
|||||||
},
|
},
|
||||||
"health": {
|
"health": {
|
||||||
"connected": "подключён",
|
"connected": "подключён",
|
||||||
"disconnected": "отключён"
|
"disconnected": "отключён",
|
||||||
|
"rawError": "Технические детали",
|
||||||
|
"retryNow": "Проверить сейчас"
|
||||||
},
|
},
|
||||||
"nav": {
|
"nav": {
|
||||||
"dashboard": "Панель",
|
"dashboard": "Панель",
|
||||||
|
|||||||
@@ -172,6 +172,16 @@ export interface Volume {
|
|||||||
updated_at: string;
|
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. */
|
/** A persistent event log entry. */
|
||||||
export interface EventLogEntry {
|
export interface EventLogEntry {
|
||||||
id: number;
|
id: number;
|
||||||
|
|||||||
@@ -34,11 +34,16 @@
|
|||||||
return pathname.startsWith(href);
|
return pathname.startsWith(href);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
import type { DockerHealth } from '$lib/types';
|
||||||
|
|
||||||
let sseConnection: SSEConnection | null = null;
|
let sseConnection: SSEConnection | null = null;
|
||||||
let sidebarOpen = $state(false);
|
let sidebarOpen = $state(false);
|
||||||
let dockerConnected = $state<boolean | null>(null);
|
let dockerHealth = $state<DockerHealth | null>(null);
|
||||||
let healthChecked = $state(false);
|
let healthChecked = $state(false);
|
||||||
let healthInterval: ReturnType<typeof setInterval> | null = null;
|
let healthInterval: ReturnType<typeof setInterval> | null = null;
|
||||||
|
let hintsExpanded = $state(false);
|
||||||
|
|
||||||
|
const dockerConnected = $derived(dockerHealth?.connected ?? false);
|
||||||
|
|
||||||
// Hide sidebar and chrome on the login page.
|
// Hide sidebar and chrome on the login page.
|
||||||
const isLoginPage = $derived($page.url.pathname === '/login');
|
const isLoginPage = $derived($page.url.pathname === '/login');
|
||||||
@@ -91,9 +96,9 @@
|
|||||||
async function checkHealth() {
|
async function checkHealth() {
|
||||||
try {
|
try {
|
||||||
const h = await api.getHealth();
|
const h = await api.getHealth();
|
||||||
dockerConnected = h.docker;
|
dockerHealth = h.docker;
|
||||||
} catch {
|
} catch {
|
||||||
dockerConnected = false;
|
dockerHealth = { connected: false };
|
||||||
}
|
}
|
||||||
healthChecked = true;
|
healthChecked = true;
|
||||||
}
|
}
|
||||||
@@ -181,14 +186,56 @@
|
|||||||
<!-- Footer controls -->
|
<!-- Footer controls -->
|
||||||
<div class="space-y-3 border-t border-[var(--border-primary)] px-4 py-3">
|
<div class="space-y-3 border-t border-[var(--border-primary)] px-4 py-3">
|
||||||
{#if healthChecked}
|
{#if healthChecked}
|
||||||
<div class="flex items-center gap-2 rounded-md px-2 py-1.5 text-xs {dockerConnected ? 'text-emerald-600' : 'text-red-500'}">
|
<div class="rounded-md {dockerConnected ? '' : 'bg-red-50 dark:bg-red-950/30'}">
|
||||||
<span class="relative flex h-2 w-2">
|
<button
|
||||||
{#if dockerConnected}
|
type="button"
|
||||||
<span class="absolute inline-flex h-full w-full animate-ping rounded-full bg-emerald-400 opacity-50"></span>
|
class="flex w-full items-center gap-2 px-2 py-1.5 text-xs {dockerConnected ? 'text-emerald-600' : 'text-red-500 cursor-pointer'}"
|
||||||
|
onclick={() => { if (!dockerConnected) hintsExpanded = !hintsExpanded; }}
|
||||||
|
disabled={dockerConnected}
|
||||||
|
>
|
||||||
|
<span class="relative flex h-2 w-2">
|
||||||
|
{#if dockerConnected}
|
||||||
|
<span class="absolute inline-flex h-full w-full animate-ping rounded-full bg-emerald-400 opacity-50"></span>
|
||||||
|
{/if}
|
||||||
|
<span class="relative inline-flex h-2 w-2 rounded-full {dockerConnected ? 'bg-emerald-500' : 'bg-red-500'}"></span>
|
||||||
|
</span>
|
||||||
|
<span class="flex-1 text-left">Docker {dockerConnected ? $t('health.connected') : $t('health.disconnected')}</span>
|
||||||
|
{#if !dockerConnected}
|
||||||
|
<svg class="h-3 w-3 transition-transform {hintsExpanded ? 'rotate-180' : ''}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="6 9 12 15 18 9"/></svg>
|
||||||
{/if}
|
{/if}
|
||||||
<span class="relative inline-flex h-2 w-2 rounded-full {dockerConnected ? 'bg-emerald-500' : 'bg-red-500'}"></span>
|
</button>
|
||||||
</span>
|
{#if !dockerConnected && hintsExpanded && dockerHealth?.hints?.length}
|
||||||
Docker {dockerConnected ? $t('health.connected') : $t('health.disconnected')}
|
<div class="px-2 pb-2">
|
||||||
|
<ul class="space-y-1 text-[11px] text-red-600 dark:text-red-400">
|
||||||
|
{#each dockerHealth.hints as hint}
|
||||||
|
<li class="flex gap-1.5">
|
||||||
|
<span class="mt-0.5 shrink-0">•</span>
|
||||||
|
<span>{hint}</span>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
{#if dockerHealth.error}
|
||||||
|
<details class="mt-1.5">
|
||||||
|
<summary class="text-[10px] text-[var(--text-tertiary)] cursor-pointer">{$t('health.rawError')}</summary>
|
||||||
|
<code class="mt-1 block text-[10px] text-[var(--text-tertiary)] break-all">{dockerHealth.error}</code>
|
||||||
|
</details>
|
||||||
|
{/if}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="mt-2 w-full rounded border border-red-300 dark:border-red-700 px-2 py-1 text-[11px] font-medium text-red-600 dark:text-red-400 hover:bg-red-100 dark:hover:bg-red-900/30 transition-colors"
|
||||||
|
onclick={async () => {
|
||||||
|
try {
|
||||||
|
const h = await api.getHealth();
|
||||||
|
dockerHealth = h.docker;
|
||||||
|
} catch {
|
||||||
|
dockerHealth = { connected: false };
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{$t('health.retryNow')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
|
|||||||
Reference in New Issue
Block a user