diff --git a/internal/api/stages.go b/internal/api/stages.go index cf8d256..8534de2 100644 --- a/internal/api/stages.go +++ b/internal/api/stages.go @@ -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 } diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index d8bad5c..9ce7834 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -57,7 +57,10 @@ function acquireSlot(signal?: AbortSignal | null): Promise { return Promise.resolve(); } return new Promise((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', () => { diff --git a/web/src/lib/components/EventLogFilter.svelte b/web/src/lib/components/EventLogFilter.svelte index c00b9e4..9af1c9f 100644 --- a/web/src/lib/components/EventLogFilter.svelte +++ b/web/src/lib/components/EventLogFilter.svelte @@ -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' }, diff --git a/web/src/lib/i18n/en.json b/web/src/lib/i18n/en.json index cc4ffc7..fd90e02 100644 --- a/web/src/lib/i18n/en.json +++ b/web/src/lib/i18n/en.json @@ -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" }, diff --git a/web/src/lib/i18n/ru.json b/web/src/lib/i18n/ru.json index 3316113..1380e0f 100644 --- a/web/src/lib/i18n/ru.json +++ b/web/src/lib/i18n/ru.json @@ -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": "Подробности" }, diff --git a/web/src/lib/stores/navCounts.ts b/web/src/lib/stores/navCounts.ts new file mode 100644 index 0000000..93d3f1e --- /dev/null +++ b/web/src/lib/stores/navCounts.ts @@ -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(EMPTY); + +export const navCounts: Readable = { subscribe: store.subscribe }; + +let pollTimer: ReturnType | null = null; +let inFlight = false; + +async function refreshOnce(): Promise { + 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(); +} diff --git a/web/src/routes/+layout.svelte b/web/src/routes/+layout.svelte index e12dc72..31fd22c 100644 --- a/web/src/routes/+layout.svelte +++ b/web/src/routes/+layout.svelte @@ -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(); }); @@ -211,9 +232,21 @@ {:else if item.icon === 'settings'} {/if} - {$t(item.labelKey)} + {$t(item.labelKey)} + + {#if item.countKey} + {@const count = $navCounts[item.countKey]} + {#if count !== null && count > 0} + + {count > 99 ? '99+' : count} + + {:else if count !== null && !item.alert} + 0 + {/if} + {/if} + {#if active} -
+ {/if} {/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: ''; diff --git a/web/src/routes/events/+page.svelte b/web/src/routes/events/+page.svelte index 7eb7945..9c4e0b3 100644 --- a/web/src/routes/events/+page.svelte +++ b/web/src/routes/events/+page.svelte @@ -29,7 +29,7 @@ // Filters let severities = $state(['info', 'warn', 'error']); - let sources = $state(['deploy', 'container', 'proxy', 'system']); + let sources = $state(['deploy', 'static_site', 'stale_scanner', 'stale_cleanup', 'admin']); let dateRange = $state('all'); let searchText = $state(''); @@ -216,7 +216,7 @@
{#snippet heroToolbar()} {#if stats.total > 0} - {stats.total} total + {$t('events.totalCount', { count: String(stats.total) })}