Pulse/frontend-modern/src/App.tsx
rcourtman 88ad986877 Revert "Hide Settings tab when authentication is not configured"
This reverts commit d5a1e3d07729bad61743e8645a636e2545e11038.
2025-11-05 23:21:34 +00:00

1161 lines
41 KiB
TypeScript

import {
Show,
For,
Suspense,
lazy,
createSignal,
createContext,
useContext,
createEffect,
createMemo,
onCleanup,
onMount,
getOwner,
runWithOwner,
} from 'solid-js';
import type { JSX } from 'solid-js';
import { Router, Route, Navigate, useNavigate, useLocation } from '@solidjs/router';
import { getGlobalWebSocketStore } from './stores/websocket-global';
import { ToastContainer } from './components/Toast/Toast';
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 { UpdatesAPI } from './api/updates';
import type { VersionInfo } from './api/updates';
import { apiFetch } from './utils/apiClient';
import { SettingsAPI } from './api/settings';
import { eventBus } from './stores/events';
import { updateStore } from './stores/updates';
import { UpdateBanner } from './components/UpdateBanner';
import { DemoBanner } from './components/DemoBanner';
import { createTooltipSystem } from './components/shared/Tooltip';
import type { State } from '@/types/api';
import { ProxmoxIcon } from '@/components/icons/ProxmoxIcon';
import BoxesIcon from 'lucide-solid/icons/boxes';
import MonitorIcon from 'lucide-solid/icons/monitor';
import BellIcon from 'lucide-solid/icons/bell';
import SettingsIcon from 'lucide-solid/icons/settings';
import { TokenRevealDialog } from './components/TokenRevealDialog';
import { useAlertsActivation } from './stores/alertsActivation';
const Dashboard = lazy(() =>
import('./components/Dashboard/Dashboard').then((module) => ({ default: module.Dashboard })),
);
const StorageComponent = lazy(() => import('./components/Storage/Storage'));
const Backups = lazy(() => import('./components/Backups/Backups'));
const Replication = lazy(() => import('./components/Replication/Replication'));
const MailGateway = lazy(() => import('./components/PMG/MailGateway'));
const AlertsPage = lazy(() =>
import('./pages/Alerts').then((module) => ({ default: module.Alerts })),
);
const SettingsPage = lazy(() => import('./components/Settings/Settings'));
const DockerHosts = lazy(() =>
import('./components/Docker/DockerHosts').then((module) => ({ default: module.DockerHosts })),
);
const HostsOverview = lazy(() =>
import('./components/Hosts/HostsOverview').then((module) => ({
default: module.HostsOverview,
})),
);
// Enhanced store type with proper typing
type EnhancedStore = ReturnType<typeof getGlobalWebSocketStore>;
// Export WebSocket context for other components
export const WebSocketContext = createContext<EnhancedStore>();
export const useWebSocket = () => {
const context = useContext(WebSocketContext);
if (!context) {
throw new Error('useWebSocket must be used within WebSocketContext.Provider');
}
return context;
};
// Dark mode context for reactive theme switching
export const DarkModeContext = createContext<() => boolean>(() => false);
export const useDarkMode = () => {
const context = useContext(DarkModeContext);
if (!context) {
throw new Error('useDarkMode must be used within DarkModeContext.Provider');
}
return context;
};
// Docker route component that properly uses activeAlerts from useWebSocket
function DockerRoute() {
const wsContext = useContext(WebSocketContext);
if (!wsContext) {
return <div>Loading...</div>;
}
const { state, activeAlerts } = wsContext;
const hosts = createMemo(() => state.dockerHosts ?? []);
return <DockerHosts hosts={hosts()} activeAlerts={activeAlerts} />;
}
function HostsRoute() {
const wsContext = useContext(WebSocketContext);
if (!wsContext) {
return <div>Loading...</div>;
}
const { state } = wsContext;
return (
<HostsOverview hosts={state.hosts ?? []} connectionHealth={state.connectionHealth ?? {}} />
);
}
function App() {
const TooltipRoot = createTooltipSystem();
const owner = getOwner();
const acquireWsStore = (): EnhancedStore => {
const store = owner
? runWithOwner(owner, () => getGlobalWebSocketStore())
: getGlobalWebSocketStore();
return store || getGlobalWebSocketStore();
};
const alertsActivation = useAlertsActivation();
let hasPreloadedRoutes = false;
let hasFetchedVersionInfo = false;
const preloadLazyRoutes = () => {
if (hasPreloadedRoutes || typeof window === 'undefined') {
return;
}
hasPreloadedRoutes = true;
const loaders: Array<() => Promise<unknown>> = [
() => import('./components/Storage/Storage'),
() => import('./components/Backups/Backups'),
() => import('./components/Replication/Replication'),
() => import('./components/PMG/MailGateway'),
() => import('./components/Hosts/HostsOverview'),
() => import('./pages/Alerts'),
() => import('./components/Settings/Settings'),
() => import('./components/Docker/DockerHosts'),
];
loaders.forEach((load) => {
void load().catch((error) => {
logger.warn('Preloading route module failed', error);
});
});
};
const fallbackState: State = {
nodes: [],
vms: [],
containers: [],
dockerHosts: [],
hosts: [],
storage: [],
cephClusters: [],
physicalDisks: [],
pbs: [],
pmg: [],
replicationJobs: [],
metrics: [],
pveBackups: {
backupTasks: [],
storageBackups: [],
guestSnapshots: [],
},
pbsBackups: [],
pmgBackups: [],
backups: {
pve: {
backupTasks: [],
storageBackups: [],
guestSnapshots: [],
},
pbs: [],
pmg: [],
},
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: '',
};
// Simple auth state
const [isLoading, setIsLoading] = createSignal(true);
const [needsAuth, setNeedsAuth] = createSignal(false);
const [hasAuth, setHasAuth] = createSignal(false);
const [proxyAuthInfo, setProxyAuthInfo] = createSignal<{
username?: string;
logoutURL?: string;
} | null>(null);
// Don't initialize WebSocket until after auth check
const [wsStore, setWsStore] = createSignal<EnhancedStore | null>(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;
// Last update time formatting
const [lastUpdateText, setLastUpdateText] = createSignal('');
const formatLastUpdate = (timestamp: string) => {
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 && updateTime !== '') {
setDataUpdated(true);
setLastUpdateText(formatLastUpdate(updateTime));
window.clearTimeout(updateTimeout);
updateTimeout = window.setTimeout(() => setDataUpdated(false), POLLING_INTERVALS.DATA_FLASH);
}
});
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() || hasFetchedVersionInfo) {
return;
}
hasFetchedVersionInfo = true;
UpdatesAPI.getVersion()
.then((version) => {
setVersionInfo(version);
// Check for updates after loading version info (non-blocking)
updateStore.checkForUpdates();
})
.catch((error) => {
logger.error('Failed to load version', error);
});
});
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<VersionInfo | null>(null);
// Dark mode - initialize immediately from localStorage to prevent flash
// This addresses issue #443 where dark mode wasn't persisting
// Priority: 1. localStorage (user's last choice on this device)
// 2. System preference
// 3. Server preference (loaded later for cross-device sync)
const savedDarkMode = localStorage.getItem(STORAGE_KEYS.DARK_MODE);
const hasLocalPreference = savedDarkMode !== null;
const initialDarkMode = hasLocalPreference
? savedDarkMode === 'true'
: window.matchMedia('(prefers-color-scheme: dark)').matches;
const [darkMode, setDarkMode] = createSignal(initialDarkMode);
const [, setHasLoadedServerTheme] = createSignal(false);
// Apply dark mode immediately on initialization
if (initialDarkMode) {
document.documentElement.classList.add('dark');
} else {
document.documentElement.classList.remove('dark');
}
// Toggle dark mode
const toggleDarkMode = async () => {
const newMode = !darkMode();
setDarkMode(newMode);
localStorage.setItem(STORAGE_KEYS.DARK_MODE, String(newMode));
if (newMode) {
document.documentElement.classList.add('dark');
} else {
document.documentElement.classList.remove('dark');
}
logger.info('Theme changed', { mode: newMode ? 'dark' : 'light' });
// Save theme preference to server if authenticated
if (!needsAuth()) {
try {
await SettingsAPI.updateSystemSettings({ theme: newMode ? 'dark' : 'light' });
logger.info('Theme preference saved to server');
} catch (error) {
logger.error('Failed to save theme preference to server', error);
// Don't show error to user - local change still works
}
}
};
// Don't initialize dark mode here - will be handled based on auth state
// Listen for theme changes from other browser instances
onMount(() => {
const handleThemeChange = (theme?: string) => {
if (!theme) return;
logger.info('Received theme change from another browser instance', { theme });
const isDark = theme === 'dark';
// Update local state
setDarkMode(isDark);
localStorage.setItem(STORAGE_KEYS.DARK_MODE, String(isDark));
// Update DOM
if (isDark) {
document.documentElement.classList.add('dark');
} else {
document.documentElement.classList.remove('dark');
}
};
// Subscribe to theme change events
eventBus.on('theme_changed', handleThemeChange);
// Cleanup on unmount
onCleanup(() => {
eventBus.off('theme_changed', handleThemeChange);
});
});
// 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');
if (justLoggedOut) {
localStorage.removeItem('just_logged_out');
logger.debug('[App] User logged out, showing login page');
setHasAuth(true); // Force showing login instead of setup
setNeedsAuth(true);
setIsLoading(false);
return;
}
// First check security status to see if auth is configured
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);
}
setHasAuth(false);
setNeedsAuth(true);
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);
// Detect legacy DISABLE_AUTH flag (now ignored) so we can surface a warning
if (securityData.deprecatedDisableAuth === true) {
logger.warn(
'[App] Legacy DISABLE_AUTH flag detected; authentication remains enabled. Remove the flag and restart Pulse to silence this warning.',
);
}
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);
// Initialize WebSocket for proxy auth users
setWsStore(acquireWsStore());
// Load theme preference from server for cross-device sync
// Only use server preference if no local preference exists
if (!hasLocalPreference) {
try {
const systemSettings = await SettingsAPI.getSystemSettings();
if (systemSettings.theme && systemSettings.theme !== '') {
const prefersDark = systemSettings.theme === 'dark';
setDarkMode(prefersDark);
localStorage.setItem(STORAGE_KEYS.DARK_MODE, String(prefersDark));
if (prefersDark) {
document.documentElement.classList.add('dark');
} else {
document.documentElement.classList.remove('dark');
}
}
setHasLoadedServerTheme(true);
} catch (error) {
logger.error('Failed to load theme from server', error);
}
}
// Load version info
UpdatesAPI.getVersion()
.then((version) => {
setVersionInfo(version);
// Check for updates after loading version info (non-blocking)
updateStore.checkForUpdates();
})
.catch((error) => logger.error('Failed to load version', error));
setIsLoading(false);
return;
}
// Check for OIDC session
if (securityData.oidcEnabled && securityData.oidcUsername) {
logger.info('[App] OIDC session detected', { user: securityData.oidcUsername });
setHasAuth(true); // OIDC is enabled, so auth is configured
setProxyAuthInfo({
username: securityData.oidcUsername,
logoutURL: securityData.oidcLogoutURL, // OIDC logout URL from IdP
});
setNeedsAuth(false);
// Initialize WebSocket for OIDC users
setWsStore(acquireWsStore());
// Load theme preference from server for cross-device sync
// Only use server preference if no local preference exists
if (!hasLocalPreference) {
try {
const systemSettings = await SettingsAPI.getSystemSettings();
if (systemSettings.theme && systemSettings.theme !== '') {
const prefersDark = systemSettings.theme === 'dark';
setDarkMode(prefersDark);
localStorage.setItem(STORAGE_KEYS.DARK_MODE, String(prefersDark));
if (prefersDark) {
document.documentElement.classList.add('dark');
} else {
document.documentElement.classList.remove('dark');
}
}
setHasLoadedServerTheme(true);
} catch (error) {
logger.error('Failed to load theme from server', error);
}
}
// Load version info
UpdatesAPI.getVersion()
.then((version) => {
setVersionInfo(version);
// Check for updates after loading version info (non-blocking)
updateStore.checkForUpdates();
})
.catch((error) => logger.error('Failed to load version', error));
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);
// Only initialize WebSocket after successful auth check
setWsStore(acquireWsStore());
// Load theme preference from server for cross-device sync
// Only use server preference if no local preference exists
if (!hasLocalPreference) {
try {
const systemSettings = await SettingsAPI.getSystemSettings();
if (systemSettings.theme && systemSettings.theme !== '') {
const prefersDark = systemSettings.theme === 'dark';
setDarkMode(prefersDark);
localStorage.setItem(STORAGE_KEYS.DARK_MODE, String(prefersDark));
if (prefersDark) {
document.documentElement.classList.add('dark');
} else {
document.documentElement.classList.remove('dark');
}
}
setHasLoadedServerTheme(true);
} catch (error) {
logger.error('Failed to load theme from server', error);
}
} else {
// We have a local preference, just mark that we've checked the server
setHasLoadedServerTheme(true);
}
}
} 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 all local storage EXCEPT theme preference and logout flag
const currentTheme = localStorage.getItem(STORAGE_KEYS.DARK_MODE);
localStorage.clear();
sessionStorage.clear();
localStorage.setItem('just_logged_out', 'true');
// Preserve theme preference across logout
if (currentTheme) {
localStorage.setItem(STORAGE_KEYS.DARK_MODE, currentTheme);
}
// 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();
const DashboardView = () => (
<Dashboard vms={state().vms} containers={state().containers} nodes={state().nodes} />
);
const SettingsRoute = () => (
<SettingsPage darkMode={darkMode} toggleDarkMode={toggleDarkMode} />
);
// Root layout component for Router
const RootLayout = (props: { children?: JSX.Element }) => {
return (
<Show
when={!isLoading()}
fallback={
<div class="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-900">
<div class="text-gray-600 dark:text-gray-400">Loading...</div>
</div>
}
>
<Show when={!needsAuth()} fallback={<Login onLogin={handleLogin} />}>
<ErrorBoundary>
<Show when={enhancedStore()} fallback={<div>Initializing...</div>}>
<WebSocketContext.Provider value={enhancedStore()!}>
<DarkModeContext.Provider value={darkMode}>
<SecurityWarning />
<DemoBanner />
<UpdateBanner />
<div class="min-h-screen bg-gray-100 dark:bg-gray-900 text-gray-800 dark:text-gray-200 font-sans py-4 sm:py-6">
<AppLayout
connected={connected}
reconnecting={reconnecting}
dataUpdated={dataUpdated}
lastUpdateText={lastUpdateText}
versionInfo={versionInfo}
hasAuth={hasAuth}
needsAuth={needsAuth}
proxyAuthInfo={proxyAuthInfo}
handleLogout={handleLogout}
state={state}
>
{props.children}
</AppLayout>
</div>
<ToastContainer />
<TokenRevealDialog />
<TooltipRoot />
</DarkModeContext.Provider>
</WebSocketContext.Provider>
</Show>
</ErrorBoundary>
</Show>
</Show>
);
};
// Use Router with routes
return (
<Router root={RootLayout}>
<Route path="/" component={() => <Navigate href="/proxmox/overview" />} />
<Route path="/proxmox" component={() => <Navigate href="/proxmox/overview" />} />
<Route path="/proxmox/overview" component={DashboardView} />
<Route path="/proxmox/storage" component={StorageComponent} />
<Route path="/proxmox/replication" component={Replication} />
<Route path="/proxmox/mail" component={MailGateway} />
<Route path="/proxmox/backups" component={Backups} />
<Route path="/storage" component={() => <Navigate href="/proxmox/storage" />} />
<Route path="/backups" component={() => <Navigate href="/proxmox/backups" />} />
<Route path="/docker" component={DockerRoute} />
<Route path="/hosts" component={HostsRoute} />
<Route path="/servers" component={() => <Navigate href="/hosts" />} />
<Route path="/alerts/*" component={AlertsPage} />
<Route path="/settings/*" component={SettingsRoute} />
</Router>
);
}
function ConnectionStatusBadge(props: {
connected: () => boolean;
reconnecting: () => boolean;
class?: string;
}) {
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()
? '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-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 min-w-6 h-6 group-hover:px-3'
} ${props.class ?? ''}`}
>
<Show when={props.reconnecting()}>
<svg class="animate-spin h-3 w-3 flex-shrink-0" fill="none" viewBox="0 0 24 24">
<circle
class="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
stroke-width="4"
></circle>
<path
class="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
</Show>
<Show when={props.connected()}>
<span class="h-2.5 w-2.5 rounded-full bg-green-600 dark:bg-green-400 flex-shrink-0"></span>
</Show>
<Show when={!props.connected() && !props.reconnecting()}>
<span class="h-2.5 w-2.5 rounded-full bg-gray-600 dark:bg-gray-400 flex-shrink-0"></span>
</Show>
<span
class={`whitespace-nowrap overflow-hidden transition-all duration-500 ${
props.connected() || (!props.connected() && !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'
: props.reconnecting()
? 'Reconnecting...'
: 'Disconnected'}
</span>
</div>
);
}
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;
children?: JSX.Element;
}) {
const navigate = useNavigate();
const location = useLocation();
const readSeenPlatforms = (): Record<string, boolean> => {
if (typeof window === 'undefined') return {};
try {
const stored = window.localStorage.getItem(STORAGE_KEYS.PLATFORMS_SEEN);
if (stored) {
const parsed = JSON.parse(stored) as Record<string, boolean>;
if (parsed && typeof parsed === 'object') {
return parsed;
}
}
} catch (error) {
logger.warn('Failed to parse stored platform visibility preferences', error);
}
return {};
};
const [seenPlatforms, setSeenPlatforms] = createSignal<Record<string, boolean>>(readSeenPlatforms());
const persistSeenPlatforms = (map: Record<string, boolean>) => {
if (typeof window === 'undefined') return;
try {
window.localStorage.setItem(STORAGE_KEYS.PLATFORMS_SEEN, JSON.stringify(map));
} catch (error) {
logger.warn('Failed to persist platform visibility preferences', error);
}
};
const markPlatformSeen = (platformId: string) => {
setSeenPlatforms((current) => {
if (current[platformId]) {
return current;
}
const updated = { ...current, [platformId]: true };
persistSeenPlatforms(updated);
return updated;
});
};
// Determine active tab from current path
const getActiveTab = () => {
const path = location.pathname;
if (path.startsWith('/proxmox')) return 'proxmox';
if (path.startsWith('/docker')) return 'docker';
if (path.startsWith('/hosts')) return 'hosts';
if (path.startsWith('/servers')) return 'hosts'; // Legacy redirect
if (path.startsWith('/alerts')) return 'alerts';
if (path.startsWith('/settings')) return 'settings';
return 'proxmox';
};
const hasDockerHosts = createMemo(() => (props.state().dockerHosts?.length ?? 0) > 0);
const hasHosts = createMemo(() => (props.state().hosts?.length ?? 0) > 0);
const hasProxmoxHosts = createMemo(
() =>
(props.state().nodes?.length ?? 0) > 0 ||
(props.state().vms?.length ?? 0) > 0 ||
(props.state().containers?.length ?? 0) > 0,
);
createEffect(() => {
if (hasDockerHosts()) {
markPlatformSeen('docker');
}
});
createEffect(() => {
if (hasProxmoxHosts()) {
markPlatformSeen('proxmox');
}
});
createEffect(() => {
if (hasHosts()) {
markPlatformSeen('hosts');
}
});
const platformTabs = createMemo(() => {
return [
{
id: 'proxmox' as const,
label: 'Proxmox',
route: '/proxmox/overview',
settingsRoute: '/settings',
tooltip: 'Monitor Proxmox clusters and nodes',
enabled: hasProxmoxHosts() || !!seenPlatforms()['proxmox'],
live: hasProxmoxHosts(),
icon: (
<ProxmoxIcon class="w-4 h-4 shrink-0" />
),
},
{
id: 'docker' as const,
label: 'Docker',
route: '/docker',
settingsRoute: '/settings/docker',
tooltip: 'Monitor Docker hosts and containers',
enabled: hasDockerHosts() || !!seenPlatforms()['docker'],
live: hasDockerHosts(),
icon: (
<BoxesIcon class="w-4 h-4 shrink-0" />
),
},
{
id: 'hosts' as const,
label: 'Hosts',
route: '/hosts',
settingsRoute: '/settings/host-agents',
tooltip: 'Monitor hosts with the host agent',
enabled: hasHosts() || !!seenPlatforms()['hosts'],
live: hasHosts(),
icon: (
<MonitorIcon class="w-4 h-4 shrink-0" />
),
},
];
});
const utilityTabs = createMemo(() => {
const allAlerts = props.state().activeAlerts || [];
const breakdown = allAlerts.reduce(
(acc, alert: any) => {
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;
return [
{
id: 'alerts' as const,
label: 'Alerts',
route: '/alerts',
tooltip: 'Review active alerts and automation rules',
badge: null as 'update' | null,
count: activeAlertCount,
breakdown,
icon: <BellIcon class="w-4 h-4 shrink-0" />,
},
{
id: 'settings' as const,
label: 'Settings',
route: '/settings',
tooltip: 'Configure Pulse preferences and integrations',
badge: updateStore.isUpdateVisible() ? ('update' as const) : null,
count: undefined,
breakdown: undefined,
icon: <SettingsIcon class="w-4 h-4 shrink-0" />,
},
];
});
const handlePlatformClick = (platform: ReturnType<typeof platformTabs>[number]) => {
if (platform.enabled) {
navigate(platform.route);
} else {
navigate(platform.settingsRoute);
}
};
const handleUtilityClick = (tab: ReturnType<typeof utilityTabs>[number]) => {
navigate(tab.route);
};
return (
<div class="pulse-shell">
{/* Header */}
<div class="header mb-3 flex items-center justify-between gap-2 sm:grid sm:grid-cols-[1fr_auto_1fr] sm:items-center sm:gap-0">
<div class="flex items-center gap-2 sm:flex-initial sm:gap-2 sm:col-start-2 sm:col-end-3 sm:justify-self-center">
<div class="flex items-center gap-2">
<svg
width="20"
height="20"
viewBox="0 0 256 256"
xmlns="http://www.w3.org/2000/svg"
class={`pulse-logo ${props.connected() && props.dataUpdated() ? 'animate-pulse-logo' : ''}`}
>
<title>Pulse Logo</title>
<circle
class="pulse-bg fill-blue-600 dark:fill-blue-500"
cx="128"
cy="128"
r="122"
/>
<circle
class="pulse-ring fill-none stroke-white stroke-[14] opacity-[0.92]"
cx="128"
cy="128"
r="84"
/>
<circle
class="pulse-center fill-white dark:fill-[#dbeafe]"
cx="128"
cy="128"
r="26"
/>
</svg>
<span class="text-lg font-medium text-gray-800 dark:text-gray-200">Pulse</span>
<Show when={props.versionInfo()?.channel === 'rc'}>
<span class="text-xs px-1.5 py-0.5 bg-orange-500 text-white rounded font-bold">
RC
</span>
</Show>
</div>
</div>
<div class="header-controls flex items-center gap-2 justify-end sm:col-start-3 sm:col-end-4 sm:w-auto sm:justify-end sm:justify-self-end">
<Show when={props.hasAuth() && !props.needsAuth()}>
<div class="flex items-center gap-2">
<Show when={props.proxyAuthInfo()?.username}>
<span class="text-xs px-2 py-1 text-gray-600 dark:text-gray-400">
{props.proxyAuthInfo()?.username}
</span>
</Show>
<button
type="button"
onClick={props.handleLogout}
class="group relative flex h-7 items-center justify-center gap-1 rounded-full bg-gray-200 px-2 text-xs text-gray-700 transition-all duration-500 ease-in-out hover:bg-gray-300 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-500 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600"
title="Logout"
aria-label="Logout"
>
<svg
class="h-3 w-3 flex-shrink-0"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2"
aria-hidden="true"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1"
/>
</svg>
<span
class="max-w-0 overflow-hidden whitespace-nowrap opacity-0 transition-all duration-500 ease-in-out group-hover:ml-1 group-hover:max-w-[80px] group-hover:opacity-100 group-focus-visible:ml-1 group-focus-visible:max-w-[80px] group-focus-visible:opacity-100"
>
Logout
</span>
</button>
</div>
</Show>
<ConnectionStatusBadge
connected={props.connected}
reconnecting={props.reconnecting}
class="flex-shrink-0"
/>
</div>
</div>
{/* Tabs */}
<div
class="tabs mb-2 flex items-end gap-2 overflow-x-auto overflow-y-hidden whitespace-nowrap border-b border-gray-300 dark:border-gray-700 scrollbar-hide"
role="tablist"
aria-label="Primary navigation"
>
<div class="flex items-end gap-1" role="group" aria-label="Infrastructure">
<For each={platformTabs()}>
{(platform) => {
const isActive = () => getActiveTab() === platform.id;
const disabled = () => !platform.enabled;
const baseClasses =
'tab relative px-2 sm:px-3 py-1.5 text-xs sm:text-sm font-medium flex items-center gap-1 sm:gap-1.5 rounded-t border border-transparent transition-colors whitespace-nowrap cursor-pointer';
const className = () => {
if (isActive()) {
return `${baseClasses} bg-white dark:bg-gray-800 text-blue-600 dark:text-blue-400 border-gray-300 dark:border-gray-700 border-b border-b-white dark:border-b-gray-800 shadow-sm font-semibold`;
}
if (disabled()) {
return `${baseClasses} cursor-not-allowed text-gray-400 dark:text-gray-600 opacity-70 bg-gray-100/40 dark:bg-gray-800/40`;
}
return `${baseClasses} text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 hover:bg-gray-200/60 dark:hover:bg-gray-700/60`;
};
const title = () =>
disabled()
? `${platform.label} is not configured yet. Click to open settings.`
: platform.tooltip;
return (
<div
class={className()}
role="tab"
aria-disabled={disabled()}
onClick={() => handlePlatformClick(platform)}
title={title()}
>
{platform.icon}
<span>{platform.label}</span>
</div>
);
}}
</For>
</div>
<div class="flex items-end gap-2 ml-auto" role="group" aria-label="System">
<div class="flex items-end gap-1 pl-3 sm:pl-4">
<For each={utilityTabs()}>
{(tab) => {
const isActive = () => getActiveTab() === tab.id;
const baseClasses =
'tab relative px-2 sm:px-3 py-1.5 text-xs sm:text-sm font-medium flex items-center gap-1 sm:gap-1.5 rounded-t border border-transparent transition-colors whitespace-nowrap cursor-pointer';
const className = () => {
if (isActive()) {
return `${baseClasses} bg-white dark:bg-gray-800 text-blue-600 dark:text-blue-400 border-gray-300 dark:border-gray-700 border-b border-b-white dark:border-b-gray-800 shadow-sm font-semibold`;
}
return `${baseClasses} text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 hover:bg-gray-200/60 dark:hover:bg-gray-700/60`;
};
return (
<div
class={className()}
role="tab"
aria-disabled={false}
onClick={() => handleUtilityClick(tab)}
title={tab.tooltip}
>
{tab.icon}
<span class="flex items-center gap-1.5">
<span>{tab.label}</span>
{tab.id === 'alerts' && (() => {
const total = tab.count ?? 0;
if (total <= 0) {
return null;
}
return (
<span class="inline-flex items-center gap-1">
{tab.breakdown?.critical > 0 && (
<span class="inline-flex items-center justify-center min-w-[18px] h-[18px] px-1 text-[10px] font-bold text-white bg-red-600 dark:bg-red-500 rounded-full">
{tab.breakdown.critical}
</span>
)}
{tab.breakdown?.warning > 0 && (
<span class="inline-flex items-center justify-center min-w-[18px] h-[18px] px-1 text-[10px] font-semibold text-amber-900 dark:text-amber-100 bg-amber-200 dark:bg-amber-500/80 rounded-full">
{tab.breakdown.warning}
</span>
)}
</span>
);
})()}
</span>
<Show when={tab.badge === 'update'}>
<span class="ml-1 flex items-center">
<span class="sr-only">Update available</span>
<span aria-hidden="true" class="block h-2 w-2 rounded-full bg-red-500 animate-pulse"></span>
</span>
</Show>
</div>
);
}}
</For>
</div>
</div>
</div>
{/* Main Content */}
<main
id="main"
class="tab-content block bg-white dark:bg-gray-800 rounded-b rounded-tr shadow mb-2"
>
<div class="pulse-panel">
<Suspense fallback={<div class="p-6 text-sm text-gray-500 dark:text-gray-400">Loading view...</div>}>
{props.children}
</Suspense>
</div>
</main>
{/* Footer */}
<footer class="text-center text-xs text-gray-500 dark:text-gray-400 py-4">
Pulse | Version:{' '}
<a
href="https://github.com/rcourtman/Pulse/releases"
target="_blank"
rel="noopener noreferrer"
class="text-blue-600 dark:text-blue-400 hover:underline"
>
{props.versionInfo()?.version || 'loading...'}
</a>
{props.versionInfo()?.isDevelopment && ' (Development)'}
{props.versionInfo()?.isDocker && ' - Docker'}
<Show when={props.lastUpdateText()}>
<span class="mx-2">|</span>
<span>Last refresh: {props.lastUpdateText()}</span>
</Show>
</footer>
</div>
);
}
export default App; // Test hot-reload comment $(date)