feat: nav counter badges, login backdrop, events i18n + misc fixes
Build / build (push) Successful in 10m29s

Nav & UI polish
- Sidebar nav items show monospace count badges (projects, sites, stacks,
  proxies). Events badge shows error count only, styled red as actionable
- New $lib/stores/navCounts.ts polls all counts in parallel every 60s and
  refreshes on route change so badges track mutations
- Login page gets a dynamic forge backdrop: rotating conic glow, drifting
  embers, dot-grid texture, vignette — all pure CSS, reduced-motion safe
- main element gets scrollbar-gutter: stable so Settings tab switching no
  longer shifts horizontally when content heights differ

Events i18n
- events.source.* dictionary rewritten to match actually-emitted backend
  sources (deploy, static_site, stale_scanner, stale_cleanup, admin);
  dead keys (container, proxy, system) removed
- EventLogFilter.allSources + /events default sources state updated to match
- Localize "{N} total" via events.totalCount in the page hero toolbar

Backend
- Stage API accepts enable_proxy on create/update (defaults to true) so
  proxy registration can be opted out per stage

Concurrency
- api.ts: queued request waiters no longer double-increment the inflight
  counter; releasing a slot hands it off directly

Reactive effects
- project detail / env / volumes pages wrap side-effect calls in untrack()
  to prevent $effect feedback loops when their loaders mutate tracked state
