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
+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();
}