From 6d54ffb18ecb8aabb44b8f056add8b5da3edf1a9 Mon Sep 17 00:00:00 2001 From: rcourtman Date: Fri, 20 Mar 2026 20:57:25 +0000 Subject: [PATCH] Extract app runtime owners --- .../v6/internal/subsystems/cloud-paid.md | 32 +- .../v6/internal/subsystems/registry.json | 5 + frontend-modern/src/App.tsx | 1619 +---------------- frontend-modern/src/AppLayout.tsx | 813 +++++++++ .../src/__tests__/App.architecture.test.ts | 31 + frontend-modern/src/useAppRuntimeState.ts | 649 +++++++ 6 files changed, 1553 insertions(+), 1596 deletions(-) create mode 100644 frontend-modern/src/AppLayout.tsx create mode 100644 frontend-modern/src/__tests__/App.architecture.test.ts create mode 100644 frontend-modern/src/useAppRuntimeState.ts diff --git a/docs/release-control/v6/internal/subsystems/cloud-paid.md b/docs/release-control/v6/internal/subsystems/cloud-paid.md index 6973294d1..e0bb430e0 100644 --- a/docs/release-control/v6/internal/subsystems/cloud-paid.md +++ b/docs/release-control/v6/internal/subsystems/cloud-paid.md @@ -44,16 +44,18 @@ agreement, and cloud-specific enforcement rules. 22. `internal/cloudcp/stripe/provisioner.go` 23. `internal/hosted/provisioner.go` 24. `frontend-modern/src/App.tsx` -25. `frontend-modern/src/components/Dashboard/RelayOnboardingCard.tsx` -26. `frontend-modern/src/components/Settings/BillingAdminPanel.tsx` -27. `frontend-modern/src/components/Settings/OrganizationBillingPanel.tsx` -28. `frontend-modern/src/components/Settings/ProLicensePanel.tsx` -29. `frontend-modern/src/components/Settings/CommercialBillingSections.tsx` -30. `frontend-modern/src/components/Settings/SelfHostedCommercialActivationSection.tsx` -31. `frontend-modern/src/components/Settings/RelaySettingsPanel.tsx` -32. `frontend-modern/src/pages/CloudPricing.tsx` -33. `frontend-modern/src/utils/apiClient.ts` -34. `frontend-modern/src/utils/commercialBillingModel.ts` +25. `frontend-modern/src/AppLayout.tsx` +26. `frontend-modern/src/useAppRuntimeState.ts` +27. `frontend-modern/src/components/Dashboard/RelayOnboardingCard.tsx` +28. `frontend-modern/src/components/Settings/BillingAdminPanel.tsx` +29. `frontend-modern/src/components/Settings/OrganizationBillingPanel.tsx` +30. `frontend-modern/src/components/Settings/ProLicensePanel.tsx` +31. `frontend-modern/src/components/Settings/CommercialBillingSections.tsx` +32. `frontend-modern/src/components/Settings/SelfHostedCommercialActivationSection.tsx` +33. `frontend-modern/src/components/Settings/RelaySettingsPanel.tsx` +34. `frontend-modern/src/pages/CloudPricing.tsx` +35. `frontend-modern/src/utils/apiClient.ts` +36. `frontend-modern/src/utils/commercialBillingModel.ts` ## Shared Boundaries @@ -81,7 +83,7 @@ agreement, and cloud-specific enforcement rules. 15. Add or change paid relay settings and onboarding presentation through `frontend-modern/src/components/Settings/RelaySettingsPanel.tsx` and `frontend-modern/src/components/Dashboard/RelayOnboardingCard.tsx` 16. Add or change cloud plan presentation through `frontend-modern/src/pages/CloudPricing.tsx` 17. Add contract tests where runtime and pricing need to stay aligned -18. Add or change hosted browser org-context bootstrap through `frontend-modern/src/App.tsx` and `frontend-modern/src/utils/apiClient.ts` +18. Add or change hosted browser org-context bootstrap through `frontend-modern/src/App.tsx`, `frontend-modern/src/AppLayout.tsx`, `frontend-modern/src/useAppRuntimeState.ts`, and `frontend-modern/src/utils/apiClient.ts` ## Forbidden Paths @@ -214,6 +216,14 @@ simply because hosted tenants do not expose self-hosted multi-tenant admin capabilities. Hosted runtime entry must therefore treat tenant org context as infrastructure state, not a paid UI toggle that can be discarded during app bootstrap. +That hosted browser bootstrap owner is now intentionally split by role: +`frontend-modern/src/App.tsx` is the route/provider entry shell, +`frontend-modern/src/useAppRuntimeState.ts` owns authentication, org bootstrap, +theme synchronization, and authenticated runtime startup, and +`frontend-modern/src/AppLayout.tsx` owns authenticated cloud-aware chrome such +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. The Pro license settings surface now follows the same rule as well. Changes to `frontend-modern/src/components/Settings/ProLicensePanel.tsx` must carry this contract and the dedicated Pro-license proof file instead of remaining an diff --git a/docs/release-control/v6/internal/subsystems/registry.json b/docs/release-control/v6/internal/subsystems/registry.json index 579e451d2..263cf5519 100644 --- a/docs/release-control/v6/internal/subsystems/registry.json +++ b/docs/release-control/v6/internal/subsystems/registry.json @@ -1012,6 +1012,7 @@ ], "owned_files": [ "frontend-modern/src/App.tsx", + "frontend-modern/src/AppLayout.tsx", "frontend-modern/src/components/Dashboard/RelayOnboardingCard.tsx", "frontend-modern/src/components/Settings/BillingAdminPanel.tsx", "frontend-modern/src/components/Settings/OrganizationBillingPanel.tsx", @@ -1019,6 +1020,7 @@ "frontend-modern/src/components/Settings/RelaySettingsPanel.tsx", "frontend-modern/src/pages/CloudPricing.tsx", "frontend-modern/src/pages/HostedSignup.tsx", + "frontend-modern/src/useAppRuntimeState.ts", "frontend-modern/src/utils/apiClient.ts", "internal/api/public_signup_handlers.go", "internal/cloudcp/account/tenant_handlers.go", @@ -1478,11 +1480,14 @@ "match_prefixes": [], "match_files": [ "frontend-modern/src/App.tsx", + "frontend-modern/src/AppLayout.tsx", + "frontend-modern/src/useAppRuntimeState.ts", "frontend-modern/src/utils/apiClient.ts" ], "allow_same_subsystem_tests": false, "test_prefixes": [], "exact_files": [ + "frontend-modern/src/__tests__/App.architecture.test.ts", "frontend-modern/src/utils/__tests__/apiClient.org.test.ts" ] }, diff --git a/frontend-modern/src/App.tsx b/frontend-modern/src/App.tsx index 4a4ca50a1..393f914dd 100644 --- a/frontend-modern/src/App.tsx +++ b/frontend-modern/src/App.tsx @@ -1,7 +1,5 @@ import { Show, - For, - Suspense, lazy, createSignal, createContext, @@ -10,8 +8,6 @@ import { createMemo, onCleanup, onMount, - getOwner, - runWithOwner, } from 'solid-js'; import type { JSX } from 'solid-js'; import { Router, Route, Navigate, useNavigate, useLocation } from '@solidjs/router'; @@ -21,24 +17,6 @@ import { ErrorBoundary } from './components/ErrorBoundary'; import { SecurityWarning } from './components/SecurityWarning'; import { Login } from './components/Login'; import { logger } from './utils/logger'; -import { POLLING_INTERVALS } from './constants'; -import { STORAGE_KEYS } from '@/utils/localStorage'; -import { layoutStore } from '@/utils/layout'; -import { MONITORING_READ_SCOPE } from '@/constants/apiScopes'; -import { UpdatesAPI } from './api/updates'; -import type { VersionInfo } from './api/updates'; -import type { TimeRange } from './api/charts'; -import { - apiFetch, - getOrgID as getSelectedOrgID, - setOrgID as setSelectedOrgID, -} from './utils/apiClient'; -import type { SecurityStatus } from '@/types/config'; -import { SettingsAPI } from './api/settings'; -import { OrgsAPI } from '@/api/orgs'; -import type { Organization } from '@/api/orgs'; -import { eventBus } from './stores/events'; -import { updateStore } from './stores/updates'; import { UpdateBanner } from './components/UpdateBanner'; import { DemoBanner } from './components/DemoBanner'; import { GitHubStarBanner } from './components/GitHubStarBanner'; @@ -48,71 +26,22 @@ import { ActiveUseTrialNudge } from './components/shared/ActiveUseTrialNudge'; import { WhatsNewModal } from './components/shared/WhatsNewModal'; import { KeyboardShortcutsModal } from './components/shared/KeyboardShortcutsModal'; import { CommandPaletteModal } from './components/shared/CommandPaletteModal'; -import { MobileNavBar } from './components/shared/MobileNavBar'; import { createTooltipSystem } from './components/shared/Tooltip'; -import { OrgSwitcher } from './components/OrgSwitcher'; -import type { State, Alert } from '@/types/api'; -import { startMetricsCollector } from './stores/metricsCollector'; -import BoxesIcon from 'lucide-solid/icons/boxes'; -import LayoutDashboardIcon from 'lucide-solid/icons/layout-dashboard'; -import ServerIcon from 'lucide-solid/icons/server'; -import HardDriveIcon from 'lucide-solid/icons/hard-drive'; -import ArchiveIcon from 'lucide-solid/icons/archive'; -import BellIcon from 'lucide-solid/icons/bell'; -import SettingsIcon from 'lucide-solid/icons/settings'; -import Maximize2Icon from 'lucide-solid/icons/maximize-2'; -import Minimize2Icon from 'lucide-solid/icons/minimize-2'; -import ActivityIcon from 'lucide-solid/icons/activity'; -import { PulsePatrolLogo } from '@/components/Brand/PulsePatrolLogo'; import { TokenRevealDialog } from './components/TokenRevealDialog'; -import { useAlertsActivation } from './stores/alertsActivation'; import { UpdateProgressModal } from './components/UpdateProgressModal'; -import type { UpdateStatus } from './api/updates'; +import { UpdatesAPI, type UpdateStatus } from './api/updates'; import { AIChat } from './components/AI/Chat'; import { aiChatStore } from './stores/aiChat'; import { useKeyboardShortcuts } from './hooks/useKeyboardShortcuts'; -import { - updateSystemSettingsFromResponse, - markSystemSettingsLoadedWithDefaults, -} from './stores/systemSettings'; -import { - fetchInfrastructureSummaryAndCache, - hasFreshInfrastructureSummaryCache, -} from '@/utils/infrastructureSummaryCache'; -import { - initKioskMode, - setKioskMode, - getKioskModePreference, - getPulseWebSocketUrl, -} from './utils/url'; -import { useKioskMode, syncKioskMode } from '@/hooks/useKioskMode'; -import { getActiveTabForPath } from './routing/navigation'; +import { useKioskMode } from '@/hooks/useKioskMode'; import { buildRecoveryPath, - DASHBOARD_PATH, buildInfrastructurePath, buildStoragePath, buildWorkloadsPath, } from './routing/resourceLinks'; -import { buildStorageRecoveryTabSpecs } from './routing/platformTabs'; -import { - isHostedModeEnabled, - isMultiTenantEnabled, - isPro, - licenseLoaded, - loadLicenseStatus, -} from '@/stores/license'; - -import { showToast } from '@/utils/toast'; -import { - applyThemeClass, - computeIsDark, - getStoredThemePreference, - hasStoredThemePreference, - normalizeThemePreference, - persistThemePreference, - type ThemePreference, -} from '@/utils/theme'; +import { AppLayout } from '@/AppLayout'; +import { useAppRuntimeState } from '@/useAppRuntimeState'; function isPublicRoutePath(pathname: string): boolean { // Public routes must be viewable without authentication. @@ -273,699 +202,19 @@ function App() { location.pathname.replace(/^\/settings\/operations(?=\/|$)/, '/operations') || '/operations'; return ; }; - - // Initialize kiosk mode from URL params immediately (persists to sessionStorage) - // This must happen before any renders so kiosk state is available everywhere - initKioskMode(); - syncKioskMode(); // Re-sync shared signal after URL params are processed - - // Reactive kiosk state for App-level components (banners, etc.) const kioskMode = useKioskMode(); - const TooltipRoot = createTooltipSystem(); - const owner = getOwner(); - const acquireWsStore = (): EnhancedStore => { - const store = owner - ? runWithOwner(owner, () => getGlobalWebSocketStore()) - : getGlobalWebSocketStore(); - return store || getGlobalWebSocketStore(); - }; - const alertsActivation = useAlertsActivation(); + const runtime = useAppRuntimeState(); - // Start metrics collector (always runs for Storage page) - onMount(() => { - startMetricsCollector(); - }); - - let hasPreloadedRoutes = false; - let hasFetchedVersionInfo = false; - let hasPrewarmedInfrastructureCharts = false; - - const getInfrastructureTrendRangeForPrewarm = (): TimeRange => '1h'; - - const shouldPrewarmInfrastructure = (): boolean => { - if (typeof window === 'undefined') return false; - - // Respect data-saver / very slow connections. - // We treat this as an optimization, not a correctness requirement. - const conn = ( - navigator as unknown as { connection?: { saveData?: boolean; effectiveType?: string } } - ).connection; - if (conn?.saveData) return false; - const effective = conn?.effectiveType; - if (typeof effective === 'string' && (effective === 'slow-2g' || effective === '2g')) { - return false; - } - - const pathname = window.location.pathname; - if (!pathname) return true; - if (pathname === ROOT_INFRASTRUCTURE_PATH) return false; - return true; - }; - - const prewarmInfrastructureCharts = () => { - if (hasPrewarmedInfrastructureCharts || !shouldPrewarmInfrastructure()) { - return; - } - - const range = getInfrastructureTrendRangeForPrewarm(); - if (hasFreshInfrastructureSummaryCache(range)) { - hasPrewarmedInfrastructureCharts = true; - return; - } - - hasPrewarmedInfrastructureCharts = true; - void fetchInfrastructureSummaryAndCache(range, { caller: 'App prewarm' }).catch(() => { - // Non-blocking prewarm; ignore failures. - }); - }; - - const preloadLazyRoutes = () => { - if (hasPreloadedRoutes || typeof window === 'undefined') { - return; - } - hasPreloadedRoutes = true; - const loaders: Array<() => Promise> = [ - () => import('./pages/Alerts'), - () => import('./components/Settings/Settings'), - ]; - - loaders.forEach((load) => { - void load().catch((error) => { - logger.warn('Preloading route module failed', error); - }); - }); - }; - - const fallbackState: State = { - connectedInfrastructure: [], - metrics: [], - performance: { - apiCallDuration: {}, - lastPollDuration: 0, - pollingStartTime: '', - totalApiCalls: 0, - failedApiCalls: 0, - cacheHits: 0, - cacheMisses: 0, - }, - connectionHealth: {}, - stats: { - startTime: new Date().toISOString(), - uptime: 0, - pollingCycles: 0, - webSocketClients: 0, - version: '0.0.0', - }, - activeAlerts: [], - recentlyResolved: [], - lastUpdate: 0, - resources: [], - }; - - // Simple auth state - const [isLoading, setIsLoading] = createSignal(true); - const [needsAuth, setNeedsAuth] = createSignal(false); - const [hasAuth, setHasAuth] = createSignal(false); - const [organizations, setOrganizations] = createSignal([ - { id: 'default', displayName: 'Default Organization' }, - ]); - const [activeOrgID, setActiveOrgID] = createSignal(getSelectedOrgID() || 'default'); - const [orgsLoading, setOrgsLoading] = createSignal(false); - // Store full security status for Login component (hideLocalLogin, SSO providers, etc.) - // Store full security status for Login component (hideLocalLogin, SSO providers, etc.) - const [securityStatus, setSecurityStatus] = createSignal(null); - const [proxyAuthInfo, setProxyAuthInfo] = createSignal<{ - username?: string; - logoutURL?: string; - } | null>(null); - - // Don't initialize WebSocket until after auth check - const [wsStore, setWsStore] = createSignal(null); - const state = (): State => wsStore()?.state || fallbackState; - const connected = () => wsStore()?.connected() || false; - const reconnecting = () => wsStore()?.reconnecting() || false; - - // Data update indicator - const [dataUpdated, setDataUpdated] = createSignal(false); - let updateTimeout: number | undefined; - - // Last update time formatting - const [lastUpdateText, setLastUpdateText] = createSignal(''); - - const loadOrganizations = async () => { - setOrgsLoading(true); - try { - if (!licenseLoaded()) { - await loadLicenseStatus(); - } - - if (!isMultiTenantEnabled()) { - const storedOrgID = getSelectedOrgID(); - const hostedOrgID = - isHostedModeEnabled() && storedOrgID && storedOrgID !== 'default' - ? storedOrgID - : 'default'; - setOrganizations([ - { - id: hostedOrgID, - displayName: hostedOrgID === 'default' ? 'Default Organization' : hostedOrgID, - }, - ]); - setSelectedOrgID(hostedOrgID); - setActiveOrgID(hostedOrgID); - return; - } - - const fetched = await OrgsAPI.list(); - const orgList = - fetched.length > 0 ? fetched : [{ id: 'default', displayName: 'Default Organization' }]; - setOrganizations(orgList); - - const storedOrgID = getSelectedOrgID(); - const selected = - (storedOrgID && orgList.some((org) => org.id === storedOrgID) ? storedOrgID : null) || - orgList[0]?.id || - 'default'; - - setSelectedOrgID(selected); - setActiveOrgID(selected); - } catch (error) { - logger.warn('Failed to load organizations, falling back to default org', error); - showToast('error', 'Failed to load organizations. Using default.'); - const fallback = [{ id: 'default', displayName: 'Default Organization' }]; - setOrganizations(fallback); - setSelectedOrgID('default'); - setActiveOrgID('default'); - } finally { - setOrgsLoading(false); - } - }; - - const handleOrgSwitch = (nextOrgID: string) => { - const target = nextOrgID?.trim() || 'default'; - if (target === activeOrgID()) { - return; - } - - if (target !== 'default' && !organizations().some((org) => org.id === target)) { - showToast('error', 'Organization no longer exists'); - return; - } - - setSelectedOrgID(target); - setActiveOrgID(target); - - eventBus.emit('org_switched', target); - - // Clear org-specific client-side caches - try { - localStorage.removeItem(STORAGE_KEYS.GUEST_METADATA); - localStorage.removeItem(STORAGE_KEYS.DOCKER_METADATA); - localStorage.removeItem(STORAGE_KEYS.DOCKER_METADATA + '_hosts'); - } catch { - /* ignore storage errors */ - } - - try { - const store = wsStore(); - if (store && typeof store.switchUrl === 'function') { - store.switchUrl(getPulseWebSocketUrl()); - } else { - store?.reconnect(); - } - showToast('success', 'Organization switched'); - } catch (error) { - logger.error('Failed to switch organization', error); - showToast('error', 'Failed to switch organization'); - } - }; - - const formatLastUpdate = (timestamp: number) => { - if (!timestamp) return ''; - const date = new Date(timestamp); - return date.toLocaleTimeString('en-US', { - hour: 'numeric', - minute: '2-digit', - second: '2-digit', - hour12: true, - }); - }; - - // Flash indicator when data updates - createEffect(() => { - // Watch for state changes - const updateTime = state().lastUpdate; - if (updateTime > 0) { - setDataUpdated(true); - setLastUpdateText(formatLastUpdate(updateTime)); - if (updateTimeout !== undefined) { - window.clearTimeout(updateTimeout); - } - updateTimeout = window.setTimeout(() => setDataUpdated(false), POLLING_INTERVALS.DATA_FLASH); - } - }); - - onCleanup(() => { - if (updateTimeout !== undefined) { - window.clearTimeout(updateTimeout); - } - }); - - createEffect(() => { - if (!isLoading() && !needsAuth()) { - if (typeof window === 'undefined') { - return; - } - if (!hasPreloadedRoutes) { - // Defer to the next tick so we don't contend with initial render - window.setTimeout(preloadLazyRoutes, 0); - } - } - }); - - createEffect(() => { - if (isLoading() || needsAuth() || hasPrewarmedInfrastructureCharts) { - return; - } - if (typeof window === 'undefined') { - return; - } - - if (typeof window.requestIdleCallback === 'function') { - const id = window.requestIdleCallback( - () => { - prewarmInfrastructureCharts(); - }, - { timeout: 2_000 }, - ); - onCleanup(() => { - window.cancelIdleCallback(id); - }); - return; - } - - const timeout = window.setTimeout(() => { - prewarmInfrastructureCharts(); - }, 500); - onCleanup(() => { - window.clearTimeout(timeout); - }); - }); - - createEffect(() => { - if (isLoading() || needsAuth() || hasFetchedVersionInfo) { - return; - } - hasFetchedVersionInfo = true; - - void syncVersionInfoFromUpdateStore(); - }); - - let alertsInitialized = false; - createEffect(() => { - const ready = !isLoading() && !needsAuth(); - if (ready && !alertsInitialized) { - alertsInitialized = true; - void alertsActivation.refreshConfig(); - void alertsActivation.refreshActiveAlerts(); - } - if (!ready) { - alertsInitialized = false; - } - }); - - // No longer need tab state management - using router now - - // Version info - const [versionInfo, setVersionInfo] = createSignal(null); - async function syncVersionInfoFromUpdateStore() { - const cachedVersion = updateStore.versionInfo(); - if (cachedVersion) { - setVersionInfo(cachedVersion); - } - - await updateStore.checkForUpdates(); - - const resolvedVersion = updateStore.versionInfo(); - if (resolvedVersion) { - setVersionInfo(resolvedVersion); - } - } - - // Theme settings - // Single source of truth: - // 1) localStorage preference - // 2) system preference - // 3) server preference (only when no local preference exists) - const initialThemePref = getStoredThemePreference(); - const [themePreference, setThemePreference] = createSignal(initialThemePref); - const [, setHasLoadedServerTheme] = createSignal(false); - - const [darkMode, setDarkMode] = createSignal(computeIsDark(initialThemePref)); - - // Apply dark mode immediately on initialization - applyThemeClass(darkMode()); - - // Handle explicit theme changes - const handleThemeChange = async (newPref: ThemePreference) => { - setThemePreference(newPref); - persistThemePreference(newPref); - - const isDark = computeIsDark(newPref); - setDarkMode(isDark); - applyThemeClass(isDark); - - logger.info('Theme changed', { pref: newPref, active: isDark ? 'dark' : 'light' }); - - // Save theme preference to server if authenticated - if (!needsAuth()) { - try { - await SettingsAPI.updateSystemSettings({ theme: newPref }); - logger.info('Theme preference saved to server'); - } catch (error) { - logger.error('Failed to save theme preference to server', error); - } - } - }; - - // Don't initialize dark mode here - will be handled based on auth state - - // Listen for theme changes from other browser instances and system pref - onMount(() => { - const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)'); - const systemThemeListener = (e: MediaQueryListEvent) => { - if (themePreference() === 'system') { - const isDark = e.matches; - setDarkMode(isDark); - applyThemeClass(isDark); - } - }; - mediaQuery.addEventListener('change', systemThemeListener); - - const handleRemoteThemeChange = (theme?: string) => { - if (!theme) return; - logger.info('Received theme change from another browser instance', { theme }); - - const pref = normalizeThemePreference(theme); - setThemePreference(pref); - persistThemePreference(pref); - - const isDark = computeIsDark(pref); - setDarkMode(isDark); - applyThemeClass(isDark); - }; - - // Handle WebSocket reconnection - refresh alert config to restore activation state - // This fixes issue where alert toggle appears disabled after connection loss - const handleWebSocketReconnected = () => { - logger.info('WebSocket reconnected, refreshing alert configuration'); - void alertsActivation.refreshConfig(); - void alertsActivation.refreshActiveAlerts(); - }; - - // Subscribe to events - eventBus.on('theme_changed', handleRemoteThemeChange); - eventBus.on('websocket_reconnected', handleWebSocketReconnected); - - // Cleanup on unmount - onCleanup(() => { - mediaQuery.removeEventListener('change', systemThemeListener); - eventBus.off('theme_changed', handleRemoteThemeChange); - eventBus.off('websocket_reconnected', handleWebSocketReconnected); - }); - }); - - // Check auth on mount - onMount(async () => { - logger.debug('[App] Starting auth check...'); - - // Check if we just logged out - if so, always show login page - const justLoggedOut = localStorage.getItem('just_logged_out'); - - // First check security status to see if auth is configured - // We need this for ALL paths to properly set hideLocalLogin, SSO providers, etc. - try { - const securityRes = await apiFetch('/api/security/status'); - - if (securityRes.status === 401) { - logger.warn( - '[App] Security status request returned 401. Clearing stored credentials and showing login.', - ); - try { - const { clearAuth } = await import('./utils/apiClient'); - clearAuth(); - } catch (clearError) { - logger.warn('[App] Failed to clear stored auth after 401', clearError); - } - // Still try to parse security data from 401 response for OIDC settings - // If not available, Login component will fetch it on mount - setHasAuth(false); - setNeedsAuth(true); - setIsLoading(false); - return; - } - - // Handle just_logged_out AFTER we have security status - if (justLoggedOut) { - localStorage.removeItem('just_logged_out'); - logger.debug('[App] User logged out, showing login page'); - // Parse security data to get hideLocalLogin, SSO providers, etc. - if (securityRes.ok) { - const securityData = await securityRes.json(); - setSecurityStatus(securityData as SecurityStatus); - } - setHasAuth(true); // Force showing login instead of setup - setNeedsAuth(true); - setIsLoading(false); - return; - } - - if (!securityRes.ok) { - throw new Error(`Security status request failed with status ${securityRes.status}`); - } - - const securityData = await securityRes.json(); - logger.debug('[App] Security status fetched', securityData); - - // Store full security status for Login component - setSecurityStatus(securityData as SecurityStatus); - - const authConfigured = securityData.hasAuthentication || false; - setHasAuth(authConfigured); - - // Check for proxy auth - if (securityData.hasProxyAuth && securityData.proxyAuthUsername) { - logger.info('[App] Proxy auth detected', { user: securityData.proxyAuthUsername }); - setProxyAuthInfo({ - username: securityData.proxyAuthUsername, - logoutURL: securityData.proxyAuthLogoutURL, - }); - setNeedsAuth(false); - await loadOrganizations(); - // Initialize WebSocket for proxy auth users - setWsStore(acquireWsStore()); - - // Load system settings (theme + server-wide feature flags + layout prefs) - try { - const systemSettings = await SettingsAPI.getSystemSettings(); - updateSystemSettingsFromResponse(systemSettings); - - if (!hasStoredThemePreference() && systemSettings.theme && systemSettings.theme !== '') { - const pref = normalizeThemePreference(systemSettings.theme); - setThemePreference(pref); - persistThemePreference(pref); - const isDark = computeIsDark(pref); - setDarkMode(isDark); - applyThemeClass(isDark); - } - - setHasLoadedServerTheme(true); - layoutStore.loadFromServer(); - } catch (error) { - logger.error('Failed to load system settings from server', error); - // Ensure settings are marked as loaded so UI doesn't stay in loading state - markSystemSettingsLoadedWithDefaults(); - } - - void syncVersionInfoFromUpdateStore(); - - setIsLoading(false); - return; - } - - // Check for SSO session - if (securityData.ssoEnabled && securityData.ssoSessionUsername) { - logger.info('[App] SSO session detected', { user: securityData.ssoSessionUsername }); - setHasAuth(true); // SSO is enabled, so auth is configured - setProxyAuthInfo({ - username: securityData.ssoSessionUsername, - logoutURL: securityData.ssoLogoutURL, // Optional SSO logout URL from IdP - }); - setNeedsAuth(false); - await loadOrganizations(); - // Initialize WebSocket for SSO users - setWsStore(acquireWsStore()); - - // Load system settings (theme + server-wide feature flags + layout prefs) - try { - const systemSettings = await SettingsAPI.getSystemSettings(); - updateSystemSettingsFromResponse(systemSettings); - - // Only apply server theme if user has no local preference on this device. - if (!hasStoredThemePreference() && systemSettings.theme && systemSettings.theme !== '') { - const pref = normalizeThemePreference(systemSettings.theme); - setThemePreference(pref); - persistThemePreference(pref); - const isDark = computeIsDark(pref); - setDarkMode(isDark); - applyThemeClass(isDark); - } - - setHasLoadedServerTheme(true); - layoutStore.loadFromServer(); - } catch (error) { - logger.error('Failed to load system settings from server', error); - // Ensure settings are marked as loaded so UI doesn't stay in loading state - markSystemSettingsLoadedWithDefaults(); - } - - void syncVersionInfoFromUpdateStore(); - - setIsLoading(false); - return; - } - - // If no auth is configured, show FirstRunSetup - if (!authConfigured) { - logger.info('[App] No auth configured, showing Login/FirstRunSetup'); - setNeedsAuth(true); // This will show the Login component which shows FirstRunSetup - setIsLoading(false); - return; - } - - // If auth is configured, check if we're authenticated - const stateRes = await apiFetch('/api/state', { - headers: { - 'X-Requested-With': 'XMLHttpRequest', - Accept: 'application/json', - }, - }); - - if (stateRes.status === 401) { - setNeedsAuth(true); - } else { - setNeedsAuth(false); - await loadOrganizations(); - // Only initialize WebSocket after successful auth check - setWsStore(acquireWsStore()); - - // Load system settings (theme + server-wide feature flags + layout prefs) - try { - const systemSettings = await SettingsAPI.getSystemSettings(); - updateSystemSettingsFromResponse(systemSettings); - - // Only apply server theme if user has no local preference on this device. - if (!hasStoredThemePreference() && systemSettings.theme && systemSettings.theme !== '') { - const pref = normalizeThemePreference(systemSettings.theme); - setThemePreference(pref); - persistThemePreference(pref); - const isDark = computeIsDark(pref); - setDarkMode(isDark); - applyThemeClass(isDark); - } - - setHasLoadedServerTheme(true); - layoutStore.loadFromServer(); - } catch (error) { - logger.error('Failed to load system settings from server', error); - // Ensure settings are marked as loaded so UI doesn't stay in loading state - markSystemSettingsLoadedWithDefaults(); - } - } - } catch (error) { - logger.error('Auth check error', error); - try { - const { clearAuth } = await import('./utils/apiClient'); - clearAuth(); - } catch (clearError) { - logger.warn('[App] Failed to clear stored auth after auth check error', clearError); - } - setHasAuth(false); - setNeedsAuth(true); - } finally { - setIsLoading(false); - } - }); - - const handleLogin = () => { - window.location.reload(); - }; - - const handleLogout = async () => { - // Check if we're using proxy auth with a logout URL - const proxyAuth = proxyAuthInfo(); - if (proxyAuth?.logoutURL) { - // Redirect to proxy auth logout URL - window.location.href = proxyAuth.logoutURL; - return; - } - - try { - // Import the apiClient to get CSRF token support - const { apiFetch, clearAuth } = await import('./utils/apiClient'); - - // Clear any session data - this will include CSRF token - const response = await apiFetch('/api/logout', { - method: 'POST', - }); - - if (!response.ok) { - logger.error('Logout failed', { status: response.status }); - } - - // Clear auth from apiClient - clearAuth(); - } catch (error) { - logger.error('Logout error', error); - } - - // Clear only auth and session-specific storage, preserve user preferences - // Keys to clear on logout (auth and per-session caches) - const keysToRemove = [ - STORAGE_KEYS.AUTH, - STORAGE_KEYS.GUEST_METADATA, - STORAGE_KEYS.DOCKER_METADATA, - STORAGE_KEYS.DOCKER_METADATA + '_hosts', - ]; - keysToRemove.forEach((key) => localStorage.removeItem(key)); - sessionStorage.clear(); - localStorage.setItem('just_logged_out', 'true'); - - // Clear WebSocket connection - if (wsStore()) { - setWsStore(null); - } - - // Force reload to login page - window.location.href = '/'; - }; - - // Pass through the store directly (only when initialized) - const enhancedStore = () => wsStore(); - - // Workloads view - v2 resources only - const WorkloadsView = () => { - return ; - }; + const WorkloadsView = () => ; // V2 is GA - always serve V2 at canonical routes. const SettingsRoute = () => ( ); @@ -977,7 +226,7 @@ function App() { const isPublicRoute = createMemo(() => isPublicRoutePath(location.pathname)); useKeyboardShortcuts({ - enabled: () => !needsAuth(), + enabled: () => !runtime.needsAuth(), isShortcutsOpen: shortcutsOpen, isCommandPaletteOpen: commandPaletteOpen, onToggleShortcuts: () => { @@ -996,7 +245,7 @@ function App() { onMount(() => { // Only check AI settings if already authenticated (not on login screen) // Otherwise, the 401 response triggers a redirect loop - if (!needsAuth()) { + if (!runtime.needsAuth()) { import('./api/ai').then(({ AIAPI }) => { AIAPI.getSettings() .then((settings) => { @@ -1027,7 +276,7 @@ function App() { return (
Loading...
@@ -1038,26 +287,26 @@ function App() { when={isPublicRoute()} fallback={ } >
Initializing...
} > - - + + @@ -1076,21 +325,21 @@ 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`} > securityStatus()?.tokenScopes} - organizations={organizations} - activeOrgID={activeOrgID} - orgsLoading={orgsLoading} - onSwitchOrg={handleOrgSwitch} + connected={runtime.connected} + reconnecting={runtime.reconnecting} + dataUpdated={runtime.dataUpdated} + lastUpdateText={runtime.lastUpdateText} + versionInfo={runtime.versionInfo} + hasAuth={runtime.hasAuth} + needsAuth={runtime.needsAuth} + proxyAuthInfo={runtime.proxyAuthInfo} + handleLogout={runtime.handleLogout} + state={runtime.state} + tokenScopes={() => runtime.securityStatus()?.tokenScopes} + organizations={runtime.organizations} + activeOrgID={runtime.activeOrgID} + orgsLoading={runtime.orgsLoading} + onSwitchOrg={runtime.handleOrgSwitch} > {props.children} @@ -1150,804 +399,4 @@ function App() { ); } -function ConnectionStatusBadge(props: { - connected: () => boolean; - reconnecting: () => boolean; - class?: string; -}) { - return ( -
- - - - - - - - - - - - - - {props.connected() - ? 'Connected' - : props.reconnecting() - ? 'Reconnecting...' - : 'Disconnected'} - -
- ); -} - -function AppLayout(props: { - connected: () => boolean; - reconnecting: () => boolean; - dataUpdated: () => boolean; - lastUpdateText: () => string; - versionInfo: () => VersionInfo | null; - hasAuth: () => boolean; - needsAuth: () => boolean; - proxyAuthInfo: () => { username?: string; logoutURL?: string } | null; - handleLogout: () => void; - state: () => State; - tokenScopes: () => string[] | undefined; - organizations: () => Organization[]; - activeOrgID: () => string; - orgsLoading: () => boolean; - onSwitchOrg: (orgID: string) => void; - children?: JSX.Element; -}) { - const navigate = useNavigate(); - const location = useLocation(); - - // Reactive kiosk mode state (shared across all components) - const kioskMode = useKioskMode(); - - // Kiosk-mode header auto-hide/reveal state - const [headerVisible, setHeaderVisible] = createSignal(true); - let headerEl: HTMLDivElement | undefined; - let headerHideTimeout: ReturnType | undefined; - - const clearHeaderHideTimeout = () => { - if (headerHideTimeout !== undefined) { - clearTimeout(headerHideTimeout); - headerHideTimeout = undefined; - } - }; - - const showHeader = () => { - clearHeaderHideTimeout(); - setHeaderVisible(true); - }; - - const scheduleHideHeader = (delayMs: number) => { - clearHeaderHideTimeout(); - headerHideTimeout = setTimeout(() => { - setHeaderVisible(false); - headerHideTimeout = undefined; - }, delayMs); - }; - - // Auto-enable kiosk mode for monitoring-only tokens (if no user preference is set) - createEffect(() => { - const scopes = props.tokenScopes(); - // Only proceed if scopes are loaded and equal exactly ['monitoring:read'] - if (scopes && scopes.length === 1 && scopes[0] === MONITORING_READ_SCOPE) { - // Check if user has an explicit preference - const pref = getKioskModePreference(); - // If preference is unset (null), default to Kiosk Mode - if (pref === null) { - setKioskMode(true); - } - } - }); - - const toggleKioskMode = () => { - setKioskMode(!kioskMode()); - }; - - // Kiosk-mode header behavior: visible briefly on entry, hover/touch to reveal, Escape to exit. - createEffect(() => { - if (kioskMode()) { - setHeaderVisible(true); - scheduleHideHeader(1500); - } else { - clearHeaderHideTimeout(); - setHeaderVisible(true); - } - }); - - createEffect(() => { - if (!kioskMode()) return; - - const onKeyDown = (e: KeyboardEvent) => { - if (e.key === 'Escape') { - toggleKioskMode(); - } - }; - window.addEventListener('keydown', onKeyDown); - onCleanup(() => window.removeEventListener('keydown', onKeyDown)); - }); - - // Kiosk route guard: redirect away from config/control-plane pages. - // Data pages (dashboard, infrastructure, workloads, storage, recovery, ceph) are allowed. - // Alerts allows read-only tabs (overview, history); config tabs redirect. - createEffect(() => { - if (!kioskMode()) return; - const normalizedPath = location.pathname.replace(/\/+$/, '') || '/'; - - const blockedPrefixes = ['/settings', '/operations', '/ai']; - const isBlocked = blockedPrefixes.some( - (p) => normalizedPath === p || normalizedPath.startsWith(p + '/'), - ); - - const isAlertsPath = normalizedPath === '/alerts' || normalizedPath.startsWith('/alerts/'); - const isAlertConfigTab = - isAlertsPath && - normalizedPath !== '/alerts' && - normalizedPath !== '/alerts/overview' && - normalizedPath !== '/alerts/history' && - !normalizedPath.startsWith('/alerts/overview/') && - !normalizedPath.startsWith('/alerts/history/'); - - if ((isBlocked || isAlertConfigTab) && normalizedPath !== DASHBOARD_PATH) { - navigate(DASHBOARD_PATH, { replace: true }); - } - }); - - createEffect(() => { - if (!kioskMode()) return; - - // Touch support: tapping near the top shows header briefly. - const onPointerDown = (e: PointerEvent) => { - if (e.pointerType !== 'touch') return; - if (e.clientY > 60) return; - showHeader(); - scheduleHideHeader(3000); - }; - - // Use capture so it works even when the header is hidden (pointer-events: none). - window.addEventListener('pointerdown', onPointerDown, { passive: true, capture: true }); - onCleanup(() => window.removeEventListener('pointerdown', onPointerDown, true)); - - // Fallback for environments without PointerEvent support. - if (typeof (window as any).PointerEvent === 'undefined') { - const onTouchStart = (e: TouchEvent) => { - const t = e.touches?.[0]; - if (!t) return; - if (t.clientY > 60) return; - showHeader(); - scheduleHideHeader(3000); - }; - window.addEventListener('touchstart', onTouchStart, { passive: true, capture: true }); - onCleanup(() => window.removeEventListener('touchstart', onTouchStart, true)); - } - }); - - onCleanup(() => { - clearHeaderHideTimeout(); - }); - - // Determine active tab from current path - const getActiveTabDesktop = () => getActiveTabForPath(location.pathname); - const getActiveTabMobile = () => getActiveTabForPath(location.pathname); - - type PlatformTab = { - id: string; - label: string; - route: string; - settingsRoute: string; - tooltip: string; - enabled: boolean; - live: boolean; - icon: JSX.Element; - alwaysShow: boolean; - badge?: string; - }; - - const platformTabsDesktop = createMemo(() => { - const allPlatforms: PlatformTab[] = [ - { - id: 'dashboard' as const, - label: 'Dashboard', - route: DASHBOARD_PATH, - settingsRoute: '/settings', - tooltip: 'Environment overview and command center', - enabled: true, - live: true, - icon: , - alwaysShow: true, - }, - { - id: 'infrastructure' as const, - label: 'Infrastructure', - route: ROOT_INFRASTRUCTURE_PATH, - settingsRoute: '/settings', - tooltip: 'All agents and nodes across platforms', - enabled: true, - live: true, - icon: , - alwaysShow: true, - }, - { - id: 'workloads' as const, - label: 'Workloads', - route: ROOT_WORKLOADS_PATH, - settingsRoute: '/settings/workloads/docker', - tooltip: 'VMs, containers, and Kubernetes workloads', - enabled: true, - live: true, - icon: , - alwaysShow: true, - }, - ...buildStorageRecoveryTabSpecs().map((tab) => ({ - ...tab, - enabled: true, - live: true, - icon: tab.id.startsWith('storage') ? ( - - ) : ( - - ), - alwaysShow: true, - })), - ]; - - // Filter out platforms that should be hidden when not configured - return allPlatforms.filter((p) => p.alwaysShow || p.enabled); - }); - - const platformTabsMobile = createMemo(() => { - const allPlatforms: PlatformTab[] = [ - { - id: 'dashboard' as const, - label: 'Dashboard', - route: DASHBOARD_PATH, - settingsRoute: '/settings', - tooltip: 'Environment overview and command center', - enabled: true, - live: true, - icon: , - alwaysShow: true, - }, - { - id: 'infrastructure' as const, - label: 'Infrastructure', - route: ROOT_INFRASTRUCTURE_PATH, - settingsRoute: '/settings', - tooltip: 'All agents and nodes across platforms', - enabled: true, - live: true, - icon: , - alwaysShow: true, - }, - { - id: 'workloads' as const, - label: 'Workloads', - route: ROOT_WORKLOADS_PATH, - settingsRoute: '/settings/workloads/docker', - tooltip: 'VMs, containers, and Kubernetes workloads', - enabled: true, - live: true, - icon: , - alwaysShow: true, - }, - ...buildStorageRecoveryTabSpecs().map((tab) => ({ - ...tab, - enabled: true, - live: true, - icon: tab.id.startsWith('storage') ? ( - - ) : ( - - ), - alwaysShow: true, - })), - ]; - - return allPlatforms.filter((p) => p.alwaysShow || p.enabled); - }); - - const utilityTabs = createMemo(() => { - const allAlerts = props.state().activeAlerts || []; - const breakdown = allAlerts.reduce( - (acc, alert: Alert) => { - if (alert?.acknowledged) return acc; - const level = String(alert?.level || '').toLowerCase(); - if (level === 'critical') { - acc.critical += 1; - } else { - acc.warning += 1; - } - return acc; - }, - { warning: 0, critical: 0 }, - ); - const activeAlertCount = breakdown.warning + breakdown.critical; - - // Check if settings should be shown based on token scopes - // If no scopes (session auth), show settings - // If scopes include '*' (wildcard) or 'settings:read', show settings - const scopes = props.tokenScopes(); - const hasSettingsAccess = - !scopes || scopes.length === 0 || scopes.includes('*') || scopes.includes('settings:read'); - - const tabs: Array<{ - id: 'alerts' | 'ai' | 'operations' | 'settings'; - label: string; - route: string; - tooltip: string; - badge: 'update' | 'pro' | null; - count: number | undefined; - breakdown: { warning: number; critical: number } | undefined; - icon: JSX.Element; - }> = [ - { - id: 'alerts', - label: 'Alerts', - route: '/alerts', - tooltip: 'Review active alerts and automation rules', - badge: null, - count: activeAlertCount, - breakdown, - icon: , - }, - { - id: 'ai', - label: 'Patrol', - route: '/ai', - tooltip: 'Pulse Patrol monitoring and analysis', - badge: null, // Patrol is free with BYOK; auto-fix is Pro - count: undefined, - breakdown: undefined, - icon: , - }, - { - id: 'operations', - label: 'Operations', - route: '/operations', - tooltip: 'System operations, diagnostics, and reporting', - badge: null, - count: undefined, - breakdown: undefined, - icon: , - }, - ]; - - // Only show settings tab if user has access - if (hasSettingsAccess) { - tabs.push({ - id: 'settings', - label: 'Settings', - route: '/settings', - tooltip: 'Configure Pulse preferences and integrations', - badge: updateStore.isUpdateVisible() ? 'update' : null, - count: undefined, - breakdown: undefined, - icon: , - }); - } - - return tabs; - }); - - const handlePlatformClick = (platform: PlatformTab) => { - if (platform.enabled) { - navigate(platform.route); - } else { - navigate(platform.settingsRoute); - } - }; - - const handleUtilityClick = (tab: ReturnType[number]) => { - navigate(tab.route); - }; - - return ( -
- {/* Header - simplified in kiosk mode */} - - - - - - - - - {/* Footer - hidden in kiosk mode */} - - - - - {/* Fixed AI Assistant Button - only shows when chat is CLOSED and NOT in kiosk mode */} - - - -
- ); -} - export default App; // Test hot-reload comment $(date) diff --git a/frontend-modern/src/AppLayout.tsx b/frontend-modern/src/AppLayout.tsx new file mode 100644 index 000000000..d37adb0ba --- /dev/null +++ b/frontend-modern/src/AppLayout.tsx @@ -0,0 +1,813 @@ +import { + For, + Show, + Suspense, + createEffect, + createMemo, + createSignal, + onCleanup, +} from 'solid-js'; +import type { JSX } from 'solid-js'; +import { useLocation, useNavigate } from '@solidjs/router'; +import BoxesIcon from 'lucide-solid/icons/boxes'; +import LayoutDashboardIcon from 'lucide-solid/icons/layout-dashboard'; +import ServerIcon from 'lucide-solid/icons/server'; +import HardDriveIcon from 'lucide-solid/icons/hard-drive'; +import ArchiveIcon from 'lucide-solid/icons/archive'; +import BellIcon from 'lucide-solid/icons/bell'; +import SettingsIcon from 'lucide-solid/icons/settings'; +import Maximize2Icon from 'lucide-solid/icons/maximize-2'; +import Minimize2Icon from 'lucide-solid/icons/minimize-2'; +import ActivityIcon from 'lucide-solid/icons/activity'; +import { MobileNavBar } from '@/components/shared/MobileNavBar'; +import { OrgSwitcher } from '@/components/OrgSwitcher'; +import { PulsePatrolLogo } from '@/components/Brand/PulsePatrolLogo'; +import { MONITORING_READ_SCOPE } from '@/constants/apiScopes'; +import type { Organization } from '@/api/orgs'; +import type { VersionInfo } from '@/api/updates'; +import type { Alert, State } from '@/types/api'; +import { useKioskMode } from '@/hooks/useKioskMode'; +import { layoutStore } from '@/utils/layout'; +import { getActiveTabForPath } from '@/routing/navigation'; +import { + buildInfrastructurePath, + buildWorkloadsPath, + DASHBOARD_PATH, +} from '@/routing/resourceLinks'; +import { buildStorageRecoveryTabSpecs } from '@/routing/platformTabs'; +import { getKioskModePreference, setKioskMode } from '@/utils/url'; +import { updateStore } from '@/stores/updates'; +import { aiChatStore } from '@/stores/aiChat'; +import { isMultiTenantEnabled, isPro } from '@/stores/license'; + +const ROOT_INFRASTRUCTURE_PATH = buildInfrastructurePath(); +const ROOT_WORKLOADS_PATH = buildWorkloadsPath(); + +type PlatformTab = { + id: string; + label: string; + route: string; + settingsRoute: string; + tooltip: string; + enabled: boolean; + live: boolean; + icon: JSX.Element; + alwaysShow: boolean; + badge?: string; +}; + +type UtilityTab = { + id: 'alerts' | 'ai' | 'operations' | 'settings'; + label: string; + route: string; + tooltip: string; + badge: 'update' | 'pro' | null; + count: number | undefined; + breakdown: { warning: number; critical: number } | undefined; + icon: JSX.Element; +}; + +export interface AppLayoutProps { + connected: () => boolean; + reconnecting: () => boolean; + dataUpdated: () => boolean; + lastUpdateText: () => string; + versionInfo: () => VersionInfo | null; + hasAuth: () => boolean; + needsAuth: () => boolean; + proxyAuthInfo: () => { username?: string; logoutURL?: string } | null; + handleLogout: () => void; + state: () => State; + tokenScopes: () => string[] | undefined; + organizations: () => Organization[]; + activeOrgID: () => string; + orgsLoading: () => boolean; + onSwitchOrg: (orgID: string) => void; + children?: JSX.Element; +} + +function ConnectionStatusBadge(props: { + connected: () => boolean; + reconnecting: () => boolean; + class?: string; +}) { + return ( +
+ + + + + + + + + + + + + + {props.connected() + ? 'Connected' + : props.reconnecting() + ? 'Reconnecting...' + : 'Disconnected'} + +
+ ); +} + +export function AppLayout(props: AppLayoutProps) { + const navigate = useNavigate(); + const location = useLocation(); + const kioskMode = useKioskMode(); + + const [headerVisible, setHeaderVisible] = createSignal(true); + let headerEl: HTMLDivElement | undefined; + let headerHideTimeout: ReturnType | undefined; + + const clearHeaderHideTimeout = () => { + if (headerHideTimeout !== undefined) { + clearTimeout(headerHideTimeout); + headerHideTimeout = undefined; + } + }; + + const showHeader = () => { + clearHeaderHideTimeout(); + setHeaderVisible(true); + }; + + const scheduleHideHeader = (delayMs: number) => { + clearHeaderHideTimeout(); + headerHideTimeout = setTimeout(() => { + setHeaderVisible(false); + headerHideTimeout = undefined; + }, delayMs); + }; + + createEffect(() => { + const scopes = props.tokenScopes(); + if (scopes && scopes.length === 1 && scopes[0] === MONITORING_READ_SCOPE) { + const preference = getKioskModePreference(); + if (preference === null) { + setKioskMode(true); + } + } + }); + + const toggleKioskMode = () => { + setKioskMode(!kioskMode()); + }; + + createEffect(() => { + if (kioskMode()) { + setHeaderVisible(true); + scheduleHideHeader(1500); + } else { + clearHeaderHideTimeout(); + setHeaderVisible(true); + } + }); + + createEffect(() => { + if (!kioskMode()) return; + + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + toggleKioskMode(); + } + }; + window.addEventListener('keydown', onKeyDown); + onCleanup(() => window.removeEventListener('keydown', onKeyDown)); + }); + + createEffect(() => { + if (!kioskMode()) return; + const normalizedPath = location.pathname.replace(/\/+$/, '') || '/'; + const blockedPrefixes = ['/settings', '/operations', '/ai']; + const isBlocked = blockedPrefixes.some( + (prefix) => normalizedPath === prefix || normalizedPath.startsWith(prefix + '/'), + ); + + const isAlertsPath = normalizedPath === '/alerts' || normalizedPath.startsWith('/alerts/'); + const isAlertConfigTab = + isAlertsPath && + normalizedPath !== '/alerts' && + normalizedPath !== '/alerts/overview' && + normalizedPath !== '/alerts/history' && + !normalizedPath.startsWith('/alerts/overview/') && + !normalizedPath.startsWith('/alerts/history/'); + + if ((isBlocked || isAlertConfigTab) && normalizedPath !== DASHBOARD_PATH) { + navigate(DASHBOARD_PATH, { replace: true }); + } + }); + + createEffect(() => { + if (!kioskMode()) return; + + const onPointerDown = (event: PointerEvent) => { + if (event.pointerType !== 'touch') return; + if (event.clientY > 60) return; + showHeader(); + scheduleHideHeader(3000); + }; + + window.addEventListener('pointerdown', onPointerDown, { passive: true, capture: true }); + onCleanup(() => window.removeEventListener('pointerdown', onPointerDown, true)); + + if (typeof (window as { PointerEvent?: unknown }).PointerEvent === 'undefined') { + const onTouchStart = (event: TouchEvent) => { + const touch = event.touches?.[0]; + if (!touch || touch.clientY > 60) return; + showHeader(); + scheduleHideHeader(3000); + }; + window.addEventListener('touchstart', onTouchStart, { passive: true, capture: true }); + onCleanup(() => window.removeEventListener('touchstart', onTouchStart, true)); + } + }); + + onCleanup(() => { + clearHeaderHideTimeout(); + }); + + const getActiveTabDesktop = () => getActiveTabForPath(location.pathname); + const getActiveTabMobile = () => getActiveTabForPath(location.pathname); + + const platformTabsDesktop = createMemo(() => { + const allPlatforms: PlatformTab[] = [ + { + id: 'dashboard', + label: 'Dashboard', + route: DASHBOARD_PATH, + settingsRoute: '/settings', + tooltip: 'Environment overview and command center', + enabled: true, + live: true, + icon: , + alwaysShow: true, + }, + { + id: 'infrastructure', + label: 'Infrastructure', + route: ROOT_INFRASTRUCTURE_PATH, + settingsRoute: '/settings', + tooltip: 'All agents and nodes across platforms', + enabled: true, + live: true, + icon: , + alwaysShow: true, + }, + { + id: 'workloads', + label: 'Workloads', + route: ROOT_WORKLOADS_PATH, + settingsRoute: '/settings/workloads/docker', + tooltip: 'VMs, containers, and Kubernetes workloads', + enabled: true, + live: true, + icon: , + alwaysShow: true, + }, + ...buildStorageRecoveryTabSpecs().map((tab) => ({ + ...tab, + enabled: true, + live: true, + icon: tab.id.startsWith('storage') ? ( + + ) : ( + + ), + alwaysShow: true, + })), + ]; + + return allPlatforms.filter((platform) => platform.alwaysShow || platform.enabled); + }); + + const platformTabsMobile = createMemo(() => { + const allPlatforms: PlatformTab[] = [ + { + id: 'dashboard', + label: 'Dashboard', + route: DASHBOARD_PATH, + settingsRoute: '/settings', + tooltip: 'Environment overview and command center', + enabled: true, + live: true, + icon: , + alwaysShow: true, + }, + { + id: 'infrastructure', + label: 'Infrastructure', + route: ROOT_INFRASTRUCTURE_PATH, + settingsRoute: '/settings', + tooltip: 'All agents and nodes across platforms', + enabled: true, + live: true, + icon: , + alwaysShow: true, + }, + { + id: 'workloads', + label: 'Workloads', + route: ROOT_WORKLOADS_PATH, + settingsRoute: '/settings/workloads/docker', + tooltip: 'VMs, containers, and Kubernetes workloads', + enabled: true, + live: true, + icon: , + alwaysShow: true, + }, + ...buildStorageRecoveryTabSpecs().map((tab) => ({ + ...tab, + enabled: true, + live: true, + icon: tab.id.startsWith('storage') ? ( + + ) : ( + + ), + alwaysShow: true, + })), + ]; + + return allPlatforms.filter((platform) => platform.alwaysShow || platform.enabled); + }); + + const utilityTabs = createMemo(() => { + const allAlerts = props.state().activeAlerts || []; + const breakdown = allAlerts.reduce( + (accumulator, alert: Alert) => { + if (alert?.acknowledged) return accumulator; + const level = String(alert?.level || '').toLowerCase(); + if (level === 'critical') { + accumulator.critical += 1; + } else { + accumulator.warning += 1; + } + return accumulator; + }, + { warning: 0, critical: 0 }, + ); + const activeAlertCount = breakdown.warning + breakdown.critical; + + const scopes = props.tokenScopes(); + const hasSettingsAccess = + !scopes || scopes.length === 0 || scopes.includes('*') || scopes.includes('settings:read'); + + const tabs: UtilityTab[] = [ + { + id: 'alerts', + label: 'Alerts', + route: '/alerts', + tooltip: 'Review active alerts and automation rules', + badge: null, + count: activeAlertCount, + breakdown, + icon: , + }, + { + id: 'ai', + label: 'Patrol', + route: '/ai', + tooltip: 'Pulse Patrol monitoring and analysis', + badge: null, + count: undefined, + breakdown: undefined, + icon: , + }, + { + id: 'operations', + label: 'Operations', + route: '/operations', + tooltip: 'System operations, diagnostics, and reporting', + badge: null, + count: undefined, + breakdown: undefined, + icon: , + }, + ]; + + if (hasSettingsAccess) { + tabs.push({ + id: 'settings', + label: 'Settings', + route: '/settings', + tooltip: 'Configure Pulse preferences and integrations', + badge: updateStore.isUpdateVisible() ? 'update' : null, + count: undefined, + breakdown: undefined, + icon: , + }); + } + + return tabs; + }); + + const handlePlatformClick = (platform: PlatformTab) => { + if (platform.enabled) { + navigate(platform.route); + } else { + navigate(platform.settingsRoute); + } + }; + + const handleUtilityClick = (tab: UtilityTab) => { + navigate(tab.route); + }; + + return ( +
+ + + + + + + + + + + + + + + +
+ ); +} diff --git a/frontend-modern/src/__tests__/App.architecture.test.ts b/frontend-modern/src/__tests__/App.architecture.test.ts new file mode 100644 index 000000000..fdd3cebf3 --- /dev/null +++ b/frontend-modern/src/__tests__/App.architecture.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from 'vitest'; +import appSource from '@/App.tsx?raw'; +import appLayoutSource from '@/AppLayout.tsx?raw'; +import appRuntimeStateSource from '@/useAppRuntimeState.ts?raw'; + +describe('App architecture', () => { + it('keeps App as the entry shell that delegates runtime and chrome ownership', () => { + expect(appSource).toContain("import { AppLayout } from '@/AppLayout';"); + expect(appSource).toContain("import { useAppRuntimeState } from '@/useAppRuntimeState';"); + expect(appSource).toContain('const runtime = useAppRuntimeState();'); + expect(appSource).not.toContain('function ConnectionStatusBadge('); + expect(appSource).not.toContain('function AppLayout('); + expect(appSource).not.toContain('const [organizations, setOrganizations] = createSignal('); + expect(appSource).not.toContain('const [themePreference, setThemePreference] ='); + expect(appSource).not.toContain('const [activeOrgID, setActiveOrgID] = createSignal('); + }); + + it('keeps authenticated chrome in AppLayout and hosted bootstrap in useAppRuntimeState', () => { + expect(appLayoutSource).toContain('export function AppLayout(props: AppLayoutProps)'); + expect(appLayoutSource).toContain(''); + expect(appRuntimeStateSource).toContain('export const useAppRuntimeState = () =>'); + expect(appRuntimeStateSource).toContain('const beginAuthenticatedRuntime = async () =>'); + expect(appRuntimeStateSource).toContain('const loadOrganizations = async () =>'); + expect(appRuntimeStateSource).toContain('const handleOrgSwitch = (nextOrgID: string) =>'); + expect(appRuntimeStateSource).toContain( + 'const [activeOrgID, setActiveOrgID] = createSignal(', + ); + expect(appRuntimeStateSource).not.toContain('function AppLayout('); + }); +}); diff --git a/frontend-modern/src/useAppRuntimeState.ts b/frontend-modern/src/useAppRuntimeState.ts new file mode 100644 index 000000000..d29dda628 --- /dev/null +++ b/frontend-modern/src/useAppRuntimeState.ts @@ -0,0 +1,649 @@ +import { + createEffect, + createSignal, + getOwner, + onCleanup, + onMount, + runWithOwner, +} from 'solid-js'; +import { getGlobalWebSocketStore } from '@/stores/websocket-global'; +import { logger } from '@/utils/logger'; +import { POLLING_INTERVALS } from '@/constants'; +import { STORAGE_KEYS } from '@/utils/localStorage'; +import type { TimeRange } from '@/api/charts'; +import type { VersionInfo } from '@/api/updates'; +import type { Organization } from '@/api/orgs'; +import { OrgsAPI } from '@/api/orgs'; +import type { SecurityStatus } from '@/types/config'; +import type { State } from '@/types/api'; +import { SettingsAPI } from '@/api/settings'; +import { + apiFetch, + getOrgID as getSelectedOrgID, + setOrgID as setSelectedOrgID, +} from '@/utils/apiClient'; +import { eventBus } from '@/stores/events'; +import { showToast } from '@/utils/toast'; +import { updateStore } from '@/stores/updates'; +import { + fetchInfrastructureSummaryAndCache, + hasFreshInfrastructureSummaryCache, +} from '@/utils/infrastructureSummaryCache'; +import { buildInfrastructurePath } from '@/routing/resourceLinks'; +import { startMetricsCollector } from '@/stores/metricsCollector'; +import { useAlertsActivation } from '@/stores/alertsActivation'; +import { + applyThemeClass, + computeIsDark, + getStoredThemePreference, + hasStoredThemePreference, + normalizeThemePreference, + persistThemePreference, + type ThemePreference, +} from '@/utils/theme'; +import { + initKioskMode, + getPulseWebSocketUrl, +} from '@/utils/url'; +import { syncKioskMode } from '@/hooks/useKioskMode'; +import { + isHostedModeEnabled, + isMultiTenantEnabled, + licenseLoaded, + loadLicenseStatus, +} from '@/stores/license'; +import { layoutStore } from '@/utils/layout'; +import { + markSystemSettingsLoadedWithDefaults, + updateSystemSettingsFromResponse, +} from '@/stores/systemSettings'; + +type EnhancedStore = ReturnType; + +const ROOT_INFRASTRUCTURE_PATH = buildInfrastructurePath(); + +export const useAppRuntimeState = () => { + initKioskMode(); + syncKioskMode(); + + const owner = getOwner(); + const acquireWsStore = (): EnhancedStore => { + const store = owner + ? runWithOwner(owner, () => getGlobalWebSocketStore()) + : getGlobalWebSocketStore(); + return store || getGlobalWebSocketStore(); + }; + + const alertsActivation = useAlertsActivation(); + + onMount(() => { + startMetricsCollector(); + }); + + let hasPreloadedRoutes = false; + let hasFetchedVersionInfo = false; + let hasPrewarmedInfrastructureCharts = false; + + const getInfrastructureTrendRangeForPrewarm = (): TimeRange => '1h'; + + const shouldPrewarmInfrastructure = (): boolean => { + if (typeof window === 'undefined') return false; + + const connection = ( + navigator as unknown as { connection?: { saveData?: boolean; effectiveType?: string } } + ).connection; + if (connection?.saveData) return false; + const effectiveType = connection?.effectiveType; + if ( + typeof effectiveType === 'string' && + (effectiveType === 'slow-2g' || effectiveType === '2g') + ) { + return false; + } + + const pathname = window.location.pathname; + if (!pathname) return true; + if (pathname === ROOT_INFRASTRUCTURE_PATH) return false; + return true; + }; + + const prewarmInfrastructureCharts = () => { + if (hasPrewarmedInfrastructureCharts || !shouldPrewarmInfrastructure()) { + return; + } + + const range = getInfrastructureTrendRangeForPrewarm(); + if (hasFreshInfrastructureSummaryCache(range)) { + hasPrewarmedInfrastructureCharts = true; + return; + } + + hasPrewarmedInfrastructureCharts = true; + void fetchInfrastructureSummaryAndCache(range, { caller: 'App prewarm' }).catch(() => { + // Non-blocking prewarm; ignore failures. + }); + }; + + const preloadLazyRoutes = () => { + if (hasPreloadedRoutes || typeof window === 'undefined') { + return; + } + hasPreloadedRoutes = true; + const loaders: Array<() => Promise> = [ + () => import('@/pages/Alerts'), + () => import('@/components/Settings/Settings'), + ]; + + loaders.forEach((load) => { + void load().catch((error) => { + logger.warn('Preloading route module failed', error); + }); + }); + }; + + const fallbackState: State = { + connectedInfrastructure: [], + metrics: [], + performance: { + apiCallDuration: {}, + lastPollDuration: 0, + pollingStartTime: '', + totalApiCalls: 0, + failedApiCalls: 0, + cacheHits: 0, + cacheMisses: 0, + }, + connectionHealth: {}, + stats: { + startTime: new Date().toISOString(), + uptime: 0, + pollingCycles: 0, + webSocketClients: 0, + version: '0.0.0', + }, + activeAlerts: [], + recentlyResolved: [], + lastUpdate: 0, + resources: [], + }; + + const [isLoading, setIsLoading] = createSignal(true); + const [needsAuth, setNeedsAuth] = createSignal(false); + const [hasAuth, setHasAuth] = createSignal(false); + const [organizations, setOrganizations] = createSignal([ + { id: 'default', displayName: 'Default Organization' }, + ]); + const [activeOrgID, setActiveOrgID] = createSignal(getSelectedOrgID() || 'default'); + const [orgsLoading, setOrgsLoading] = createSignal(false); + const [securityStatus, setSecurityStatus] = createSignal(null); + const [proxyAuthInfo, setProxyAuthInfo] = createSignal<{ + username?: string; + logoutURL?: string; + } | null>(null); + const [wsStore, setWsStore] = createSignal(null); + const state = (): State => wsStore()?.state || fallbackState; + const connected = () => wsStore()?.connected() || false; + const reconnecting = () => wsStore()?.reconnecting() || false; + const [dataUpdated, setDataUpdated] = createSignal(false); + const [lastUpdateText, setLastUpdateText] = createSignal(''); + const [versionInfo, setVersionInfo] = createSignal(null); + + const initialThemePreference = getStoredThemePreference(); + const [themePreference, setThemePreference] = + createSignal(initialThemePreference); + const [, setHasLoadedServerTheme] = createSignal(false); + const [darkMode, setDarkMode] = createSignal(computeIsDark(initialThemePreference)); + applyThemeClass(darkMode()); + + const applyThemePreferenceLocally = (preference: ThemePreference) => { + setThemePreference(preference); + persistThemePreference(preference); + const isDark = computeIsDark(preference); + setDarkMode(isDark); + applyThemeClass(isDark); + }; + + const applyServerThemeIfAllowed = (theme?: string) => { + if (!hasStoredThemePreference() && theme && theme !== '') { + applyThemePreferenceLocally(normalizeThemePreference(theme)); + } + }; + + const loadSystemSettingsAndLayout = async () => { + try { + const systemSettings = await SettingsAPI.getSystemSettings(); + updateSystemSettingsFromResponse(systemSettings); + applyServerThemeIfAllowed(systemSettings.theme); + setHasLoadedServerTheme(true); + layoutStore.loadFromServer(); + } catch (error) { + logger.error('Failed to load system settings from server', error); + markSystemSettingsLoadedWithDefaults(); + } + }; + + const beginAuthenticatedRuntime = async () => { + setNeedsAuth(false); + await loadOrganizations(); + setWsStore(acquireWsStore()); + await loadSystemSettingsAndLayout(); + }; + + const loadOrganizations = async () => { + setOrgsLoading(true); + try { + if (!licenseLoaded()) { + await loadLicenseStatus(); + } + + if (!isMultiTenantEnabled()) { + const storedOrgID = getSelectedOrgID(); + const hostedOrgID = + isHostedModeEnabled() && storedOrgID && storedOrgID !== 'default' + ? storedOrgID + : 'default'; + setOrganizations([ + { + id: hostedOrgID, + displayName: hostedOrgID === 'default' ? 'Default Organization' : hostedOrgID, + }, + ]); + setSelectedOrgID(hostedOrgID); + setActiveOrgID(hostedOrgID); + return; + } + + const fetched = await OrgsAPI.list(); + const organizationList = + fetched.length > 0 ? fetched : [{ id: 'default', displayName: 'Default Organization' }]; + setOrganizations(organizationList); + + const storedOrgID = getSelectedOrgID(); + const selectedOrg = + (storedOrgID && organizationList.some((org) => org.id === storedOrgID) + ? storedOrgID + : null) || + organizationList[0]?.id || + 'default'; + + setSelectedOrgID(selectedOrg); + setActiveOrgID(selectedOrg); + } catch (error) { + logger.warn('Failed to load organizations, falling back to default org', error); + showToast('error', 'Failed to load organizations. Using default.'); + const fallback = [{ id: 'default', displayName: 'Default Organization' }]; + setOrganizations(fallback); + setSelectedOrgID('default'); + setActiveOrgID('default'); + } finally { + setOrgsLoading(false); + } + }; + + const handleOrgSwitch = (nextOrgID: string) => { + const target = nextOrgID?.trim() || 'default'; + if (target === activeOrgID()) { + return; + } + + if (target !== 'default' && !organizations().some((org) => org.id === target)) { + showToast('error', 'Organization no longer exists'); + return; + } + + setSelectedOrgID(target); + setActiveOrgID(target); + eventBus.emit('org_switched', target); + + try { + localStorage.removeItem(STORAGE_KEYS.GUEST_METADATA); + localStorage.removeItem(STORAGE_KEYS.DOCKER_METADATA); + localStorage.removeItem(STORAGE_KEYS.DOCKER_METADATA + '_hosts'); + } catch { + // Ignore storage errors. + } + + try { + const store = wsStore(); + if (store && typeof store.switchUrl === 'function') { + store.switchUrl(getPulseWebSocketUrl()); + } else { + store?.reconnect(); + } + showToast('success', 'Organization switched'); + } catch (error) { + logger.error('Failed to switch organization', error); + showToast('error', 'Failed to switch organization'); + } + }; + + const formatLastUpdate = (timestamp: number) => { + if (!timestamp) return ''; + const date = new Date(timestamp); + return date.toLocaleTimeString('en-US', { + hour: 'numeric', + minute: '2-digit', + second: '2-digit', + hour12: true, + }); + }; + + let updateTimeout: number | undefined; + createEffect(() => { + const updateTime = state().lastUpdate; + if (updateTime > 0) { + setDataUpdated(true); + setLastUpdateText(formatLastUpdate(updateTime)); + if (updateTimeout !== undefined) { + window.clearTimeout(updateTimeout); + } + updateTimeout = window.setTimeout(() => setDataUpdated(false), POLLING_INTERVALS.DATA_FLASH); + } + }); + + onCleanup(() => { + if (updateTimeout !== undefined) { + window.clearTimeout(updateTimeout); + } + }); + + createEffect(() => { + if (!isLoading() && !needsAuth()) { + if (typeof window === 'undefined') { + return; + } + if (!hasPreloadedRoutes) { + window.setTimeout(preloadLazyRoutes, 0); + } + } + }); + + createEffect(() => { + if (isLoading() || needsAuth() || hasPrewarmedInfrastructureCharts) { + return; + } + if (typeof window === 'undefined') { + return; + } + + if (typeof window.requestIdleCallback === 'function') { + const id = window.requestIdleCallback(() => { + prewarmInfrastructureCharts(); + }, { timeout: 2_000 }); + onCleanup(() => { + window.cancelIdleCallback(id); + }); + return; + } + + const timeout = window.setTimeout(() => { + prewarmInfrastructureCharts(); + }, 500); + onCleanup(() => { + window.clearTimeout(timeout); + }); + }); + + const syncVersionInfoFromUpdateStore = async () => { + const cachedVersion = updateStore.versionInfo(); + if (cachedVersion) { + setVersionInfo(cachedVersion); + } + + await updateStore.checkForUpdates(); + + const resolvedVersion = updateStore.versionInfo(); + if (resolvedVersion) { + setVersionInfo(resolvedVersion); + } + }; + + createEffect(() => { + if (isLoading() || needsAuth() || hasFetchedVersionInfo) { + return; + } + hasFetchedVersionInfo = true; + void syncVersionInfoFromUpdateStore(); + }); + + let alertsInitialized = false; + createEffect(() => { + const ready = !isLoading() && !needsAuth(); + if (ready && !alertsInitialized) { + alertsInitialized = true; + void alertsActivation.refreshConfig(); + void alertsActivation.refreshActiveAlerts(); + } + if (!ready) { + alertsInitialized = false; + } + }); + + const handleThemeChange = async (newPreference: ThemePreference) => { + applyThemePreferenceLocally(newPreference); + logger.info('Theme changed', { pref: newPreference, active: computeIsDark(newPreference) ? 'dark' : 'light' }); + + if (!needsAuth()) { + try { + await SettingsAPI.updateSystemSettings({ theme: newPreference }); + logger.info('Theme preference saved to server'); + } catch (error) { + logger.error('Failed to save theme preference to server', error); + } + } + }; + + onMount(() => { + const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)'); + const systemThemeListener = (event: MediaQueryListEvent) => { + if (themePreference() === 'system') { + const isDark = event.matches; + setDarkMode(isDark); + applyThemeClass(isDark); + } + }; + mediaQuery.addEventListener('change', systemThemeListener); + + const handleRemoteThemeChange = (theme?: string) => { + if (!theme) return; + logger.info('Received theme change from another browser instance', { theme }); + applyThemePreferenceLocally(normalizeThemePreference(theme)); + }; + + const handleWebSocketReconnected = () => { + logger.info('WebSocket reconnected, refreshing alert configuration'); + void alertsActivation.refreshConfig(); + void alertsActivation.refreshActiveAlerts(); + }; + + eventBus.on('theme_changed', handleRemoteThemeChange); + eventBus.on('websocket_reconnected', handleWebSocketReconnected); + + onCleanup(() => { + mediaQuery.removeEventListener('change', systemThemeListener); + eventBus.off('theme_changed', handleRemoteThemeChange); + eventBus.off('websocket_reconnected', handleWebSocketReconnected); + }); + }); + + onMount(async () => { + logger.debug('[App] Starting auth check...'); + const justLoggedOut = localStorage.getItem('just_logged_out'); + + try { + const securityResponse = await apiFetch('/api/security/status'); + + if (securityResponse.status === 401) { + logger.warn( + '[App] Security status request returned 401. Clearing stored credentials and showing login.', + ); + try { + const { clearAuth } = await import('@/utils/apiClient'); + clearAuth(); + } catch (clearError) { + logger.warn('[App] Failed to clear stored auth after 401', clearError); + } + setHasAuth(false); + setNeedsAuth(true); + setIsLoading(false); + return; + } + + if (justLoggedOut) { + localStorage.removeItem('just_logged_out'); + logger.debug('[App] User logged out, showing login page'); + if (securityResponse.ok) { + setSecurityStatus((await securityResponse.json()) as SecurityStatus); + } + setHasAuth(true); + setNeedsAuth(true); + setIsLoading(false); + return; + } + + if (!securityResponse.ok) { + throw new Error(`Security status request failed with status ${securityResponse.status}`); + } + + const securityData = (await securityResponse.json()) as SecurityStatus & { + hasAuthentication?: boolean; + hasProxyAuth?: boolean; + proxyAuthUsername?: string; + proxyAuthLogoutURL?: string; + ssoEnabled?: boolean; + ssoSessionUsername?: string; + ssoLogoutURL?: string; + }; + logger.debug('[App] Security status fetched', securityData); + setSecurityStatus(securityData); + + const authConfigured = securityData.hasAuthentication || false; + setHasAuth(authConfigured); + + if (securityData.hasProxyAuth && securityData.proxyAuthUsername) { + logger.info('[App] Proxy auth detected', { user: securityData.proxyAuthUsername }); + setProxyAuthInfo({ + username: securityData.proxyAuthUsername, + logoutURL: securityData.proxyAuthLogoutURL, + }); + await beginAuthenticatedRuntime(); + void syncVersionInfoFromUpdateStore(); + setIsLoading(false); + return; + } + + if (securityData.ssoEnabled && securityData.ssoSessionUsername) { + logger.info('[App] SSO session detected', { user: securityData.ssoSessionUsername }); + setHasAuth(true); + setProxyAuthInfo({ + username: securityData.ssoSessionUsername, + logoutURL: securityData.ssoLogoutURL, + }); + await beginAuthenticatedRuntime(); + void syncVersionInfoFromUpdateStore(); + setIsLoading(false); + return; + } + + if (!authConfigured) { + logger.info('[App] No auth configured, showing Login/FirstRunSetup'); + setNeedsAuth(true); + setIsLoading(false); + return; + } + + const stateResponse = await apiFetch('/api/state', { + headers: { + 'X-Requested-With': 'XMLHttpRequest', + Accept: 'application/json', + }, + }); + + if (stateResponse.status === 401) { + setNeedsAuth(true); + } else { + await beginAuthenticatedRuntime(); + } + } catch (error) { + logger.error('Auth check error', error); + try { + const { clearAuth } = await import('@/utils/apiClient'); + clearAuth(); + } catch (clearError) { + logger.warn('[App] Failed to clear stored auth after auth check error', clearError); + } + setHasAuth(false); + setNeedsAuth(true); + } finally { + setIsLoading(false); + } + }); + + const handleLogin = () => { + window.location.reload(); + }; + + const handleLogout = async () => { + const proxyAuth = proxyAuthInfo(); + if (proxyAuth?.logoutURL) { + window.location.href = proxyAuth.logoutURL; + return; + } + + try { + const { apiFetch: runtimeApiFetch, clearAuth } = await import('@/utils/apiClient'); + const response = await runtimeApiFetch('/api/logout', { + method: 'POST', + }); + + if (!response.ok) { + logger.error('Logout failed', { status: response.status }); + } + clearAuth(); + } catch (error) { + logger.error('Logout error', error); + } + + const keysToRemove = [ + STORAGE_KEYS.AUTH, + STORAGE_KEYS.GUEST_METADATA, + STORAGE_KEYS.DOCKER_METADATA, + STORAGE_KEYS.DOCKER_METADATA + '_hosts', + ]; + keysToRemove.forEach((key) => localStorage.removeItem(key)); + sessionStorage.clear(); + localStorage.setItem('just_logged_out', 'true'); + + if (wsStore()) { + setWsStore(null); + } + + window.location.href = '/'; + }; + + const enhancedStore = () => wsStore(); + + return { + isLoading, + needsAuth, + hasAuth, + organizations, + activeOrgID, + orgsLoading, + securityStatus, + proxyAuthInfo, + state, + connected, + reconnecting, + dataUpdated, + lastUpdateText, + versionInfo, + themePreference, + darkMode, + handleThemeChange, + handleOrgSwitch, + handleLogin, + handleLogout, + enhancedStore, + }; +};