This commit is contained in:
2026-04-22 18:30:34 +03:00
parent ef0669d5dd
commit a182a93950
12 changed files with 389 additions and 28 deletions
+9
View File
@@ -16,6 +16,7 @@ type stageRequest struct {
Name string `json:"name"`
TagPattern string `json:"tag_pattern"`
AutoDeploy *bool `json:"auto_deploy"`
EnableProxy *bool `json:"enable_proxy"`
MaxInstances *int `json:"max_instances"`
Confirm *bool `json:"confirm"`
PromoteFrom string `json:"promote_from"`
@@ -65,6 +66,10 @@ func (s *Server) createStage(w http.ResponseWriter, r *http.Request) {
if req.Confirm != nil {
confirm = *req.Confirm
}
enableProxy := true
if req.EnableProxy != nil {
enableProxy = *req.EnableProxy
}
var cpuLimit float64
if req.CpuLimit != nil {
@@ -80,6 +85,7 @@ func (s *Server) createStage(w http.ResponseWriter, r *http.Request) {
Name: req.Name,
TagPattern: req.TagPattern,
AutoDeploy: autoDeploy,
EnableProxy: enableProxy,
MaxInstances: maxInstances,
Confirm: confirm,
PromoteFrom: req.PromoteFrom,
@@ -135,6 +141,9 @@ func (s *Server) updateStage(w http.ResponseWriter, r *http.Request) {
if req.AutoDeploy != nil {
updated.AutoDeploy = *req.AutoDeploy
}
if req.EnableProxy != nil {
updated.EnableProxy = *req.EnableProxy
}
if req.MaxInstances != nil {
updated.MaxInstances = *req.MaxInstances
}
+4 -1
View File
@@ -57,7 +57,10 @@ function acquireSlot(signal?: AbortSignal | null): Promise<void> {
return Promise.resolve();
}
return new Promise<void>((resolve, reject) => {
const entry = () => { inflight++; resolve(); };
// A queued waiter inherits the releasing request's slot, so it
// must not increment `inflight` again — `releaseSlot` skips the
// decrement when it hands the slot off, keeping the count stable.
const entry = () => { resolve(); };
queue.push(entry);
signal?.addEventListener('abort', () => {
+1 -1
View File
@@ -34,7 +34,7 @@
}: Props = $props();
const allSeverities = ['info', 'warn', 'error'] as const;
const allSources = ['deploy', 'container', 'proxy', 'system'] as const;
const allSources = ['deploy', 'static_site', 'stale_scanner', 'stale_cleanup', 'admin'] as const;
const dateRangeOptions = [
{ value: '1h', labelKey: 'events.filter.lastHour' },
+5 -3
View File
@@ -817,6 +817,7 @@
"noEventsDesc": "Events will appear here as they occur.",
"loadMore": "Load more",
"newEvents": "new events",
"totalCount": "{count} total",
"clearAll": "Clear All",
"clearAllTitle": "Clear Event Log",
"clearAllMessage": "This will permanently delete all event log entries. This cannot be undone.",
@@ -840,9 +841,10 @@
},
"source": {
"deploy": "Deploy",
"container": "Container",
"proxy": "Proxy",
"system": "System"
"static_site": "Static Site",
"stale_scanner": "Stale Scanner",
"stale_cleanup": "Stale Cleanup",
"admin": "Admin"
},
"metadata": "Details"
},
+5 -3
View File
@@ -817,6 +817,7 @@
"noEventsDesc": "События будут отображаться здесь по мере их возникновения.",
"loadMore": "Загрузить ещё",
"newEvents": "новых событий",
"totalCount": "всего {count}",
"clearAll": "Очистить всё",
"clearAllTitle": "Очистить журнал событий",
"clearAllMessage": "Все записи журнала событий будут удалены безвозвратно.",
@@ -840,9 +841,10 @@
},
"source": {
"deploy": "Развёртывание",
"container": "Контейнер",
"proxy": "Прокси",
"system": "Система"
"static_site": "Статический сайт",
"stale_scanner": "Сканер устаревших",
"stale_cleanup": "Очистка устаревших",
"admin": "Администратор"
},
"metadata": "Подробности"
},
+83
View File
@@ -0,0 +1,83 @@
/**
* Small store that exposes counts for sidebar nav badges.
*
* Values reflect the last successful poll. Individual sources fail
* independently — a failure keeps the previous value and flips `stale` true
* so the UI can dim the badge if desired. The poller is intentionally
* forgiving: if the user is unauthenticated or the backend isn't ready,
* it silently retries on the next tick.
*/
import { writable, type Readable } from 'svelte/store';
import * as api from '$lib/api';
import { isAuthenticated } from '$lib/auth';
export interface NavCounts {
projects: number | null;
sites: number | null;
stacks: number | null;
proxies: number | null;
/** Error-severity events only; dashboard surfaces total separately. */
eventsErrors: number | null;
}
const EMPTY: NavCounts = {
projects: null,
sites: null,
stacks: null,
proxies: null,
eventsErrors: null
};
const store = writable<NavCounts>(EMPTY);
export const navCounts: Readable<NavCounts> = { subscribe: store.subscribe };
let pollTimer: ReturnType<typeof setInterval> | null = null;
let inFlight = false;
async function refreshOnce(): Promise<void> {
if (inFlight || !isAuthenticated()) return;
inFlight = true;
try {
const [projects, sites, stacks, proxies, eventStats] = await Promise.allSettled([
api.listProjects(),
api.listStaticSites(),
api.listStacks(),
api.listProxyRoutes(),
api.fetchEventLogStats()
]);
store.update((prev) => ({
projects: projects.status === 'fulfilled' ? projects.value.length : prev.projects,
sites: sites.status === 'fulfilled' ? sites.value.length : prev.sites,
stacks: stacks.status === 'fulfilled' ? stacks.value.length : prev.stacks,
proxies: proxies.status === 'fulfilled' ? proxies.value.length : prev.proxies,
eventsErrors: eventStats.status === 'fulfilled' ? eventStats.value.error : prev.eventsErrors
}));
} finally {
inFlight = false;
}
}
/**
* Start periodic polling of nav counts. Safe to call repeatedly —
* subsequent calls are no-ops until `stopNavCountsPolling()` is called.
*/
export function startNavCountsPolling(intervalMs = 60_000): void {
if (pollTimer) return;
void refreshOnce();
pollTimer = setInterval(() => void refreshOnce(), intervalMs);
}
export function stopNavCountsPolling(): void {
if (pollTimer) {
clearInterval(pollTimer);
pollTimer = null;
}
}
/** Trigger an out-of-band refresh (e.g. after a mutation). */
export function refreshNavCounts(): void {
void refreshOnce();
}
+94 -9
View File
@@ -13,6 +13,7 @@
import { logout as apiLogout, getHealth } from '$lib/api';
import type { DockerHealth, ProxyHealth } from '$lib/types';
import { t } from '$lib/i18n';
import { navCounts, startNavCountsPolling, stopNavCountsPolling, refreshNavCounts } from '$lib/stores/navCounts';
interface Props {
children: Snippet;
@@ -20,16 +21,25 @@
const { children }: Props = $props();
const navItems = [
type NavCountKey = 'projects' | 'sites' | 'stacks' | 'proxies' | 'eventsErrors';
const navItems: ReadonlyArray<{
href: string;
labelKey: string;
icon: string;
countKey?: NavCountKey;
/** When true the badge uses a danger style (red). */
alert?: boolean;
}> = [
{ href: '/', labelKey: 'nav.dashboard', icon: 'dashboard' },
{ href: '/projects', labelKey: 'nav.projects', icon: 'projects' },
{ href: '/sites', labelKey: 'nav.sites', icon: 'globe' },
{ href: '/stacks', labelKey: 'nav.stacks', icon: 'stacks' },
{ href: '/projects', labelKey: 'nav.projects', icon: 'projects', countKey: 'projects' },
{ href: '/sites', labelKey: 'nav.sites', icon: 'globe', countKey: 'sites' },
{ href: '/stacks', labelKey: 'nav.stacks', icon: 'stacks', countKey: 'stacks' },
{ href: '/deploy', labelKey: 'nav.deploy', icon: 'deploy' },
{ href: '/proxies', labelKey: 'nav.proxies', icon: 'proxies' },
{ href: '/events', labelKey: 'nav.events', icon: 'events' },
{ href: '/proxies', labelKey: 'nav.proxies', icon: 'proxies', countKey: 'proxies' },
{ href: '/events', labelKey: 'nav.events', icon: 'events', countKey: 'eventsErrors', alert: true },
{ href: '/settings', labelKey: 'nav.settings', icon: 'settings' }
] as const;
];
function isActive(href: string, pathname: string): boolean {
if (href === '/') return pathname === '/';
@@ -122,6 +132,16 @@
window.addEventListener('keydown', handleKeydown);
});
// Keep nav badges fresh. Poll kicks off once the user is authenticated;
// a route change also triggers a one-shot refresh so badges reflect any
// mutations just performed on the page we're leaving.
$effect(() => {
void $page.url.pathname;
if (!isAuthenticated()) return;
startNavCountsPolling();
refreshNavCounts();
});
// Start health polling when authenticated.
// Uses $effect to react to route changes (e.g., after login navigation).
$effect(() => {
@@ -148,6 +168,7 @@
if (healthInterval) clearInterval(healthInterval);
if (clockTimer) clearInterval(clockTimer);
if (typeof window !== 'undefined') window.removeEventListener('keydown', handleKeydown);
stopNavCountsPolling();
});
</script>
@@ -211,9 +232,21 @@
{:else if item.icon === 'settings'}
<IconSettings size={18} class="{active ? 'text-[var(--color-brand-600)]' : 'text-[var(--text-tertiary)] group-hover:text-[var(--text-secondary)]'} transition-colors duration-150" />
{/if}
{$t(item.labelKey)}
<span class="nav-label">{$t(item.labelKey)}</span>
{#if item.countKey}
{@const count = $navCounts[item.countKey]}
{#if count !== null && count > 0}
<span class="nav-badge" class:nav-badge-alert={item.alert} class:nav-badge-active={active}>
{count > 99 ? '99+' : count}
</span>
{:else if count !== null && !item.alert}
<span class="nav-badge nav-badge-muted" class:nav-badge-active={active}>0</span>
{/if}
{/if}
{#if active}
<div class="ml-auto h-1.5 w-1.5 rounded-full bg-[var(--color-brand-600)]"></div>
<div class="nav-active-dot"></div>
{/if}
</a>
{/each}
@@ -375,6 +408,10 @@
}
.nav-item :global(svg) { flex-shrink: 0; }
.nav-label {
flex: 1;
min-width: 0;
}
.nav-active {
background: var(--surface-card-hover);
color: var(--text-primary) !important;
@@ -388,6 +425,50 @@
background: var(--color-brand-600);
border-radius: 0 3px 3px 0;
}
.nav-active-dot {
width: 6px; height: 6px;
border-radius: 50%;
background: var(--color-brand-600);
flex-shrink: 0;
}
/* ── Nav count badges ──────────────────────────────────────── */
.nav-badge {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 1.35rem;
height: 1.1rem;
padding: 0 0.4rem;
border-radius: 999px;
background: var(--surface-card-hover);
color: var(--text-secondary);
font-family: var(--forge-mono, 'JetBrains Mono', monospace);
font-size: 0.65rem;
font-weight: 600;
letter-spacing: 0.02em;
font-variant-numeric: tabular-nums;
line-height: 1;
border: 1px solid var(--border-primary);
flex-shrink: 0;
}
.nav-badge-muted {
opacity: 0.55;
}
.nav-badge-alert {
background: var(--color-danger-light);
color: var(--color-danger-dark);
border-color: color-mix(in srgb, var(--color-danger) 45%, transparent);
}
:global([data-theme='dark']) .nav-badge-alert {
background: color-mix(in srgb, var(--color-danger) 18%, transparent);
color: #fca5a5;
border-color: color-mix(in srgb, var(--color-danger) 45%, transparent);
}
.nav-active .nav-badge:not(.nav-badge-alert) {
background: var(--surface-card);
color: var(--text-primary);
}
/* ── Sidebar footline (version + live UTC clock) ───────────── */
.forge-footline {
@@ -469,6 +550,10 @@
:global(main) {
position: relative;
isolation: isolate;
/* Reserve space for the scrollbar even when content fits, so switching
between short and tall pages (e.g. inside Settings) doesn't shift the
layout horizontally by the scrollbar width. */
scrollbar-gutter: stable;
}
:global(main)::before {
content: '';
+2 -2
View File
@@ -29,7 +29,7 @@
// Filters
let severities = $state<string[]>(['info', 'warn', 'error']);
let sources = $state<string[]>(['deploy', 'container', 'proxy', 'system']);
let sources = $state<string[]>(['deploy', 'static_site', 'stale_scanner', 'stale_cleanup', 'admin']);
let dateRange = $state('all');
let searchText = $state('');
@@ -216,7 +216,7 @@
<div class="space-y-4">
{#snippet heroToolbar()}
{#if stats.total > 0}
<span class="forge-pill"><span class="pulse"></span>{stats.total} total</span>
<span class="forge-pill"><span class="pulse"></span>{$t('events.totalCount', { count: String(stats.total) })}</span>
<button
type="button"
onclick={() => { showClearConfirm = true; }}
+174 -3
View File
@@ -82,9 +82,21 @@
}
</script>
<div class="flex min-h-screen items-center justify-center bg-[var(--surface-page)] px-4">
<div class="w-full max-w-sm">
<div class="rounded-2xl border border-[var(--border-primary)] bg-[var(--surface-card)] p-8 shadow-[var(--shadow-lg)]">
<div class="login-shell">
<!-- Drifting forge-ember backdrop (pure CSS, decorative only) -->
<div class="fx-layer" aria-hidden="true">
<div class="fx-glow"></div>
<div class="fx-grid"></div>
<div class="fx-embers">
{#each Array(24) as _, i (i)}
<span class="ember" style="--i: {i}"></span>
{/each}
</div>
<div class="fx-vignette"></div>
</div>
<div class="login-card-wrap">
<div class="rounded-2xl border border-[var(--border-primary)] bg-[var(--surface-card)] p-8 shadow-[var(--shadow-lg)] login-card">
<!-- Logo -->
<div class="mb-6 text-center">
<div class="mx-auto flex h-12 w-12 items-center justify-center rounded-xl bg-[var(--color-brand-600)] shadow-md">
@@ -172,3 +184,162 @@
</div>
</div>
</div>
<style>
.login-shell {
position: relative;
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
padding: 1rem;
background: var(--surface-page);
overflow: hidden;
isolation: isolate;
}
/* ── FX layer sits behind the card ──────────────────────────── */
.fx-layer {
position: absolute;
inset: 0;
pointer-events: none;
z-index: 0;
}
.login-card-wrap {
position: relative;
z-index: 1;
width: 100%;
max-width: 24rem;
}
.login-card {
backdrop-filter: saturate(1.2);
animation: card-rise 600ms cubic-bezier(0.22, 1, 0.36, 1) both;
}
@keyframes card-rise {
from { opacity: 0; transform: translateY(12px) scale(0.985); }
to { opacity: 1; transform: translateY(0) scale(1); }
}
/* ── Slowly rotating "forge glow" behind the card ──────────── */
.fx-glow {
position: absolute;
top: 50%; left: 50%;
width: 130vmin; height: 130vmin;
transform: translate(-50%, -50%);
background:
conic-gradient(
from 0deg at 50% 50%,
transparent 0deg,
color-mix(in srgb, var(--color-brand-500) 22%, transparent) 40deg,
color-mix(in srgb, var(--color-brand-600) 28%, transparent) 80deg,
transparent 140deg,
transparent 220deg,
color-mix(in srgb, var(--color-brand-400) 18%, transparent) 280deg,
transparent 360deg
);
-webkit-mask-image: radial-gradient(circle at center, #000 0%, #000 28%, transparent 70%);
mask-image: radial-gradient(circle at center, #000 0%, #000 28%, transparent 70%);
filter: blur(40px);
opacity: 0.9;
animation: glow-spin 40s linear infinite;
}
@keyframes glow-spin {
to { transform: translate(-50%, -50%) rotate(360deg); }
}
/* ── Dot-grid texture (matches the main app backdrop) ─────── */
.fx-grid {
position: absolute;
inset: 0;
background-image: radial-gradient(var(--border-primary) 1px, transparent 1px);
background-size: 26px 26px;
-webkit-mask-image: radial-gradient(ellipse at center, #000 0%, transparent 75%);
mask-image: radial-gradient(ellipse at center, #000 0%, transparent 75%);
opacity: 0.55;
}
/* ── Drifting embers — small dots rising from below ───────── */
.fx-embers {
position: absolute;
inset: 0;
overflow: hidden;
}
.fx-embers .ember {
position: absolute;
bottom: -16px;
width: 4px; height: 4px;
border-radius: 50%;
background: var(--color-brand-500);
box-shadow:
0 0 6px color-mix(in srgb, var(--color-brand-500) 80%, transparent),
0 0 14px color-mix(in srgb, var(--color-brand-400) 60%, transparent);
opacity: 0;
/* Each ember is placed in its own horizontal lane via --i.
Delay / duration / scale are varied below with nth-child for a natural feel. */
left: calc((var(--i) * 4.1%) + 2%);
animation: ember-rise 11s linear infinite;
animation-delay: calc(var(--i) * -0.9s);
}
/* Duration variety (5 buckets) */
.fx-embers .ember:nth-child(5n) { animation-duration: 9s; }
.fx-embers .ember:nth-child(5n+1) { animation-duration: 11s; }
.fx-embers .ember:nth-child(5n+2) { animation-duration: 13s; }
.fx-embers .ember:nth-child(5n+3) { animation-duration: 15s; }
.fx-embers .ember:nth-child(5n+4) { animation-duration: 17s; }
/* Size variety (3 buckets) */
.fx-embers .ember:nth-child(3n) { transform: scale(0.7); }
.fx-embers .ember:nth-child(3n+1) { transform: scale(1); width: 5px; height: 5px; }
.fx-embers .ember:nth-child(3n+2) { transform: scale(0.5); }
/* Horizontal drift variety (2 buckets) */
.fx-embers .ember:nth-child(even) { --drift: 22px; }
.fx-embers .ember:nth-child(odd) { --drift: -18px; }
@keyframes ember-rise {
0% {
opacity: 0;
transform: translate3d(0, 0, 0);
}
10% { opacity: 0.9; }
50% {
transform: translate3d(calc(var(--drift, 0px) * 0.5), -55vh, 0);
}
90% { opacity: 0.4; }
100% {
opacity: 0;
transform: translate3d(var(--drift, 0px), -110vh, 0);
}
}
/* ── Soft vignette keeps the center where the eye lands ───── */
.fx-vignette {
position: absolute;
inset: 0;
background: radial-gradient(
ellipse at center,
transparent 0%,
transparent 40%,
color-mix(in srgb, var(--surface-page) 55%, transparent) 80%,
var(--surface-page) 100%
);
}
/* ── Dark mode: richer glow, cooler cast ──────────────────── */
:global([data-theme='dark']) .fx-glow { opacity: 0.7; filter: blur(56px); }
:global([data-theme='dark']) .fx-grid { opacity: 0.35; }
:global([data-theme='dark']) .fx-embers .ember {
background: var(--color-brand-400);
box-shadow:
0 0 8px color-mix(in srgb, var(--color-brand-400) 90%, transparent),
0 0 18px color-mix(in srgb, var(--color-brand-500) 60%, transparent);
}
@media (prefers-reduced-motion: reduce) {
.login-card,
.fx-glow,
.fx-embers .ember {
animation: none !important;
}
.fx-embers .ember { opacity: 0.35; }
}
</style>
+4 -1
View File
@@ -1,4 +1,5 @@
<script lang="ts">
import { untrack } from 'svelte';
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import type { Project, Stage, Instance, Deploy, LocalImage } from '$lib/types';
@@ -380,7 +381,9 @@
$effect(() => {
void projectId;
if (!deleted) loadProject();
untrack(() => {
if (!deleted) loadProject();
});
return () => {
loadController?.abort();
+5 -3
View File
@@ -1,4 +1,5 @@
<script lang="ts">
import { untrack } from 'svelte';
import { page } from '$app/stores';
import type { Stage, StageEnv } from '$lib/types';
import * as api from '$lib/api';
@@ -197,12 +198,13 @@
$effect(() => {
void projectId;
loadProject();
untrack(() => loadProject());
});
$effect(() => {
if (selectedStageId) {
loadStageEnv(selectedStageId);
const sid = selectedStageId;
if (sid) {
untrack(() => loadStageEnv(sid));
}
});
</script>
@@ -1,4 +1,5 @@
<script lang="ts">
import { untrack } from 'svelte';
import { page } from '$app/stores';
import type { Volume, VolumeScopeInfo, VolumeScope } from '$lib/types';
import * as api from '$lib/api';
@@ -28,7 +29,7 @@
let volumeDeleteTarget = $state<string | null>(null);
const projectId = $derived($page.params.id);
const projectId = $derived($page.params.id ?? '');
const newScopeNeedsName = $derived(scopes.find(s => s.scope === newScope)?.needs_name ?? false);
const editScopeNeedsName = $derived(scopes.find(s => s.scope === editScope)?.needs_name ?? false);
@@ -138,7 +139,7 @@
$effect(() => {
void projectId;
loadVolumes();
untrack(() => loadVolumes());
});
</script>