Stabilize shell connection badge

This commit is contained in:
rcourtman 2026-03-24 10:53:12 +00:00
parent 999449a87a
commit f5f8eb75d8
6 changed files with 69 additions and 7 deletions

View file

@ -328,6 +328,16 @@ theme synchronization, and authenticated runtime startup, and
as org switching and kiosk-safe navigation. Future hosted browser bootstrap
work must extend that split rather than pulling org bootstrap and app chrome
back into one monolithic route component.
That same authenticated shell contract now also has to distinguish backend
availability from websocket-stream liveness. When hosted runtime health stays
available during a stream reconnect or renegotiation window,
`frontend-modern/src/useAppRuntimeState.ts` must preserve a canonical
backend-healthy signal and `frontend-modern/src/AppLayout.tsx` must not
advertise the whole paid shell as disconnected or "Reconnecting..." solely
because the live stream is transiently recovering. Future hosted shell work
must keep the top-right connection badge aligned to overall authenticated
runtime availability first, with websocket churn treated as a narrower live
stream status instead of the shell-level truth.
That same route/provider shell must stay page-oriented as well: `App.tsx`
should lazy-load route shells like `frontend-modern/src/pages/Storage.tsx`
and `frontend-modern/src/pages/Operations.tsx`

View file

@ -146,6 +146,12 @@ The subsystem registry now also requires explicit proof-policy coverage for all
shared runtime files, and shared-component guardrails fail if raw table
composition is reintroduced in new shared components outside the canonical
allowlist.
The root app shell now also treats backend availability as distinct from
websocket liveness: `frontend-modern/src/AppLayout.tsx` and
`frontend-modern/src/useAppRuntimeState.ts` must keep the top-right connection
badge aligned to overall backend availability so a healthy dev/runtime backend
does not present the whole shell as reconnecting just because the live stream
is transiently renegotiating.
Shared feature presentation helpers under `frontend-modern/src/features/` now
also need to preserve route-owned page-health semantics when the owning surface
is REST-backed: operators should only see reconnect or disconnected shells when

View file

