diff --git a/internal/api/health.go b/internal/api/health.go new file mode 100644 index 0000000..ab61b87 --- /dev/null +++ b/internal/api/health.go @@ -0,0 +1,25 @@ +package api + +import ( + "context" + "net/http" + "time" +) + +// getHealth handles GET /api/health. +// Returns connectivity status for Docker and other services. +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 + } + } + + respondJSON(w, http.StatusOK, map[string]any{ + "docker": dockerOK, + }) +} diff --git a/internal/api/router.go b/internal/api/router.go index c1f01e4..0a431d6 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -130,6 +130,7 @@ func (s *Server) Router() chi.Router { r.Use(auth.Middleware(s.localAuth)) // Read-only endpoints (any authenticated user). + r.Get("/health", s.getHealth) r.Get("/auth/me", s.currentUser) r.Get("/projects", s.listProjects) r.Route("/projects/{id}", func(r chi.Router) { diff --git a/internal/stale/scanner.go b/internal/stale/scanner.go index da85e97..384dde7 100644 --- a/internal/stale/scanner.go +++ b/internal/stale/scanner.go @@ -216,7 +216,9 @@ func (s *Scanner) FindStaleInstances(ctx context.Context) ([]StaleInstance, erro containers, err := s.docker.ListContainers(ctx, nil) if err != nil { - return nil, fmt.Errorf("list docker containers: %w", err) + // Docker unavailable — fall back to store-only detection (no live state). + slog.Warn("stale scanner: docker unavailable, using store status only", "error", err) + containers = nil } containerStateByInstanceID := make(map[string]string, len(containers)) diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index 7031d38..1120c67 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -34,12 +34,7 @@ class ApiError extends Error { } } -function getAuthToken(): string | null { - if (typeof localStorage !== 'undefined') { - return localStorage.getItem('auth_token'); - } - return null; -} +import { getAuthToken, clearAuth } from './auth'; async function request(path: string, init?: RequestInit): Promise { const token = getAuthToken(); @@ -58,7 +53,7 @@ async function request(path: string, init?: RequestInit): Promise { // Redirect to login on 401 (expired/missing token). if (res.status === 401 && typeof window !== 'undefined' && !path.includes('/auth/')) { - localStorage.removeItem('auth_token'); + clearAuth(); window.location.href = '/login'; throw new ApiError('Authentication required', 401); } @@ -270,6 +265,12 @@ export function listNpmCertificates(): Promise { return get('/api/settings/npm-certificates'); } +// ── Health ────────────────────────────────────────────────────────── + +export function getHealth(): Promise<{ docker: boolean }> { + return get<{ docker: boolean }>('/api/health'); +} + // ── Auth ───────────────────────────────────────────────────────────── export function login(username: string, password: string): Promise<{ token: string; expires_at: string }> { diff --git a/web/src/lib/auth.ts b/web/src/lib/auth.ts new file mode 100644 index 0000000..3e16683 --- /dev/null +++ b/web/src/lib/auth.ts @@ -0,0 +1,30 @@ +/** Shared auth helpers for token management. */ + +const TOKEN_KEY = 'auth_token'; + +/** Returns the stored JWT token, or null if not authenticated. */ +export function getAuthToken(): string | null { + if (typeof localStorage !== 'undefined') { + return localStorage.getItem(TOKEN_KEY); + } + return null; +} + +/** Returns true if the user has a stored auth token. */ +export function isAuthenticated(): boolean { + return getAuthToken() !== null; +} + +/** Stores the JWT token after successful login. */ +export function setAuthToken(token: string): void { + if (typeof localStorage !== 'undefined') { + localStorage.setItem(TOKEN_KEY, token); + } +} + +/** Removes the stored token and redirects to login. */ +export function clearAuth(): void { + if (typeof localStorage !== 'undefined') { + localStorage.removeItem(TOKEN_KEY); + } +} diff --git a/web/src/lib/components/EventLogEntry.svelte b/web/src/lib/components/EventLogEntry.svelte index b3fb727..d7aadf3 100644 --- a/web/src/lib/components/EventLogEntry.svelte +++ b/web/src/lib/components/EventLogEntry.svelte @@ -1,6 +1,6 @@
-
- -
- {#if entry.source === 'deploy'} - - - - - - - {:else if entry.source === 'container'} - - - - - - {:else if entry.source === 'proxy'} - - - - - {:else} - - - - - - {/if} + +
+
+
+ + +
+ +
+ {$t(`events.source.${entry.source}`)} + · + + {$t(`events.severity.${entry.severity}`)} + + + {timeAgo(entry.created_at)} +
- -
-
- - - {$t(severityLabelKeys[entry.severity] ?? 'events.severity.info')} - + +

+ {entry.message} +

- - - {$t(`events.source.${entry.source}`)} - + + {#if hasMetadata} + - - - {timeAgo(entry.created_at)} - -
- - -

- {entry.message} -

- - - {#if hasMetadata} - - - {#if expanded} -
- - - {#each Object.entries(parsedMetadata ?? {}) as [key, value]} - - - - - {/each} - -
{key}{typeof value === 'object' ? JSON.stringify(value) : String(value)}
-
- {/if} + {#if expanded} +
+
+ {#each Object.entries(parsedMetadata ?? {}) as [key, value]} +
{key}
+
{typeof value === 'object' ? JSON.stringify(value) : String(value)}
+ {/each} +
+
{/if} -
+ {/if}
diff --git a/web/src/lib/components/EventLogFilter.svelte b/web/src/lib/components/EventLogFilter.svelte index 46652a6..c00b9e4 100644 --- a/web/src/lib/components/EventLogFilter.svelte +++ b/web/src/lib/components/EventLogFilter.svelte @@ -1,15 +1,18 @@ -
-
- -
- -
- {#each allSeverities as sev} - - {/each} -
-
+
+ +
+ + {#each allSeverities as sev} + {@const active = severities.includes(sev)} + {@const style = severityStyles[sev]} + + {/each} - -
- -
- {#each allSources as src} - - {/each} -
-
+ + - -
- -
- {#each dateRangeOptions as opt} - - {/each} -
+ + {#each allSources as src} + {@const active = sources.includes(src)} + + {/each} + + +
+ + + {#if activeFilterCount > 0} + + {/if} +
+ + +
+ +
+ {#each dateRangeOptions as opt} + + {/each}
-
- -
- - - - onsearchchange((e.target as HTMLInputElement).value)} - class="w-full rounded-md border border-[var(--border-primary)] bg-[var(--surface-page)] py-1.5 pl-8 pr-3 text-xs text-[var(--text-primary)] placeholder:text-[var(--text-tertiary)] focus:border-[var(--color-brand-500)] focus:outline-none focus:ring-1 focus:ring-[var(--color-brand-500)]" - /> -
-
- - -
- +
+ + + + onsearchchange((e.target as HTMLInputElement).value)} + class="w-full rounded-lg border border-[var(--border-primary)] bg-[var(--surface-card)] py-1.5 pl-8 pr-3 text-xs text-[var(--text-primary)] placeholder:text-[var(--text-tertiary)] focus:border-[var(--color-brand-400)] focus:outline-none focus:ring-1 focus:ring-[var(--color-brand-400)] transition-colors" + />
diff --git a/web/src/lib/i18n/en.json b/web/src/lib/i18n/en.json index aa5f03e..8218a4d 100644 --- a/web/src/lib/i18n/en.json +++ b/web/src/lib/i18n/en.json @@ -3,6 +3,10 @@ "name": "Docker Watcher", "version": "v0.1" }, + "health": { + "connected": "connected", + "disconnected": "disconnected" + }, "nav": { "dashboard": "Dashboard", "projects": "Projects", diff --git a/web/src/lib/i18n/ru.json b/web/src/lib/i18n/ru.json index 6431b55..1c1d2e4 100644 --- a/web/src/lib/i18n/ru.json +++ b/web/src/lib/i18n/ru.json @@ -3,6 +3,10 @@ "name": "Docker Watcher", "version": "v0.1" }, + "health": { + "connected": "подключён", + "disconnected": "отключён" + }, "nav": { "dashboard": "Панель", "projects": "Проекты", diff --git a/web/src/lib/sse.ts b/web/src/lib/sse.ts index 7f04651..aa301ad 100644 --- a/web/src/lib/sse.ts +++ b/web/src/lib/sse.ts @@ -5,6 +5,8 @@ * event streams (deploy logs and instance status changes). */ +import { getAuthToken } from './auth'; + // ── Types ────────────────────────────────────────────────────────── export type SSEEventType = 'deploy_log' | 'instance_status' | 'deploy_status' | 'event_log'; @@ -97,7 +99,7 @@ export function connectSSE(url: string, options: SSEOptions): SSEConnection { if (closed) return; // Append auth token as query param (EventSource doesn't support custom headers). - const token = typeof localStorage !== 'undefined' ? localStorage.getItem('auth_token') : null; + const token = getAuthToken(); const authUrl = token ? `${url}${url.includes('?') ? '&' : '?'}token=${encodeURIComponent(token)}` : url; eventSource = new EventSource(authUrl); diff --git a/web/src/routes/+layout.svelte b/web/src/routes/+layout.svelte index 4aa25d8..502b841 100644 --- a/web/src/routes/+layout.svelte +++ b/web/src/routes/+layout.svelte @@ -8,6 +8,8 @@ import LocaleSwitcher from '$lib/components/LocaleSwitcher.svelte'; import { IconDashboard, IconProjects, IconDeploy, IconProxies, IconEvents, IconSettings, IconMenu, IconX, IconLogout } from '$lib/components/icons'; import { connectGlobalEvents, type SSEConnection } from '$lib/sse'; + import { isAuthenticated, clearAuth } from '$lib/auth'; + import * as api from '$lib/api'; import { instanceStatusStore } from '$lib/stores/instance-status'; import { resolvedTheme, applyTheme } from '$lib/stores/theme'; import { t } from '$lib/i18n'; @@ -34,6 +36,8 @@ let sseConnection: SSEConnection | null = null; let sidebarOpen = $state(false); + let dockerConnected = $state(null); + let healthInterval: ReturnType | null = null; // Hide sidebar and chrome on the login page. const isLoginPage = $derived($page.url.pathname === '/login'); @@ -59,28 +63,41 @@ }); function logout() { - if (typeof localStorage !== 'undefined') { - localStorage.removeItem('auth_token'); - } + clearAuth(); sseConnection?.close(); sseConnection = null; window.location.href = '/login'; } onMount(() => { - sseConnection = connectGlobalEvents({ - onInstanceStatus(payload) { - instanceStatusStore.update(payload); - }, - onDeployStatus(payload) { - instanceStatusStore.notifyDeploy(payload); + if (isAuthenticated()) { + sseConnection = connectGlobalEvents({ + onInstanceStatus(payload) { + instanceStatusStore.update(payload); + }, + onDeployStatus(payload) { + instanceStatusStore.notifyDeploy(payload); + } + }); + + // Poll Docker health every 30s. + async function checkHealth() { + try { + const h = await api.getHealth(); + dockerConnected = h.docker; + } catch { + dockerConnected = null; + } } - }); + checkHealth(); + healthInterval = setInterval(checkHealth, 30_000); + } }); onDestroy(() => { sseConnection?.close(); sseConnection = null; + if (healthInterval) clearInterval(healthInterval); }); @@ -156,6 +173,17 @@
+ {#if dockerConnected !== null} +
+ + {#if dockerConnected} + + {/if} + + + Docker {dockerConnected ? $t('health.connected') : $t('health.disconnected')} +
+ {/if}
diff --git a/web/src/routes/deploy/+page.svelte b/web/src/routes/deploy/+page.svelte index 52df090..25cce0d 100644 --- a/web/src/routes/deploy/+page.svelte +++ b/web/src/routes/deploy/+page.svelte @@ -180,7 +180,7 @@ disabled={inspecting} />
-
+
-
- {/if}
+ + + {#if hasMore && searchText.trim() === ''} +
+ +
+ {/if} {/if}
diff --git a/web/src/routes/login/+page.svelte b/web/src/routes/login/+page.svelte index e44e1d6..816491c 100644 --- a/web/src/routes/login/+page.svelte +++ b/web/src/routes/login/+page.svelte @@ -5,6 +5,7 @@ import { t } from '$lib/i18n'; import { resolvedTheme, applyTheme } from '$lib/stores/theme'; import { IconLoader } from '$lib/components/icons'; + import { setAuthToken, isAuthenticated } from '$lib/auth'; let username = $state(''); let password = $state(''); @@ -25,7 +26,7 @@ headers: { 'Authorization': `Bearer ${urlToken}` } }); if (res.ok) { - localStorage.setItem('auth_token', urlToken); + setAuthToken(urlToken); // Remove token from URL to prevent leakage via history/referrer. history.replaceState(null, '', '/login'); goto('/'); @@ -37,8 +38,7 @@ // Remove invalid token from URL. history.replaceState(null, '', '/login'); } - const existingToken = localStorage.getItem('auth_token'); - if (existingToken) { + if (isAuthenticated()) { goto('/'); } }); @@ -57,7 +57,7 @@ error = envelope.error ?? $t('login.loginFailed'); return; } - localStorage.setItem('auth_token', envelope.data.token); + setAuthToken(envelope.data.token); goto('/'); } catch (err: unknown) { error = err instanceof Error ? err.message : $t('login.networkError'); diff --git a/web/src/routes/settings/auth/+page.svelte b/web/src/routes/settings/auth/+page.svelte index f40ba3d..ac74bab 100644 --- a/web/src/routes/settings/auth/+page.svelte +++ b/web/src/routes/settings/auth/+page.svelte @@ -3,6 +3,7 @@ import { t } from '$lib/i18n'; import { IconLoader, IconPlus, IconTrash, IconUsers } from '$lib/components/icons'; import EmptyState from '$lib/components/EmptyState.svelte'; + import { getAuthToken } from '$lib/auth'; interface AuthSettings { auth_mode: string; @@ -33,8 +34,7 @@ let newEmail = $state(''); let newRole = $state('viewer'); - function getToken(): string { return localStorage.getItem('auth_token') ?? ''; } - function authHeaders(): Record { return { 'Content-Type': 'application/json', Authorization: `Bearer ${getToken()}` }; } + function authHeaders(): Record { return { 'Content-Type': 'application/json', Authorization: `Bearer ${getAuthToken() ?? ''}` }; } onMount(async () => { await Promise.all([loadSettings(), loadUsers()]); });