feat: global Docker health indicator and graceful degradation

- GET /api/health endpoint returning Docker connectivity status
- Sidebar shows Docker connection dot (green=connected, red=disconnected)
- Stale scanner returns store-only results when Docker is unavailable
- Polls health every 30s
This commit is contained in:
2026-03-30 13:43:33 +03:00
parent b57b164be0
commit 37cfa090ac
15 changed files with 317 additions and 277 deletions
+25
View File
@@ -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,
})
}
+1
View File
@@ -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) {
+3 -1
View File
@@ -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))
+8 -7
View File
@@ -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<T>(path: string, init?: RequestInit): Promise<T> {
const token = getAuthToken();
@@ -58,7 +53,7 @@ async function request<T>(path: string, init?: RequestInit): Promise<T> {
// 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<NpmCertificate[]> {
return get<NpmCertificate[]>('/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 }> {
+30
View File
@@ -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);
}
}
+57 -101
View File
@@ -1,6 +1,6 @@
<!--
Event log entry display component.
Shows timestamp, severity badge, source icon, message, and expandable metadata.
Event log entry — timeline style.
Left severity color bar, compact inline layout, expandable metadata.
-->
<script lang="ts">
import type { EventLogEntry } from '$lib/types';
@@ -15,14 +15,10 @@
let expanded = $state(false);
// ── Relative time formatting ──────────────────────────────────
function timeAgo(dateStr: string): string {
const now = Date.now();
const then = new Date(dateStr).getTime();
const diffMs = now - then;
const diffSec = Math.floor(diffMs / 1000);
const diffSec = Math.floor((now - then) / 1000);
if (diffSec < 60) return `${diffSec}s ago`;
const diffMin = Math.floor(diffSec / 60);
if (diffMin < 60) return `${diffMin}m ago`;
@@ -30,30 +26,25 @@
if (diffHour < 24) return `${diffHour}h ago`;
const diffDay = Math.floor(diffHour / 24);
if (diffDay < 30) return `${diffDay}d ago`;
const diffMonth = Math.floor(diffDay / 30);
return `${diffMonth}mo ago`;
return `${Math.floor(diffDay / 30)}mo ago`;
}
function formatFull(dateStr: string): string {
return new Date(dateStr).toLocaleString();
}
// ── Severity styling ──────────────────────────────────────────
const severityClasses: Record<string, string> = {
info: 'bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300',
warn: 'bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-300',
error: 'bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-300'
const severityBar: Record<string, string> = {
info: 'bg-blue-400 dark:bg-blue-500',
warn: 'bg-amber-400 dark:bg-amber-500',
error: 'bg-red-400 dark:bg-red-500'
};
const severityLabelKeys: Record<string, string> = {
info: 'events.severity.info',
warn: 'events.severity.warn',
error: 'events.severity.error'
const severityBadge: Record<string, string> = {
info: 'text-blue-600 dark:text-blue-400',
warn: 'text-amber-600 dark:text-amber-400',
error: 'text-red-600 dark:text-red-400'
};
// ── Metadata parsing ──────────────────────────────────────────
const parsedMetadata = $derived.by<Record<string, unknown> | null>(() => {
if (!entry.metadata || entry.metadata === '{}' || entry.metadata === 'null') return null;
try {
@@ -71,91 +62,56 @@
</script>
<div
class="group rounded-lg border border-[var(--border-primary)] bg-[var(--surface-card)] px-4 py-3 transition-all duration-200 hover:border-[var(--border-secondary)]
{isNew ? 'animate-fade-in ring-2 ring-[var(--color-brand-200)] dark:ring-[var(--color-brand-800)]' : ''}"
class="group relative flex gap-3 py-2.5 px-3 rounded-lg transition-colors duration-150 hover:bg-[var(--surface-card-hover)]
{isNew ? 'animate-fade-in bg-[var(--color-brand-50)] dark:bg-[var(--color-brand-900)]/10' : ''}"
>
<div class="flex items-start gap-3">
<!-- Source icon -->
<div class="mt-0.5 flex h-7 w-7 shrink-0 items-center justify-center rounded-md bg-[var(--surface-card-hover)] text-[var(--text-tertiary)]">
{#if entry.source === 'deploy'}
<!-- Rocket -->
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M4.5 16.5c-1.5 1.26-2 5-2 5s3.74-.5 5-2c.71-.84.7-2.13-.09-2.91a2.18 2.18 0 0 0-2.91-.09Z" />
<path d="m12 15-3-3a22 22 0 0 1 2-3.95A12.88 12.88 0 0 1 22 2c0 2.72-.78 7.5-6 11a22.35 22.35 0 0 1-4 2Z" />
<path d="M9 12H4s.55-3.03 2-4c1.62-1.08 3 0 3 0" /><path d="M12 15v5s3.03-.55 4-2c1.08-1.62 0-3 0-3" />
</svg>
{:else if entry.source === 'container'}
<!-- Box -->
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16Z" />
<path d="m3.3 7 8.7 5 8.7-5" /><path d="M12 22V12" />
</svg>
{:else if entry.source === 'proxy'}
<!-- Globe -->
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="10" /><path d="M12 2a14.5 14.5 0 0 0 0 20 14.5 14.5 0 0 0 0-20" /><path d="M2 12h20" />
</svg>
{:else}
<!-- Cog (system) -->
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z" />
<circle cx="12" cy="12" r="3" />
</svg>
{/if}
<!-- Severity color bar -->
<div class="shrink-0 pt-0.5">
<div class="w-0.5 h-full min-h-[1.5rem] rounded-full {severityBar[entry.severity] ?? severityBar.info} opacity-70"></div>
</div>
<!-- Content -->
<div class="min-w-0 flex-1">
<!-- Header: source + severity + timestamp -->
<div class="flex items-center gap-1.5 text-xs leading-none">
<span class="text-[var(--text-tertiary)]">{$t(`events.source.${entry.source}`)}</span>
<span class="text-[var(--text-tertiary)]">&middot;</span>
<span class="font-medium {severityBadge[entry.severity] ?? severityBadge.info}">
{$t(`events.severity.${entry.severity}`)}
</span>
<span class="ml-auto shrink-0 text-[var(--text-tertiary)] tabular-nums" title={formatFull(entry.created_at)}>
{timeAgo(entry.created_at)}
</span>
</div>
<!-- Content -->
<div class="min-w-0 flex-1">
<div class="flex items-center gap-2 flex-wrap">
<!-- Severity badge -->
<span class="inline-flex items-center rounded-md px-1.5 py-0.5 text-xs font-medium {severityClasses[entry.severity] ?? severityClasses.info}">
{$t(severityLabelKeys[entry.severity] ?? 'events.severity.info')}
</span>
<!-- Message -->
<p class="mt-1 text-sm text-[var(--text-primary)] leading-relaxed">
{entry.message}
</p>
<!-- Source label -->
<span class="text-xs text-[var(--text-tertiary)]">
{$t(`events.source.${entry.source}`)}
</span>
<!-- Expandable metadata -->
{#if hasMetadata}
<button
type="button"
class="mt-1 inline-flex items-center gap-1 text-[11px] font-medium text-[var(--text-tertiary)] hover:text-[var(--color-brand-600)] transition-colors"
onclick={() => { expanded = !expanded; }}
>
<svg class="h-2.5 w-2.5 transition-transform duration-150 {expanded ? 'rotate-90' : ''}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
<path d="m9 18 6-6-6-6" />
</svg>
{$t('events.metadata')}
</button>
<!-- Timestamp -->
<span class="ml-auto shrink-0 text-xs text-[var(--text-tertiary)]" title={formatFull(entry.created_at)}>
{timeAgo(entry.created_at)}
</span>
</div>
<!-- Message -->
<p class="mt-1 text-sm text-[var(--text-primary)] leading-relaxed">
{entry.message}
</p>
<!-- Expandable metadata -->
{#if hasMetadata}
<button
type="button"
class="mt-1.5 inline-flex items-center gap-1 text-xs font-medium text-[var(--color-brand-600)] hover:text-[var(--color-brand-700)] transition-colors"
onclick={() => { expanded = !expanded; }}
>
<svg class="h-3 w-3 transition-transform duration-200 {expanded ? 'rotate-90' : ''}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="m9 18 6-6-6-6" />
</svg>
{$t('events.metadata')}
</button>
{#if expanded}
<div class="mt-2 rounded-md border border-[var(--border-primary)] bg-[var(--surface-page)] p-3 animate-fade-in">
<table class="w-full text-xs">
<tbody>
{#each Object.entries(parsedMetadata ?? {}) as [key, value]}
<tr class="border-b border-[var(--border-primary)] last:border-0">
<td class="py-1 pr-3 font-medium text-[var(--text-secondary)] whitespace-nowrap align-top">{key}</td>
<td class="py-1 text-[var(--text-primary)] break-all">{typeof value === 'object' ? JSON.stringify(value) : String(value)}</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
{#if expanded}
<div class="mt-1.5 rounded-md bg-[var(--surface-page)] border border-[var(--border-primary)] p-2.5 animate-fade-in">
<dl class="grid grid-cols-[auto_1fr] gap-x-3 gap-y-1 text-xs">
{#each Object.entries(parsedMetadata ?? {}) as [key, value]}
<dt class="font-medium text-[var(--text-tertiary)] whitespace-nowrap">{key}</dt>
<dd class="text-[var(--text-secondary)] break-all font-mono text-[11px]">{typeof value === 'object' ? JSON.stringify(value) : String(value)}</dd>
{/each}
</dl>
</div>
{/if}
</div>
{/if}
</div>
</div>
+106 -97
View File
@@ -1,15 +1,18 @@
<!--
Event log filter controls component.
Severity + source multi-select, date range presets, free-text search.
Event log filter controls.
Compact pill-based severity & source toggles, date range presets, search.
Severity pills double as stats display when counts are provided.
-->
<script lang="ts">
import { t } from '$lib/i18n';
import type { EventLogStats } from '$lib/types';
interface Props {
severities: string[];
sources: string[];
dateRange: string;
searchText: string;
stats?: EventLogStats;
onseveritieschange: (v: string[]) => void;
onsourceschange: (v: string[]) => void;
ondaterangechange: (v: string) => void;
@@ -22,6 +25,7 @@
sources,
dateRange,
searchText,
stats,
onseveritieschange,
onsourceschange,
ondaterangechange,
@@ -39,7 +43,24 @@
{ value: 'all', labelKey: 'events.filter.allTime' }
] as const;
// ── Active filter count ──────────────────────────────────────
// Severity pill styling
const severityStyles: Record<string, { active: string; inactive: string; dot: string }> = {
info: {
active: 'bg-blue-100 text-blue-700 ring-1 ring-blue-300 dark:bg-blue-900/40 dark:text-blue-300 dark:ring-blue-700',
inactive: 'bg-[var(--surface-card-hover)] text-[var(--text-tertiary)] line-through decoration-1',
dot: 'bg-blue-500'
},
warn: {
active: 'bg-amber-100 text-amber-700 ring-1 ring-amber-300 dark:bg-amber-900/40 dark:text-amber-300 dark:ring-amber-700',
inactive: 'bg-[var(--surface-card-hover)] text-[var(--text-tertiary)] line-through decoration-1',
dot: 'bg-amber-500'
},
error: {
active: 'bg-red-100 text-red-700 ring-1 ring-red-300 dark:bg-red-900/40 dark:text-red-300 dark:ring-red-700',
inactive: 'bg-[var(--surface-card-hover)] text-[var(--text-tertiary)] line-through decoration-1',
dot: 'bg-red-500'
}
};
const activeFilterCount = $derived(
(severities.length < allSeverities.length ? 1 : 0) +
@@ -48,8 +69,6 @@
(searchText.trim() !== '' ? 1 : 0)
);
// ── Toggle helpers ───────────────────────────────────────────
function toggleSeverity(sev: string): void {
const next = severities.includes(sev)
? severities.filter((s) => s !== sev)
@@ -63,105 +82,95 @@
: [...sources, src];
if (next.length > 0) onsourceschange(next);
}
// ── Severity checkbox colors ─────────────────────────────────
const severityCheckboxColors: Record<string, string> = {
info: 'accent-blue-600',
warn: 'accent-amber-600',
error: 'accent-red-600'
};
</script>
<div class="rounded-lg border border-[var(--border-primary)] bg-[var(--surface-card)] p-4">
<div class="flex flex-col gap-4 lg:flex-row lg:items-end lg:gap-6">
<!-- Severity filter -->
<div class="space-y-1.5">
<label class="text-xs font-medium text-[var(--text-secondary)]">{$t('events.filter.severity')}</label>
<div class="flex items-center gap-3">
{#each allSeverities as sev}
<label class="flex items-center gap-1.5 cursor-pointer select-none">
<input
type="checkbox"
checked={severities.includes(sev)}
onchange={() => toggleSeverity(sev)}
class="h-3.5 w-3.5 rounded border-[var(--border-primary)] {severityCheckboxColors[sev]}"
/>
<span class="text-xs text-[var(--text-primary)]">{$t(`events.severity.${sev}`)}</span>
</label>
{/each}
</div>
</div>
<div class="space-y-3">
<!-- Row 1: Severity pills (with counts) + search -->
<div class="flex items-center gap-2 flex-wrap">
<!-- Severity toggle pills -->
{#each allSeverities as sev}
{@const active = severities.includes(sev)}
{@const style = severityStyles[sev]}
<button
type="button"
class="inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs font-medium transition-all duration-150 select-none cursor-pointer
{active ? style.active : style.inactive}"
onclick={() => toggleSeverity(sev)}
>
<span class="h-1.5 w-1.5 rounded-full {active ? style.dot : 'bg-[var(--text-tertiary)] opacity-40'}"></span>
{$t(`events.severity.${sev}`)}
{#if stats}
<span class="tabular-nums font-semibold {active ? '' : 'opacity-50'}">{stats[sev as keyof EventLogStats] ?? 0}</span>
{/if}
</button>
{/each}
<!-- Source filter -->
<div class="space-y-1.5">
<label class="text-xs font-medium text-[var(--text-secondary)]">{$t('events.filter.source')}</label>
<div class="flex items-center gap-3">
{#each allSources as src}
<label class="flex items-center gap-1.5 cursor-pointer select-none">
<input
type="checkbox"
checked={sources.includes(src)}
onchange={() => toggleSource(src)}
class="h-3.5 w-3.5 rounded border-[var(--border-primary)] accent-[var(--color-brand-600)]"
/>
<span class="text-xs text-[var(--text-primary)]">{$t(`events.source.${src}`)}</span>
</label>
{/each}
</div>
</div>
<!-- Divider -->
<div class="h-4 w-px bg-[var(--border-primary)] mx-0.5 hidden sm:block"></div>
<!-- Date range -->
<div class="space-y-1.5">
<label class="text-xs font-medium text-[var(--text-secondary)]">{$t('events.filter.dateRange')}</label>
<div class="flex items-center gap-1">
{#each dateRangeOptions as opt}
<button
type="button"
class="rounded-md px-2 py-1 text-xs font-medium transition-colors
{dateRange === opt.value
? 'bg-[var(--color-brand-600)] text-white'
: 'bg-[var(--surface-card-hover)] text-[var(--text-secondary)] hover:text-[var(--text-primary)]'}"
onclick={() => ondaterangechange(opt.value)}
>
{$t(opt.labelKey)}
</button>
{/each}
</div>
<!-- Source toggle pills -->
{#each allSources as src}
{@const active = sources.includes(src)}
<button
type="button"
class="inline-flex items-center gap-1 rounded-full px-2.5 py-1 text-xs font-medium transition-all duration-150 select-none cursor-pointer
{active
? 'bg-[var(--surface-card)] text-[var(--text-primary)] ring-1 ring-[var(--border-primary)] shadow-[var(--shadow-sm)]'
: 'bg-[var(--surface-card-hover)] text-[var(--text-tertiary)] line-through decoration-1'}"
onclick={() => toggleSource(src)}
>
{$t(`events.source.${src}`)}
</button>
{/each}
<!-- Spacer -->
<div class="flex-1"></div>
<!-- Clear filters -->
{#if activeFilterCount > 0}
<button
type="button"
class="inline-flex items-center gap-1 rounded-full px-2 py-1 text-xs font-medium text-[var(--text-tertiary)] hover:text-[var(--text-primary)] hover:bg-[var(--surface-card-hover)] transition-colors"
onclick={onclear}
>
<svg class="h-3 w-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M18 6 6 18"/><path d="m6 6 12 12"/>
</svg>
{$t('events.filter.clear')}
</button>
{/if}
</div>
<!-- Row 2: Date range + search -->
<div class="flex items-center gap-2 flex-wrap">
<!-- Date range pills -->
<div class="inline-flex items-center rounded-lg bg-[var(--surface-card-hover)] p-0.5">
{#each dateRangeOptions as opt}
<button
type="button"
class="rounded-md px-2.5 py-1 text-xs font-medium transition-all duration-150
{dateRange === opt.value
? 'bg-[var(--surface-card)] text-[var(--text-primary)] shadow-[var(--shadow-sm)]'
: 'text-[var(--text-tertiary)] hover:text-[var(--text-secondary)]'}"
onclick={() => ondaterangechange(opt.value)}
>
{$t(opt.labelKey)}
</button>
{/each}
</div>
<!-- Search -->
<div class="flex-1 space-y-1.5 min-w-0">
<label class="text-xs font-medium text-[var(--text-secondary)]">&nbsp;</label>
<div class="relative">
<svg class="absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-[var(--text-tertiary)]" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="11" cy="11" r="8" /><path d="m21 21-4.3-4.3" />
</svg>
<input
type="text"
placeholder={$t('events.filter.search')}
value={searchText}
oninput={(e) => 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)]"
/>
</div>
</div>
<!-- Clear button + active count -->
<div class="flex items-end">
<button
type="button"
class="inline-flex items-center gap-1.5 rounded-md px-3 py-1.5 text-xs font-medium text-[var(--text-secondary)] hover:bg-[var(--surface-card-hover)] hover:text-[var(--text-primary)] transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
onclick={onclear}
disabled={activeFilterCount === 0}
>
{$t('events.filter.clear')}
{#if activeFilterCount > 0}
<span class="inline-flex h-4 w-4 items-center justify-center rounded-full bg-[var(--color-brand-600)] text-[10px] font-bold text-white">
{activeFilterCount}
</span>
{/if}
</button>
<div class="relative flex-1 min-w-[180px] max-w-sm">
<svg class="absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-[var(--text-tertiary)]" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="11" cy="11" r="8" /><path d="m21 21-4.3-4.3" />
</svg>
<input
type="text"
placeholder={$t('events.filter.search')}
value={searchText}
oninput={(e) => 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"
/>
</div>
</div>
</div>
+4
View File
@@ -3,6 +3,10 @@
"name": "Docker Watcher",
"version": "v0.1"
},
"health": {
"connected": "connected",
"disconnected": "disconnected"
},
"nav": {
"dashboard": "Dashboard",
"projects": "Projects",
+4
View File
@@ -3,6 +3,10 @@
"name": "Docker Watcher",
"version": "v0.1"
},
"health": {
"connected": "подключён",
"disconnected": "отключён"
},
"nav": {
"dashboard": "Панель",
"projects": "Проекты",
+3 -1
View File
@@ -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);
+38 -10
View File
@@ -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<boolean | null>(null);
let healthInterval: ReturnType<typeof setInterval> | 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);
});
</script>
@@ -156,6 +173,17 @@
<!-- Footer controls -->
<div class="space-y-3 border-t border-[var(--border-primary)] px-4 py-3">
{#if dockerConnected !== null}
<div class="flex items-center gap-2 rounded-md px-2 py-1.5 text-xs {dockerConnected ? 'text-emerald-600' : 'text-red-500'}">
<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>
Docker {dockerConnected ? $t('health.connected') : $t('health.disconnected')}
</div>
{/if}
<div class="flex items-center justify-between">
<ThemeToggle />
<LocaleSwitcher />
+1 -1
View File
@@ -180,7 +180,7 @@
disabled={inspecting}
/>
</div>
<div class="flex items-end gap-2">
<div class="flex items-start gap-2 pt-[26px]">
<button
type="button"
onclick={handleBrowseImages}
+31 -53
View File
@@ -1,6 +1,6 @@
<!--
Event Log page.
Displays a filterable, paginated, real-time event log.
Filterable, paginated, real-time event log with timeline-style entries.
-->
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
@@ -82,7 +82,7 @@
hasMore = result.length === PAGE_SIZE;
offset = (append ? currentOffset : 0) + result.length;
} catch {
// Error silently for now — user sees empty state.
// Error silently — user sees empty state.
} finally {
loading = false;
loadingMore = false;
@@ -162,7 +162,6 @@
} else {
events = [newEntry, ...events];
newEventIds = new Set([...newEventIds, newEntry.id]);
// Clear "new" highlight after animation.
setTimeout(() => {
newEventIds = new Set([...newEventIds].filter((id) => id !== newEntry.id));
}, 3000);
@@ -175,10 +174,8 @@
newEventIds = ids;
pendingNewEvents = [];
// Scroll to top.
listEl?.scrollTo({ top: 0, behavior: 'smooth' });
// Clear highlights after animation.
setTimeout(() => {
newEventIds = new Set();
}, 3000);
@@ -210,41 +207,22 @@
});
</script>
<div class="space-y-6">
<div class="space-y-4">
<!-- Header -->
<div>
<div class="flex items-center justify-between">
<h1 class="text-2xl font-bold text-[var(--text-primary)]">{$t('events.title')}</h1>
{#if stats.total > 0}
<span class="text-xs text-[var(--text-tertiary)] tabular-nums">{stats.total} total</span>
{/if}
</div>
<!-- Stats bar -->
<div class="flex items-center gap-3 flex-wrap">
<div class="flex items-center gap-1.5 rounded-md bg-blue-50 px-2.5 py-1 dark:bg-blue-900/30">
<div class="h-2 w-2 rounded-full bg-blue-500"></div>
<span class="text-xs font-medium text-blue-700 dark:text-blue-300">{$t('events.severity.info')}</span>
<span class="text-xs font-bold text-blue-800 dark:text-blue-200">{stats.info}</span>
</div>
<div class="flex items-center gap-1.5 rounded-md bg-amber-50 px-2.5 py-1 dark:bg-amber-900/30">
<div class="h-2 w-2 rounded-full bg-amber-500"></div>
<span class="text-xs font-medium text-amber-700 dark:text-amber-300">{$t('events.severity.warn')}</span>
<span class="text-xs font-bold text-amber-800 dark:text-amber-200">{stats.warn}</span>
</div>
<div class="flex items-center gap-1.5 rounded-md bg-red-50 px-2.5 py-1 dark:bg-red-900/30">
<div class="h-2 w-2 rounded-full bg-red-500"></div>
<span class="text-xs font-medium text-red-700 dark:text-red-300">{$t('events.severity.error')}</span>
<span class="text-xs font-bold text-red-800 dark:text-red-200">{stats.error}</span>
</div>
<div class="flex items-center gap-1.5 rounded-md bg-[var(--surface-card-hover)] px-2.5 py-1">
<span class="text-xs text-[var(--text-secondary)]">Total</span>
<span class="text-xs font-bold text-[var(--text-primary)]">{stats.total}</span>
</div>
</div>
<!-- Filter bar -->
<!-- Filter bar (includes severity stats as pill counts) -->
<EventLogFilter
{severities}
{sources}
{dateRange}
{searchText}
{stats}
onseveritieschange={handleSeveritiesChange}
onsourceschange={handleSourcesChange}
ondaterangechange={handleDateRangeChange}
@@ -266,7 +244,7 @@
<!-- Event list -->
{#if loading}
<div class="flex items-center justify-center py-16">
<svg class="h-6 w-6 animate-spin text-[var(--color-brand-600)]" viewBox="0 0 24 24" fill="none">
<svg class="h-5 w-5 animate-spin text-[var(--color-brand-500)]" viewBox="0 0 24 24" fill="none">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
</svg>
@@ -281,7 +259,7 @@
<div
bind:this={listEl}
onscroll={handleScroll}
class="space-y-2"
class="rounded-xl border border-[var(--border-primary)] bg-[var(--surface-card)] divide-y divide-[var(--border-secondary)]"
>
{#each filteredEvents as entry (entry.id)}
<EventLogEntryComponent
@@ -289,26 +267,26 @@
isNew={newEventIds.has(entry.id)}
/>
{/each}
<!-- Load more -->
{#if hasMore && searchText.trim() === ''}
<div class="flex justify-center pt-4 pb-2">
<button
type="button"
class="inline-flex items-center gap-2 rounded-lg border border-[var(--border-primary)] bg-[var(--surface-card)] px-4 py-2 text-sm font-medium text-[var(--text-secondary)] transition-colors hover:bg-[var(--surface-card-hover)] hover:text-[var(--text-primary)] disabled:opacity-50"
onclick={loadMore}
disabled={loadingMore}
>
{#if loadingMore}
<svg class="h-4 w-4 animate-spin" viewBox="0 0 24 24" fill="none">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
</svg>
{/if}
{$t('events.loadMore')}
</button>
</div>
{/if}
</div>
<!-- Load more -->
{#if hasMore && searchText.trim() === ''}
<div class="flex justify-center pt-2 pb-1">
<button
type="button"
class="inline-flex items-center gap-2 rounded-lg border border-[var(--border-primary)] bg-[var(--surface-card)] px-4 py-2 text-sm font-medium text-[var(--text-secondary)] transition-colors hover:bg-[var(--surface-card-hover)] hover:text-[var(--text-primary)] disabled:opacity-50"
onclick={loadMore}
disabled={loadingMore}
>
{#if loadingMore}
<svg class="h-4 w-4 animate-spin" viewBox="0 0 24 24" fill="none">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
</svg>
{/if}
{$t('events.loadMore')}
</button>
</div>
{/if}
{/if}
</div>
+4 -4
View File
@@ -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');
+2 -2
View File
@@ -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<string, string> { return { 'Content-Type': 'application/json', Authorization: `Bearer ${getToken()}` }; }
function authHeaders(): Record<string, string> { return { 'Content-Type': 'application/json', Authorization: `Bearer ${getAuthToken() ?? ''}` }; }
onMount(async () => { await Promise.all([loadSettings(), loadUsers()]); });