feat: nav counter badges, login backdrop, events i18n + misc fixes
Build / build (push) Successful in 10m29s
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:
+4
-1
@@ -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', () => {
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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": "Подробности"
|
||||
},
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
Reference in New Issue
Block a user