feat: daemon health panel, brand-rail status chips, user timezone selector
Build / build (push) Successful in 10m35s

- Health API now surfaces Docker /info + /version (version, platform,
  kernel, container/image counts, storage driver, memory, latency) and
  NPM aggregates (proxy host total, managed-by-Tinyforge count, access
  lists, certificates, endpoint URL).
- Docker/NPM indicators moved out of the sidebar footer and into a
  compact mono-styled rail directly under the Tinyforge brand title,
  with pulse/fault animations and click-to-expand error hints.
- New SystemDaemonsCard on the dashboard: two terminal-styled panels
  (Docker Engine + Proxy) with a running/paused/stopped stacked bar,
  key-value diagnostics, and a total-vs-managed proportion meter on
  the proxy-hosts tile.
- Shared health store so the sidebar and dashboard share a single
  30 s poll instead of duplicating traffic.
- User-facing timezone preference with auto-detect fallback; all
  dates across projects, sites, stacks, settings, backup, event log
  and stale containers now render through \$fmt.date / \$fmt.datetime.
- en/ru translations for both features.
This commit is contained in:
2026-04-23 14:32:30 +03:00
parent a182a93950
commit 90e6e59d9e
24 changed files with 2267 additions and 177 deletions
+227 -98
View File
@@ -10,10 +10,10 @@
import { goto } from '$app/navigation';
import { resolvedTheme, applyTheme } from '$lib/stores/theme';
import { exchangeOidcToken, setAuthToken, clearAuth, isAuthenticated } from '$lib/auth';
import { logout as apiLogout, getHealth } from '$lib/api';
import type { DockerHealth, ProxyHealth } from '$lib/types';
import { logout as apiLogout } from '$lib/api';
import { t } from '$lib/i18n';
import { navCounts, startNavCountsPolling, stopNavCountsPolling, refreshNavCounts } from '$lib/stores/navCounts';
import { health, startHealthPolling, stopHealthPolling, refreshHealth } from '$lib/stores/health';
interface Props {
children: Snippet;
@@ -47,13 +47,13 @@
}
let sidebarOpen = $state(false);
let dockerHealth = $state<DockerHealth | null>(null);
let proxyHealth = $state<ProxyHealth | null>(null);
let healthChecked = $state(false);
let healthInterval: ReturnType<typeof setInterval> | null = null;
let hintsExpanded = $state(false);
let proxyHintsExpanded = $state(false);
const dockerHealth = $derived($health.docker);
const proxyHealth = $derived($health.proxy);
const healthChecked = $derived($health.checked);
// Live UTC forge clock (refreshes every second). A small thing, but it makes
// the sidebar feel alive and reinforces the "control room" aesthetic.
let nowUtc = $state('');
@@ -142,33 +142,19 @@
refreshNavCounts();
});
// Start health polling when authenticated.
// Uses $effect to react to route changes (e.g., after login navigation).
// Start health polling when authenticated. Shared store handles the
// timer; this effect just nudges it whenever auth flips on.
$effect(() => {
void $page.url.pathname;
if (!isAuthenticated() || healthInterval) return;
async function checkHealth() {
try {
const h = await getHealth();
dockerHealth = h.docker;
proxyHealth = h.proxy ?? null;
} catch {
dockerHealth = { connected: false };
proxyHealth = null;
}
healthChecked = true;
}
checkHealth();
healthInterval = setInterval(checkHealth, 30_000);
if (!isAuthenticated()) return;
startHealthPolling();
});
onDestroy(() => {
if (healthInterval) clearInterval(healthInterval);
if (clockTimer) clearInterval(clockTimer);
if (typeof window !== 'undefined') window.removeEventListener('keydown', handleKeydown);
stopNavCountsPolling();
stopHealthPolling();
});
</script>
@@ -191,19 +177,83 @@
class="fixed inset-y-0 left-0 z-50 flex w-64 flex-col border-r border-[var(--border-primary)] bg-[var(--surface-sidebar)] transition-transform duration-300 lg:static lg:translate-x-0
{sidebarOpen ? 'translate-x-0' : '-translate-x-full'}"
>
<!-- Logo -->
<div class="brand flex h-16 items-center gap-2.5 border-b border-[var(--border-primary)] px-5">
<span class="forge-ember brand-ember"></span>
<span class="brand-name">{$t('app.name')}</span>
<!-- Brand block: title + daemon status chips -->
<div class="brand-block border-b border-[var(--border-primary)] px-5 pt-4 pb-3">
<div class="flex items-center gap-2.5">
<span class="forge-ember brand-ember"></span>
<span class="brand-name">{$t('app.name')}</span>
<!-- Close sidebar (mobile) -->
<button
class="ml-auto rounded-md p-1 text-[var(--text-tertiary)] hover:text-[var(--text-primary)] lg:hidden"
onclick={() => { sidebarOpen = false; }}
aria-label="Close sidebar"
>
<IconX size={20} />
</button>
<!-- Close sidebar (mobile) -->
<button
class="ml-auto rounded-md p-1 text-[var(--text-tertiary)] hover:text-[var(--text-primary)] lg:hidden"
onclick={() => { sidebarOpen = false; }}
aria-label="Close sidebar"
>
<IconX size={20} />
</button>
</div>
<!-- Daemon health chips (Docker + proxy provider) -->
<div class="brand-rail" aria-label="Service status">
{#if healthChecked}
<button
type="button"
class="chip"
class:chip-live={dockerConnected}
class:chip-down={!dockerConnected}
title={dockerConnected ? `Docker daemon · ${dockerHealth?.version ?? 'reachable'}` : dockerHealth?.error ?? 'Docker unreachable'}
onclick={() => { if (!dockerConnected) hintsExpanded = !hintsExpanded; }}
>
<span class="chip-dot" aria-hidden="true"></span>
<span class="chip-label">DKR</span>
{#if dockerConnected && typeof dockerHealth?.running === 'number'}
<span class="chip-meter" aria-hidden="true"></span>
<span class="chip-count">{dockerHealth.running}</span>
{/if}
</button>
{#if proxyHealth && proxyProviderName !== 'none'}
<button
type="button"
class="chip"
class:chip-live={proxyConnected}
class:chip-down={!proxyConnected}
title={proxyConnected ? `${proxyProviderName.toUpperCase()} · ${proxyHealth.latency_ms ?? '?'} ms` : proxyHealth.error ?? 'Proxy unreachable'}
onclick={() => { if (!proxyConnected) proxyHintsExpanded = !proxyHintsExpanded; }}
>
<span class="chip-dot" aria-hidden="true"></span>
<span class="chip-label">{proxyProviderName === 'npm' ? 'NPM' : 'TRF'}</span>
{#if proxyConnected && typeof proxyHealth.proxy_hosts === 'number'}
<span class="chip-meter" aria-hidden="true"></span>
<span class="chip-count">{proxyHealth.proxy_hosts}</span>
{/if}
</button>
{/if}
{:else}
<span class="chip chip-idle">
<span class="chip-dot" aria-hidden="true"></span>
<span class="chip-label">BOOT</span>
</span>
{/if}
</div>
<!-- Expandable error hints -->
{#if healthChecked && !dockerConnected && hintsExpanded && dockerHealth?.error}
<div class="chip-error">
<code>{dockerHealth.error}</code>
<button type="button" class="chip-retry" onclick={() => void refreshHealth()}>
{$t('health.retryNow')}
</button>
</div>
{/if}
{#if healthChecked && !proxyConnected && proxyHintsExpanded && proxyHealth?.error}
<div class="chip-error">
<code>{proxyHealth.error}</code>
<button type="button" class="chip-retry" onclick={() => void refreshHealth()}>
{$t('health.retryNow')}
</button>
</div>
{/if}
</div>
<!-- Navigation -->
@@ -254,65 +304,6 @@
<!-- Footer controls -->
<div class="space-y-3 border-t border-[var(--border-primary)] px-4 py-3">
{#if healthChecked}
<div class="flex items-center gap-3 px-1 text-[11px]">
<button
type="button"
class="flex items-center gap-1.5 {dockerConnected ? 'text-emerald-600' : 'text-red-500'}"
title={dockerConnected ? 'Docker connected' : dockerHealth?.error ?? 'Docker disconnected'}
onclick={() => { if (!dockerConnected) hintsExpanded = !hintsExpanded; }}
>
<span class="relative flex h-2 w-2 shrink-0">
{#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
</button>
{#if proxyHealth && proxyProviderName !== 'none'}
<button
type="button"
class="flex items-center gap-1.5 {proxyConnected ? 'text-emerald-600' : 'text-red-500'}"
title={proxyConnected ? (proxyProviderName === 'npm' ? 'NPM' : 'Traefik') + ' connected' : proxyHealth.error ?? 'Proxy disconnected'}
onclick={() => { if (!proxyConnected) proxyHintsExpanded = !proxyHintsExpanded; }}
>
<span class="relative flex h-2 w-2 shrink-0">
{#if proxyConnected}
<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 {proxyConnected ? 'bg-emerald-500' : 'bg-red-500'}"></span>
</span>
{proxyProviderName === 'npm' ? 'NPM' : 'Traefik'}
</button>
{/if}
</div>
{#if !dockerConnected && hintsExpanded && dockerHealth?.error}
<div class="rounded-md bg-red-50 dark:bg-red-950/30 px-2 py-2">
<code class="block text-[10px] text-red-600 dark:text-red-400 break-all leading-relaxed">{dockerHealth.error}</code>
<button
type="button"
class="mt-1.5 w-full rounded border border-red-300 dark:border-red-700 px-2 py-0.5 text-[10px] 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 getHealth();
dockerHealth = h.docker;
proxyHealth = h.proxy ?? null;
} catch {
dockerHealth = { connected: false };
}
}}
>
{$t('health.retryNow')}
</button>
</div>
{/if}
{#if !proxyConnected && proxyHintsExpanded && proxyHealth?.error}
<div class="rounded-md bg-red-50 dark:bg-red-950/30 px-2 py-2">
<code class="block text-[10px] text-red-600 dark:text-red-400 break-all leading-relaxed">{proxyHealth.error}</code>
</div>
{/if}
{/if}
<div class="flex items-center justify-between">
<ThemeToggle />
<LocaleSwitcher />
@@ -392,8 +383,8 @@
font-family: var(--forge-mono, 'JetBrains Mono', monospace);
}
.brand {
gap: 0.75rem;
.brand-block {
position: relative;
}
.brand-ember {
width: 10px; height: 10px;
@@ -407,6 +398,144 @@
color: var(--text-primary);
}
/* ── Daemon status chips under the brand title ─────────────────── */
.brand-rail {
display: flex;
align-items: center;
gap: 0.4rem;
margin-top: 0.55rem;
padding-left: 1.15rem; /* align with brand text (after ember) */
}
.chip {
display: inline-flex;
align-items: center;
gap: 0.38rem;
padding: 0.2rem 0.55rem 0.2rem 0.45rem;
background: var(--surface-card);
border: 1px solid var(--border-primary);
border-radius: 999px;
font-family: var(--forge-mono);
font-size: 0.58rem;
font-weight: 700;
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--text-secondary);
cursor: default;
transition: border-color 150ms ease, background 150ms ease, color 150ms ease, transform 150ms ease;
line-height: 1;
}
.chip:hover {
transform: translateY(-1px);
}
.chip-idle {
opacity: 0.6;
}
.chip-idle .chip-dot {
background: var(--text-tertiary);
}
.chip-dot {
width: 6px; height: 6px;
border-radius: 50%;
background: var(--text-tertiary);
box-shadow: 0 0 0 0 transparent;
flex-shrink: 0;
}
.chip-live {
color: var(--color-success-dark);
border-color: color-mix(in srgb, var(--color-success) 40%, transparent);
background: color-mix(in srgb, var(--color-success) 7%, var(--surface-card));
}
.chip-live .chip-dot {
background: var(--color-success);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-success) 22%, transparent);
animation: chip-pulse 1.9s ease-in-out infinite;
}
.chip-down {
color: var(--color-danger-dark);
border-color: color-mix(in srgb, var(--color-danger) 45%, transparent);
background: color-mix(in srgb, var(--color-danger) 9%, var(--surface-card));
cursor: pointer;
}
.chip-down .chip-dot {
background: var(--color-danger);
animation: chip-fault 0.9s steps(2) infinite;
}
.chip-down:hover {
background: color-mix(in srgb, var(--color-danger) 14%, var(--surface-card));
}
:global([data-theme='dark']) .chip-live {
color: #86efac;
background: color-mix(in srgb, var(--color-success) 12%, transparent);
border-color: color-mix(in srgb, var(--color-success) 35%, transparent);
}
:global([data-theme='dark']) .chip-down {
color: #fca5a5;
background: color-mix(in srgb, var(--color-danger) 14%, transparent);
border-color: color-mix(in srgb, var(--color-danger) 40%, transparent);
}
.chip-meter {
width: 1px;
height: 0.7rem;
background: currentColor;
opacity: 0.25;
}
.chip-count {
font-variant-numeric: tabular-nums;
letter-spacing: 0.04em;
opacity: 0.85;
}
@keyframes chip-pulse {
0%, 100% { box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-success) 22%, transparent); }
50% { box-shadow: 0 0 0 5px color-mix(in srgb, var(--color-success) 12%, transparent); }
}
@keyframes chip-fault {
0%, 100% { opacity: 1; }
50% { opacity: 0.35; }
}
.chip-error {
margin-top: 0.55rem;
padding: 0.45rem 0.6rem;
background: color-mix(in srgb, var(--color-danger) 8%, transparent);
border: 1px solid color-mix(in srgb, var(--color-danger) 35%, transparent);
border-radius: 8px;
}
.chip-error code {
display: block;
font-family: var(--forge-mono);
font-size: 0.62rem;
line-height: 1.5;
color: var(--color-danger-dark);
word-break: break-word;
}
:global([data-theme='dark']) .chip-error code { color: #fca5a5; }
.chip-retry {
margin-top: 0.4rem;
width: 100%;
padding: 0.28rem 0.5rem;
border-radius: 6px;
border: 1px solid color-mix(in srgb, var(--color-danger) 40%, transparent);
background: transparent;
font-family: var(--forge-mono);
font-size: 0.6rem;
font-weight: 600;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--color-danger-dark);
cursor: pointer;
transition: background 150ms ease;
}
.chip-retry:hover {
background: color-mix(in srgb, var(--color-danger) 12%, transparent);
}
:global([data-theme='dark']) .chip-retry { color: #fca5a5; }
.nav-item :global(svg) { flex-shrink: 0; }
.nav-label {
flex: 1;