mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-04-28 11:30:15 +00:00
1161 lines
41 KiB
TypeScript
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)
|