@ -325,6 +325,7 @@ function App() {
class={`app-scroll-shell flex-1 min-w-0 overflow-y-scroll bg-base text-base-content font-sans py-4 sm:py-6 transition-all duration-300`}
>
<AppLayout
backendHealthy={runtime.backendHealthy}
connected={runtime.connected}
reconnecting={runtime.reconnecting}
dataUpdated={runtime.dataUpdated}

View file

@ -68,6 +68,7 @@ type UtilityTab = {
};
export interface AppLayoutProps {
backendHealthy: () => boolean;
connected: () => boolean;
reconnecting: () => boolean;
dataUpdated: () => boolean;
@ -87,21 +88,25 @@ export interface AppLayoutProps {
}
function ConnectionStatusBadge(props: {
backendHealthy: () => boolean;
connected: () => boolean;
reconnecting: () => boolean;
class?: string;
}) {
const shellAvailable = () => props.connected() || props.backendHealthy();
const showSyncing = () => !props.connected() && props.backendHealthy() && props.reconnecting();
return (
<div
class={`group status text-xs rounded-full flex items-center justify-center transition-all duration-500 ease-in-out px-1.5 ${
props.connected()
shellAvailable()
? 'connected bg-green-200 dark:bg-green-700 text-green-700 dark:text-green-300 min-w-6 h-6 group-hover:px-3'
: props.reconnecting()
? 'reconnecting bg-yellow-200 dark:bg-yellow-700 text-yellow-700 dark:text-yellow-300 py-1'
: 'disconnected bg-surface-hover text-base-content min-w-6 h-6 group-hover:px-3'
} ${props.class ?? ''}`}
>
<Show when={props.reconnecting()}>
<Show when={!shellAvailable() && props.reconnecting()}>
<svg class="animate-spin h-3 w-3 flex-shrink-0" fill="none" viewBox="0 0 24 24">
<circle
class="opacity-25"
@ -118,21 +123,23 @@ function ConnectionStatusBadge(props: {
/>
</svg>
</Show>
<Show when={props.connected()}>
<Show when={shellAvailable()}>
<span class="h-2.5 w-2.5 rounded-full bg-green-600 dark:bg-green-400 flex-shrink-0" />
</Show>
<Show when={!props.connected() && !props.reconnecting()}>
<Show when={!shellAvailable() && !props.reconnecting()}>
<span class="h-2.5 w-2.5 rounded-full bg-slate-600 flex-shrink-0" />
</Show>
<span
class={`whitespace-nowrap overflow-hidden transition-all duration-500 ${
props.connected() || (!props.connected() && !props.reconnecting())
shellAvailable() || (!shellAvailable() && !props.reconnecting())
? 'max-w-0 group-hover:max-w-[100px] group-hover:ml-2 group-hover:mr-1 opacity-0 group-hover:opacity-100'
: 'max-w-[100px] ml-1 opacity-100'
}`}
>
{props.connected()
? 'Connected'
{shellAvailable()
? showSyncing()
? 'Connected'
: 'Connected'
: props.reconnecting()
? 'Reconnecting...'
: 'Disconnected'}
@ -593,6 +600,7 @@ export function AppLayout(props: AppLayoutProps) {
</div>
</Show>
<ConnectionStatusBadge
backendHealthy={props.backendHealthy}
connected={props.connected}
reconnecting={props.reconnecting}
class="flex-shrink-0"

View file

@ -23,9 +23,12 @@ describe('App architecture', () => {
it('keeps authenticated chrome in AppLayout and hosted bootstrap in useAppRuntimeState', () => {
expect(appLayoutSource).toContain('export function AppLayout(props: AppLayoutProps)');
expect(appLayoutSource).toContain('<OrgSwitcher');
expect(appLayoutSource).toContain('const shellAvailable = () => props.connected() || props.backendHealthy();');
expect(appLayoutSource).toContain('const utilityTabs = createMemo(() =>');
expect(appRuntimeStateSource).toContain('export const useAppRuntimeState = () =>');
expect(appRuntimeStateSource).toContain('const beginAuthenticatedRuntime = async () =>');
expect(appRuntimeStateSource).toContain("const [backendHealthy, setBackendHealthy] = createSignal(false);");
expect(appRuntimeStateSource).toContain("const checkBackendHealth = async () => {");
expect(appRuntimeStateSource).toContain('const loadOrganizations = async () =>');
expect(appRuntimeStateSource).toContain('const handleOrgSwitch = (nextOrgID: string) =>');
expect(appRuntimeStateSource).toContain(

View file

@ -181,6 +181,7 @@ export const useAppRuntimeState = () => {
logoutURL?: string;
} | null>(null);
const [wsStore, setWsStore] = createSignal<EnhancedStore | null>(null);
const [backendHealthy, setBackendHealthy] = createSignal(false);
const state = (): State => wsStore()?.state || fallbackState;
const connected = () => wsStore()?.connected() || false;
const reconnecting = () => wsStore()?.reconnecting() || false;
@ -226,9 +227,21 @@ export const useAppRuntimeState = () => {
setNeedsAuth(false);
await loadOrganizations();
setWsStore(acquireWsStore());
setBackendHealthy(true);
await loadSystemSettingsAndLayout();
};
const checkBackendHealth = async () => {
try {
const response = await apiFetch('/api/health', { cache: 'no-store' });
setBackendHealthy(response.ok);
return response.ok;
} catch {
setBackendHealthy(false);
return false;
}
};
const loadOrganizations = async () => {
setOrgsLoading(true);
try {
@ -419,6 +432,26 @@ export const useAppRuntimeState = () => {
}
});
createEffect(() => {
if (connected()) {
setBackendHealthy(true);
return;
}
if (!reconnecting()) {
return;
}
void checkBackendHealth();
const interval = window.setInterval(() => {
void checkBackendHealth();
}, 5000);
onCleanup(() => {
window.clearInterval(interval);
});
});
const handleThemeChange = async (newPreference: ThemePreference) => {
applyThemePreferenceLocally(newPreference);
logger.info('Theme changed', { pref: newPreference, active: computeIsDark(newPreference) ? 'dark' : 'light' });
@ -634,6 +667,7 @@ export const useAppRuntimeState = () => {
proxyAuthInfo,
state,
connected,
backendHealthy,
reconnecting,
dataUpdated,
lastUpdateText,