From e07336dd9f38b48d79d9c229c70f7b9ff4cbf6ea Mon Sep 17 00:00:00 2001 From: rcourtman Date: Mon, 27 Oct 2025 19:46:51 +0000 Subject: [PATCH] refactor: remove legacy DISABLE_AUTH flag and enhance authentication UX Major authentication system improvements: - Remove deprecated DISABLE_AUTH environment variable support - Update all documentation to remove DISABLE_AUTH references - Add auth recovery instructions to docs (create .auth_recovery file) - Improve first-run setup and Quick Security wizard flows - Enhance login page with better error messaging and validation - Refactor Docker hosts view with new unified table and tree components - Add useDebouncedValue hook for better search performance - Improve Settings page with better security configuration UX - Update mock mode and development scripts for consistency - Add ScrollableTable persistence and improved responsive design Backend changes: - Remove DISABLE_AUTH flag detection and handling - Improve auth configuration validation and error messages - Enhance security status endpoint responses - Update router integration tests Frontend changes: - New Docker components: DockerUnifiedTable, DockerTree, DockerSummaryStats - Better connection status indicator positioning - Improved authentication state management - Enhanced CSRF and session handling - Better loading states and error recovery This completes the migration away from the insecure DISABLE_AUTH pattern toward proper authentication with recovery mechanisms. --- docs/CONFIGURATION.md | 5 +- docs/DOCKER.md | 3 +- docs/OIDC.md | 2 +- docs/REVERSE_PROXY.md | 15 +- frontend-modern/src/App.tsx | 335 +++-- .../Docker/DockerHostSummaryTable.tsx | 2 +- .../src/components/Docker/DockerHosts.tsx | 1117 ++------------- .../components/Docker/DockerSummaryStats.tsx | 350 +++++ .../src/components/Docker/DockerTree.tsx | 331 +++++ .../components/Docker/DockerUnifiedTable.tsx | 1240 +++++++++++++++++ .../src/components/FirstRunSetup.tsx | 19 +- frontend-modern/src/components/Login.tsx | 15 +- .../src/components/Settings/DockerAgents.tsx | 425 ++---- .../Settings/QuickSecuritySetup.tsx | 5 + .../src/components/Settings/Settings.tsx | 68 +- .../src/components/shared/Card.tsx | 2 +- .../src/components/shared/ScrollableTable.tsx | 36 +- .../src/hooks/useDebouncedValue.ts | 22 + .../src/stores/alertsActivation.ts | 4 - frontend-modern/src/types/api.ts | 8 +- frontend-modern/src/types/config.ts | 3 + internal/api/auth.go | 11 +- internal/api/diagnostics.go | 14 +- internal/api/router.go | 82 +- internal/api/router_integration_test.go | 32 - internal/api/security_setup_fix.go | 12 +- internal/config/config.go | 41 +- internal/mock/generator.go | 49 +- mock.env | 2 +- scripts/hot-dev.sh | 19 +- scripts/sync-production-config.sh | 38 +- scripts/toggle-mock.sh | 18 +- 32 files changed, 2610 insertions(+), 1715 deletions(-) create mode 100644 frontend-modern/src/components/Docker/DockerSummaryStats.tsx create mode 100644 frontend-modern/src/components/Docker/DockerTree.tsx create mode 100644 frontend-modern/src/components/Docker/DockerUnifiedTable.tsx create mode 100644 frontend-modern/src/hooks/useDebouncedValue.ts diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index f63ab83..87a25b1 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -42,7 +42,6 @@ API_TOKEN=abc123... # Optional: seed a primary API token (auto- API_TOKENS=token-one,token-two # Optional: comma-separated list of API tokens # Security settings -DISABLE_AUTH=true # Disable authentication entirely PULSE_AUDIT_LOG=true # Enable security audit logging # Proxy/SSO Authentication (see docs/PROXY_AUTH.md for full details) @@ -63,6 +62,7 @@ PROXY_AUTH_LOGOUT_URL=/logout # URL for SSO logout - Changes to this file are applied immediately without restart (v4.3.9+) - **DO NOT** put port configuration here - use system.json or systemd overrides - Copy `.env.example` from the repository for a ready-to-edit template +- Locked out? Create `/.auth_recovery`, restart Pulse, and sign in from localhost to reset credentials. Remove the file afterwards. --- @@ -397,7 +397,8 @@ These should be set in the .env file for security: - `PULSE_AUTH_USER`, `PULSE_AUTH_PASS` - Basic authentication - `API_TOKEN` - Primary API token (auto-hashed if you supply the raw value) - `API_TOKENS` - Comma-separated list of additional API tokens (plain or SHA3-256 hashed) -- `DISABLE_AUTH` - Set to `true` to disable authentication entirely + +> Locked out? Create `/.auth_recovery`, restart Pulse, and sign in from localhost. Delete the flag file and restart again to restore normal authentication. #### OIDC Variables (optional overrides) Set these environment variables to manage single sign-on without using the UI. When present, the OIDC form is locked read-only. diff --git a/docs/DOCKER.md b/docs/DOCKER.md index 943ccb1..2886c06 100644 --- a/docs/DOCKER.md +++ b/docs/DOCKER.md @@ -317,9 +317,10 @@ docker run -d --name pulse \ | `PULSE_AUTH_PASS` | Admin password (plain text auto-hashed or bcrypt hash) | `super-secret-password` or `$2a$12$...` | | `API_TOKEN` | Legacy single API token (optional fallback) | `openssl rand -hex 24` | | `API_TOKENS` | Comma-separated list of API tokens (plain or SHA3-256 hashed) | `ansible-token,docker-agent-token` | -| `DISABLE_AUTH` | Disable authentication entirely | `false` | | `PULSE_AUDIT_LOG` | Enable security audit logging | `false` | +> Locked out while testing a container? Create `/data/.auth_recovery`, restart the container, and connect from localhost to reset credentials. Remove the flag file and restart again to restore normal authentication. + ### Network | Variable | Description | Default | |----------|-------------|---------| diff --git a/docs/OIDC.md b/docs/OIDC.md index f789157..4dc8062 100644 --- a/docs/OIDC.md +++ b/docs/OIDC.md @@ -45,7 +45,7 @@ OIDC is optional. Pulse continues to ship with the familiar username/password fl - First-run setup still prompts you to create an admin credential or you can pre-seed it via `PULSE_AUTH_USER` / `PULSE_AUTH_PASS`. - If OIDC is **enabled**, the login page shows both the password form and the **Continue with Single Sign-On** button. Either path issues the same session cookie (`pulse_session`). -- To run **password-only**, leave OIDC disabled (the default). To go **OIDC-only**, set `DISABLE_AUTH=true` after you confirm SSO works. +- To run **password-only**, leave OIDC disabled (the default). For an **OIDC-first** experience, enable OIDC and rotate the local admin password to a randomly generated value; Pulse always keeps the password form available for emergency access. - The `allowedGroups`, `allowedDomains`, and `allowedEmails` settings only affect OIDC logins; password authentication continues to honour the account you created locally. ## Provider Cheat-Sheet diff --git a/docs/REVERSE_PROXY.md b/docs/REVERSE_PROXY.md index d56a7d4..ee8b28d 100644 --- a/docs/REVERSE_PROXY.md +++ b/docs/REVERSE_PROXY.md @@ -13,20 +13,9 @@ Pulse uses WebSockets for real-time updates. Your reverse proxy **MUST** support ## Authentication with Reverse Proxy -If you're using authentication at the reverse proxy level (Authentik, Authelia, etc.), you can disable Pulse's built-in authentication to avoid double login prompts: +Pulse always enforces its own authentication. If you want to delegate sign-in to your reverse proxy (Authentik, Authelia, etc.), configure Pulse's **Proxy Authentication** integration under *Settings → Security*. That lets Pulse trust the authenticated user provided by the proxy while keeping per-user roles, API tokens, and audit logging intact. -```bash -# In your .env file or environment -DISABLE_AUTH=true -``` - -When `DISABLE_AUTH=true` is set: -- Pulse's built-in authentication is completely bypassed -- All endpoints become accessible without authentication -- The reverse proxy handles all authentication and authorization -- A warning is logged on startup to confirm auth is disabled - -⚠️ **Warning**: Only use `DISABLE_AUTH=true` if your reverse proxy provides authentication. Never expose Pulse directly to the internet with authentication disabled. +> **Note:** The legacy `DISABLE_AUTH` environment variable has been removed. If it still exists in your deployment, Pulse will log a warning at startup and ignore it. Remove the variable and restart Pulse to silence the warning. ## Nginx diff --git a/frontend-modern/src/App.tsx b/frontend-modern/src/App.tsx index ab6fa1e..b560bdd 100644 --- a/frontend-modern/src/App.tsx +++ b/frontend-modern/src/App.tsx @@ -118,6 +118,31 @@ function App() { }; const alertsActivation = useAlertsActivation(); + let hasPreloadedRoutes = false; + let hasFetchedVersionInfo = false; + const preloadLazyRoutes = () => { + if (hasPreloadedRoutes || typeof window === 'undefined') { + return; + } + hasPreloadedRoutes = true; + const loaders: Array<() => Promise> = [ + () => 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) => { + console.warn('[App] Failed to preload route module', error); + }); + }); + }; + const fallbackState: State = { nodes: [], vms: [], @@ -214,9 +239,46 @@ function App() { } }); - onMount(() => { - void alertsActivation.refreshConfig(); - void alertsActivation.refreshActiveAlerts(); + 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) => { + console.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 @@ -316,49 +378,34 @@ function App() { // First check security status to see if auth is configured try { const securityRes = await apiFetch('/api/security/status'); + + if (securityRes.status === 401) { + console.warn( + '[App] Security status request returned 401. Clearing stored credentials and showing login.', + ); + try { + const { clearAuth } = await import('./utils/apiClient'); + clearAuth(); + } catch (clearError) { + console.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(); console.log('[App] Security status:', securityData); - // Check if auth is disabled via DISABLE_AUTH - if (securityData.disabled === true) { - console.log('[App] Auth is disabled via DISABLE_AUTH, skipping authentication'); - setHasAuth(false); - setNeedsAuth(false); - // Initialize WebSocket immediately since no auth needed - 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) { - console.error('Failed to load theme from server:', error); - } - } - - // Load version info even when auth is disabled - UpdatesAPI.getVersion() - .then((version) => { - setVersionInfo(version); - // Check for updates after loading version info (non-blocking) - updateStore.checkForUpdates(); - }) - .catch((error) => console.error('Failed to load version:', error)); - - setIsLoading(false); - return; + // Detect legacy DISABLE_AUTH flag (now ignored) so we can surface a warning + if (securityData.deprecatedDisableAuth === true) { + console.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; @@ -504,23 +551,17 @@ function App() { } } catch (error) { console.error('Auth check error:', error); - // On error, try to proceed without auth - setNeedsAuth(false); - setWsStore(acquireWsStore()); - - // Theme is already applied on initialization, no need to reapply + try { + const { clearAuth } = await import('./utils/apiClient'); + clearAuth(); + } catch (clearError) { + console.warn('[App] Failed to clear stored auth after auth check error:', clearError); + } + setHasAuth(false); + setNeedsAuth(true); } finally { setIsLoading(false); } - - // Load version info - UpdatesAPI.getVersion() - .then((version) => { - setVersionInfo(version); - // Check for updates after loading version info (non-blocking) - updateStore.checkForUpdates(); - }) - .catch((error) => console.error('Failed to load version:', error)); }); const handleLogin = () => { @@ -655,6 +696,61 @@ 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; @@ -844,85 +940,47 @@ function AppLayout(props: { return (
{/* Header */} -
-
- - Pulse - - - RC - - -
-
-
-
+
+
+ - - - - - - {props.connected() - ? 'Connected' - : props.reconnecting() - ? 'Reconnecting...' - : 'Disconnected'} + Pulse Logo + + + + + Pulse + + + RC -
- + +
+
+
+ +
{props.proxyAuthInfo()?.username} @@ -955,8 +1013,13 @@ function AppLayout(props: { Logout - -
+
+ +
diff --git a/frontend-modern/src/components/Docker/DockerHostSummaryTable.tsx b/frontend-modern/src/components/Docker/DockerHostSummaryTable.tsx index 37f5dac..ef35be7 100644 --- a/frontend-modern/src/components/Docker/DockerHostSummaryTable.tsx +++ b/frontend-modern/src/components/Docker/DockerHostSummaryTable.tsx @@ -115,7 +115,7 @@ export const DockerHostSummaryTable: Component = (p return ( - + diff --git a/frontend-modern/src/components/Docker/DockerHosts.tsx b/frontend-modern/src/components/Docker/DockerHosts.tsx index 2b5de33..239cbc3 100644 --- a/frontend-modern/src/components/Docker/DockerHosts.tsx +++ b/frontend-modern/src/components/Docker/DockerHosts.tsx @@ -1,393 +1,33 @@ import type { Component } from 'solid-js'; -import { For, Show, createMemo, createSignal, createEffect, on, onMount, onCleanup } from 'solid-js'; -import type { DockerHost, DockerContainer, Alert } from '@/types/api'; -import { formatBytes, formatRelativeTime, formatUptime } from '@/utils/format'; +import { Show, createMemo, createSignal, onMount, onCleanup } from 'solid-js'; +import type { DockerHost } from '@/types/api'; import { Card } from '@/components/shared/Card'; -import { ScrollableTable } from '@/components/shared/ScrollableTable'; import { EmptyState } from '@/components/shared/EmptyState'; -import { MetricBar } from '@/components/Dashboard/MetricBar'; import { DockerFilter } from './DockerFilter'; -import { getAlertStyles } from '@/utils/alerts'; -// import type { DockerHostSummary } from './DockerHostSummaryTable'; -import { renderDockerStatusBadge } from './DockerStatusBadge'; +import { DockerSummaryStatsBar } from './DockerSummaryStats'; +import { DockerUnifiedTable } from './DockerUnifiedTable'; import { useWebSocket } from '@/App'; -import { useAlertsActivation } from '@/stores/alertsActivation'; +import { useDebouncedValue } from '@/hooks/useDebouncedValue'; interface DockerHostsProps { hosts: DockerHost[]; - activeAlerts?: Record | any; // Can be Store or plain object + activeAlerts?: Record | any; } -interface ContainerEntry { - host: DockerHost; - container: DockerContainer; -} - -// Drawer state storage -const drawerState = new Map(); - -const buildContainerId = (container: DockerContainer, hostId: string) => { - return `${hostId}-${container.id}`; -}; - -// Unused - kept for potential future use -// const formatContainerStatus = (container: DockerContainer) => { -// const primary = container.state || container.status || 'unknown'; -// if (container.health) { -// return `${primary} · ${container.health}`; -// } -// return primary; -// }; - -// Unused - kept for potential future use -// const DockerGroupHeader: Component<{ -// host: DockerHost; -// colspan: number; -// onToggle: (hostId: string) => void; -// selected: boolean; -// }> = (props) => { -// const lastSeenRelative = () => -// props.host.lastSeen ? formatRelativeTime(props.host.lastSeen) : '—'; -// const lastSeenAbsolute = () => -// props.host.lastSeen ? formatAbsoluteTime(props.host.lastSeen) : '—'; -// -// return ( -// -// -// -// ); -// }; - -const DockerContainerRow: Component<{ - entry: ContainerEntry; - onHostSelect: (hostId: string) => void; - activeAlerts?: Record; -}> = (props) => { - const { container, host } = props.entry; - const alertsActivation = useAlertsActivation(); - const alertsEnabled = createMemo(() => alertsActivation.activationState() === 'active'); - const containerId = createMemo(() => buildContainerId(container, host.id)); - const [drawerOpen, setDrawerOpen] = createSignal(drawerState.get(containerId()) ?? false); - - // Get alert styles for this container - const containerResourceId = createMemo(() => `docker:${host.id}/${container.id}`); - const defaultAlertStyles = { - hasUnacknowledgedAlert: false, - hasAcknowledgedOnlyAlert: false, - severity: null as 'critical' | 'warning' | null, - hasAlert: false, - alertCount: 0, - unacknowledgedCount: 0, - acknowledgedCount: 0, - rowClass: '', - indicatorClass: '', - badgeClass: '', - }; - - const alertStyles = createMemo(() => { - if (!alertsEnabled()) return defaultAlertStyles; - if (!props.activeAlerts) return defaultAlertStyles; - try { - // Convert Store to plain object if needed - const alertsObj = typeof props.activeAlerts === 'object' ? { ...props.activeAlerts } : props.activeAlerts; - return getAlertStyles(containerResourceId(), alertsObj, alertsEnabled()) || defaultAlertStyles; - } catch (e) { - console.warn('Error getting alert styles for container:', containerResourceId(), e); - return defaultAlertStyles; - } - }); - - const cpuPercent = Math.max(0, Math.min(100, container.cpuPercent ?? 0)); - const memoryPercent = Math.max(0, Math.min(100, container.memoryPercent ?? 0)); - const memoryLabel = (() => { - if (!container.memoryUsageBytes && !container.memoryLimitBytes) return undefined; - if (!container.memoryLimitBytes) return `${formatBytes(container.memoryUsageBytes || 0)} used`; - return `${formatBytes(container.memoryUsageBytes || 0)}/${formatBytes(container.memoryLimitBytes)}`; - })(); - - const uptime = () => (container.uptimeSeconds ? formatUptime(container.uptimeSeconds) : '—'); - // const startedAt = () => (container.startedAt ? formatAbsoluteTime(container.startedAt) : null); - const isRunning = () => (container.state?.toLowerCase() === 'running'); - - // Check if we have additional info to show in drawer - const hasDrawerContent = createMemo(() => { - return ( - (container.ports && container.ports.length > 0) || - (container.networks && container.networks.length > 0) || - container.createdAt || - container.image || - (isRunning() && container.uptimeSeconds) - ); - }); - - // const handleHostClick = (event: MouseEvent) => { - // event.preventDefault(); - // props.onHostSelect(host.id); - // }; - - const toggleDrawer = (event: MouseEvent) => { - if (!hasDrawerContent()) return; - const target = event.target as HTMLElement; - if (target.closest('a, button, [data-prevent-toggle]')) { - return; - } - setDrawerOpen((prev) => !prev); - }; - - // Sync drawer state - createEffect(on(containerId, (id) => { - const stored = drawerState.get(id); - if (stored !== undefined) { - setDrawerOpen(stored); - } else { - setDrawerOpen(false); - } - })); - - createEffect(() => { - drawerState.set(containerId(), drawerOpen()); - }); - - // Match GuestRow styling with alert highlighting - const hasUnacknowledgedAlert = createMemo( - () => alertStyles()?.hasUnacknowledgedAlert ?? false, - ); - const hasAcknowledgedOnlyAlert = createMemo( - () => alertStyles()?.hasAcknowledgedOnlyAlert ?? false, - ); - const showAlertHighlight = createMemo( - () => hasUnacknowledgedAlert() || hasAcknowledgedOnlyAlert(), - ); - const alertAccentColor = createMemo(() => { - if (!showAlertHighlight()) return undefined; - if (hasUnacknowledgedAlert()) { - const severity = alertStyles()?.severity; - return severity === 'critical' ? '#ef4444' : '#eab308'; - } - return '#9ca3af'; - }); - - const rowClass = () => { - const base = 'transition-all duration-200 relative'; - const hover = 'hover:shadow-sm'; - const severity = alertStyles()?.severity; - const alertBg = hasUnacknowledgedAlert() - ? severity === 'critical' - ? 'bg-red-50 dark:bg-red-950/30' - : 'bg-yellow-50 dark:bg-yellow-950/20' - : ''; - const defaultHover = hasUnacknowledgedAlert() - ? '' - : 'hover:bg-gray-50 dark:hover:bg-gray-700/30'; - const stoppedDimming = !isRunning() ? 'opacity-60' : ''; - const clickable = hasDrawerContent() ? 'cursor-pointer' : ''; - const expanded = drawerOpen() && !hasUnacknowledgedAlert() - ? 'bg-gray-50 dark:bg-gray-800/40' - : ''; - return `${base} ${hover} ${defaultHover} ${alertBg} ${stoppedDimming} ${clickable} ${expanded}`; - }; - - const rowStyle = createMemo(() => { - if (!showAlertHighlight()) return {}; - const color = alertAccentColor(); - if (!color) return {}; - return { - 'box-shadow': `inset 4px 0 0 0 ${color}`, - }; - }); - - return ( - <> - - {/* Container Name */} - - - {/* Status */} - - - {/* CPU */} - - - {/* Memory */} - - - {/* Restarts */} - - - - {/* Drawer - Additional Info */} - - - - - - - ); -}; +type StatsFilter = { type: 'host-status' | 'container-state' | 'service-health'; value: string } | null; export const DockerHosts: Component = (props) => { const { initialDataReceived, reconnecting, connected } = useWebSocket(); + + const sortedHosts = createMemo(() => { + const hosts = props.hosts || []; + return [...hosts].sort((a, b) => { + const aName = a.displayName || a.hostname || a.id || ''; + const bName = b.displayName || b.hostname || b.id || ''; + return aName.localeCompare(bName); + }); + }); + const isLoading = createMemo(() => { if (typeof initialDataReceived === 'function') { const hostCount = Array.isArray(props.hosts) ? props.hosts.length : 0; @@ -395,232 +35,67 @@ export const DockerHosts: Component = (props) => { } return false; }); - const sortedHosts = createMemo(() => { - const hosts = props.hosts || []; - return [...hosts].sort((a, b) => a.displayName.localeCompare(b.displayName)); - }); - const [selectedHostId, setSelectedHostId] = createSignal(null); const [search, setSearch] = createSignal(''); + const debouncedSearch = useDebouncedValue(search, 250); + + const [statsFilter, setStatsFilter] = createSignal(null); - // Keyboard listener to auto-focus search let searchInputRef: HTMLInputElement | undefined; - const handleKeyDown = (e: KeyboardEvent) => { - // Don't interfere if user is already typing in an input - const target = e.target as HTMLElement; + const focusSearchInput = () => { + queueMicrotask(() => searchInputRef?.focus()); + }; + + const handleKeyDown = (event: KeyboardEvent) => { + const target = event.target as HTMLElement; + + if (event.key === 'Escape' && statsFilter()) { + event.preventDefault(); + setStatsFilter(null); + return; + } + if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable) { return; } - // Don't interfere with modifier key shortcuts (except Shift for capitals) - if (e.ctrlKey || e.metaKey || e.altKey) { + if (event.ctrlKey || event.metaKey || event.altKey) { return; } - // Focus search on printable characters and start typing - if (e.key.length === 1 && searchInputRef) { - e.preventDefault(); - searchInputRef.focus(); - setSearch(search() + e.key); + if (event.key.length === 1 && searchInputRef) { + event.preventDefault(); + focusSearchInput(); + setSearch((prev) => prev + event.key); } }; - onMount(() => { - document.addEventListener('keydown', handleKeyDown); - }); + onMount(() => document.addEventListener('keydown', handleKeyDown)); + onCleanup(() => document.removeEventListener('keydown', handleKeyDown)); - onCleanup(() => { - document.removeEventListener('keydown', handleKeyDown); - }); - - // Unused - kept for potential future use - // const hostSummaries = createMemo(() => { - // return sortedHosts().map((host) => { - // const containers = host.containers || []; - // const runningCount = containers.filter((ct) => ct.state?.toLowerCase() === 'running').length; - // const totalCount = containers.length; - // - // const cpuUsage = containers.reduce((acc, ct) => acc + (ct.cpuPercent || 0), 0); - // const cpuPercent = Number.isFinite(cpuUsage) - // ? Math.min(100, Math.max(0, Number(cpuUsage.toFixed(1)))) - // : 0; - // - // const memoryUsed = containers.reduce((acc, ct) => acc + (ct.memoryUsageBytes || 0), 0); - // const memoryPercent = host.totalMemoryBytes - // ? Math.min(100, Math.max(0, Number(((memoryUsed / host.totalMemoryBytes) * 100).toFixed(1)))) - // : 0; - // - // const runningPercent = totalCount > 0 - // ? Math.min(100, Math.max(0, Number(((runningCount / totalCount) * 100).toFixed(1)))) - // : 0; - // - // const memoryLabel = host.totalMemoryBytes - // ? `${formatBytes(memoryUsed)} / ${formatBytes(host.totalMemoryBytes)}` - // : undefined; - // - // return { - // host, - // cpuPercent, - // memoryPercent, - // memoryLabel, - // runningPercent, - // runningCount, - // totalCount, - // uptimeSeconds: host.uptimeSeconds, - // lastSeenRelative: host.lastSeen ? formatRelativeTime(host.lastSeen) : '—', - // lastSeenAbsolute: host.lastSeen ? formatAbsoluteTime(host.lastSeen) : '—', - // } satisfies DockerHostSummary; - // }); - // }); - - const searchTerms = createMemo(() => - search() - .toLowerCase() - .split(/[\s,]+/) - .map((term) => term.trim()) - .filter(Boolean), - ); - - const matchesTerm = (host: DockerHost, container: DockerContainer, term: string) => { - const [prefix, value] = term.includes(':') ? term.split(/:(.+)/) : [null, term]; - const target = value || term; - - const tokens = [ - container.name, - container.image, - container.id, - container.state, - container.status, - host.displayName, - host.hostname, - host.status, - ]; - - if (container.labels) { - Object.entries(container.labels).forEach(([key, val]) => { - tokens.push(`${key}:${val}`); - }); + const handleStatsFilterChange = (filter: StatsFilter) => { + if (!filter) { + setStatsFilter(null); + return; } - const hasToken = (list: (string | undefined)[]) => - list - .filter(Boolean) - .some((entry) => entry!.toLowerCase().includes(target)); - - if (prefix) { - switch (prefix) { - case 'host': - return hasToken([host.displayName, host.hostname]); - case 'name': - return hasToken([container.name]); - case 'image': - return hasToken([container.image]); - case 'state': - return hasToken([container.state, container.status]); - case 'id': - return hasToken([container.id]); - case 'label': - if (!container.labels) return false; - return Object.entries(container.labels).some(([key, val]) => - `${key}:${val}`.toLowerCase().includes(target), - ); - default: - return hasToken(tokens); + setStatsFilter((current) => { + if (current && current.type === filter.type && current.value === filter.value) { + return null; } - } - - return hasToken(tokens); - }; - - const matchesSearch = (host: DockerHost, container: DockerContainer) => { - const terms = searchTerms(); - if (terms.length === 0) return true; - return terms.every((term) => matchesTerm(host, container, term)); - }; - - const groupedContainers = createMemo(() => { - const selectedHost = selectedHostId(); - const groups = new Map(); - - sortedHosts().forEach((host) => { - if (selectedHost && host.id !== selectedHost) { - return; - } - - (host.containers || []).forEach((container) => { - if (!matchesSearch(host, container)) return; - - const entry = { host, container }; - const existing = groups.get(host.id); - if (existing) { - existing.push(entry); - } else { - groups.set(host.id, [entry]); - } - }); + return filter; }); - - return sortedHosts() - .filter((host) => !selectedHost || host.id === selectedHost) - .map((host) => ({ host, containers: groups.get(host.id) ?? [] })) - .filter((group) => group.containers.length > 0); - }); - - const hasContainers = createMemo(() => { - const groups = groupedContainers(); - return groups && groups.length > 0 && groups.some(g => g.containers.length > 0); - }); - - const toggleHostSelection = (hostId: string) => { - setSelectedHostId((current) => (current === hostId ? null : hostId)); }; - const activeHostName = createMemo(() => { - const id = selectedHostId(); - if (!id) return undefined; - const host = sortedHosts().find((item) => item.id === id); - return host?.displayName; - }); - - // Get containers for selected host - const selectedHostContainers = createMemo(() => { - const hostId = selectedHostId(); - if (!hostId) return []; - - const host = sortedHosts().find(h => h.id === hostId); - if (!host) return []; - - return (host.containers || []) - .filter(container => matchesSearch(host, container)) - .map(container => ({ host, container })); - }); - - const selectedHost = createMemo(() => { - const hostId = selectedHostId(); - return hostId ? sortedHosts().find(h => h.id === hostId) : null; - }); - return (
- + + = (props) => { when={sortedHosts().length === 0} fallback={ <> - {/* Filters */} setSelectedHostId(null)} - onReset={() => setSelectedHostId(null)} - searchInputRef={(el) => (searchInputRef = el)} + onReset={() => { + setSearch(''); + setStatsFilter(null); + }} + searchInputRef={(el) => { + searchInputRef = el; + }} /> - {/* Master-Detail Layout */} -
- {/* Left: Host List - Only show if more than 1 host */} - 1}> - -
-

Docker Hosts

-

{sortedHosts().length} {sortedHosts().length === 1 ? 'host' : 'hosts'}

-
-
- - {(host) => { - const isSelected = () => selectedHostId() === host.id; - const containerCount = (host.containers || []).length; - const runningCount = (host.containers || []).filter(c => c.state?.toLowerCase() === 'running').length; - const tokenRevokedAt = host.tokenRevokedAt; - const tokenRevoked = typeof tokenRevokedAt === 'number'; - const tokenRevokedRelative = tokenRevokedAt ? formatRelativeTime(tokenRevokedAt) : ''; - const tokenRevokedTitle = tokenRevokedAt ? new Date(tokenRevokedAt).toLocaleString() : ''; + + + - // Check for alerts on this host's containers - const hostAlerts = createMemo(() => { - if (!props.activeAlerts) return { hasAlerts: false, criticalCount: 0, warningCount: 0 }; - - const containers = host.containers || []; - let criticalCount = 0; - let warningCount = 0; - - containers.forEach(container => { - const resourceId = `docker:${host.id}/${container.id}`; - try { - const alertsObj = typeof props.activeAlerts === 'object' ? { ...props.activeAlerts } : props.activeAlerts; - const alerts = Object.values(alertsObj).filter((alert: any) => alert?.resourceId === resourceId); - alerts.forEach((alert: any) => { - if (alert.level === 'critical') criticalCount++; - else if (alert.level === 'warning') warningCount++; - }); - } catch (_e) { - // Ignore errors - } - }); - - return { hasAlerts: criticalCount > 0 || warningCount > 0, criticalCount, warningCount }; - }); - - const buttonClass = () => { - const alerts = hostAlerts(); - let base = 'w-full text-left px-4 py-2.5 transition-all duration-200 relative'; - - if (isSelected()) { - base += ' bg-blue-100 dark:bg-blue-900/40'; - } else if (alerts.criticalCount > 0) { - base += ' bg-red-50 dark:bg-red-950/30 hover:bg-red-100 dark:hover:bg-red-950/40'; - } else if (alerts.warningCount > 0) { - base += ' bg-yellow-50 dark:bg-yellow-950/20 hover:bg-yellow-100 dark:hover:bg-yellow-950/30'; - } else { - base += ' hover:bg-blue-50 dark:hover:bg-blue-900/20'; - } - - if (tokenRevoked) { - base += ' opacity-60'; - } - - return base; - }; - - const buttonStyle = () => { - const alerts = hostAlerts(); - if (!alerts.hasAlerts) return {}; - - const color = alerts.criticalCount > 0 ? '#ef4444' : '#eab308'; - return { - 'box-shadow': `inset 4px 0 0 0 ${color}`, - }; - }; - - return ( - - ); - }} - -
-
-
- - {/* Right: Container List */} -
- - - - } - > - - -
-//
-//
-// -// -// ({props.host.hostname}) -// -// {renderDockerStatusBadge(props.host.status)} -//
-//
-// -// Last update {lastSeenRelative()} ({lastSeenAbsolute()}) -// -// -// -// v{props.host.agentVersion} -// -// -// -// {props.host.intervalSeconds}s interval -// -//
-//
-//
-
- - {container.name || container.id.slice(0, 12)} - -
-
-
- {renderDockerStatusBadge(container.state || container.status)} - - ({container.health}) - -
-
- -}> - - - - -}> - - - - - {container.restartCount ?? 0} - - (Exit {container.exitCode}) - - -
-
- {/* Network & IPs */} - 0}> -
-
Network
-
- - {(network) => ( -
- {network.name} - - - - - {network.ipv4} - - - - - {network.ipv6} - - - -
- )} -
-
-
-
- - {/* Ports */} - 0}> -
-
Ports
-
- - {(port) => ( - {port.privatePort}/{port.protocol} - }> - - {port.publicPort}→{port.privatePort} - - - )} - -
-
-
- - {/* Container Info */} -
-
Info
-
- -
- Image: - {container.image} -
-
- -
- Uptime: - {uptime()} -
-
- -
- Created: - {formatRelativeTime(container.createdAt!)} -
-
- -
- Started: - {formatRelativeTime(container.startedAt!)} -
-
-
-
-
-
- - - - - - - - - - - - - - - - - - - {(group) => ( - <> - {/* Host Section Header */} - - - - {/* Host Containers */} - - {(entry) => } - - - )} - - -
- Container - - Status - - CPU - - Memory - - Restarts -
-
-
-

{group.host.displayName}

- - ({group.host.hostname}) - - {renderDockerStatusBadge(group.host.status)} - - - - Token revoked - - - - {group.containers.length} {group.containers.length === 1 ? 'container' : 'containers'} - -
-
- - Updated {formatRelativeTime(group.host.lastSeen!)} - - - Agent {group.host.agentVersion} - - - - - Revoked {formatRelativeTime(group.host.tokenRevokedAt!)} - - -
-
-
-
-
- - } - > - {(host) => ( - 0} - fallback={ - - - - } - > - - {/* Host Info Header */} -
-
-
-

{host().displayName}

- - ({host().hostname}) - - {renderDockerStatusBadge(host().status)} - - - - Token revoked - - - - {selectedHostContainers().length} {selectedHostContainers().length === 1 ? 'container' : 'containers'} - -
-
- - - Agent {host().agentVersion} - - - - Updated {formatRelativeTime(host().lastSeen!)} - - - - - Revoked {formatRelativeTime(host().tokenRevokedAt!)} - - -
-
- - -
- -
-
Agent token was revoked {formatRelativeTime(host().tokenRevokedAt!)}
-
No new telemetry will arrive until the agent is reconfigured.
-
-
-
- - {/* Host Metrics */} - -
- {/* CPU */} -
-
CPU Usage
- { - const total = (host().containers || []) - .filter(c => c.state?.toLowerCase() === 'running') - .reduce((sum, c) => sum + (c.cpuPercent || 0), 0); - return Math.min(100, Math.max(0, total)); - })()} - label={`${(() => { - const total = (host().containers || []) - .filter(c => c.state?.toLowerCase() === 'running') - .reduce((sum, c) => sum + (c.cpuPercent || 0), 0); - return Math.min(100, Math.max(0, total)).toFixed(0); - })()}%`} - type="cpu" - /> -
- - {/* Memory */} -
-
Memory Usage
- { - if (!host().totalMemoryBytes) return 0; - const usedBytes = (host().containers || []) - .reduce((sum, c) => sum + (c.memoryUsageBytes || 0), 0); - return Math.min(100, Math.max(0, (usedBytes / host().totalMemoryBytes) * 100)); - })()} - label={`${(() => { - if (!host().totalMemoryBytes) return '0'; - const usedBytes = (host().containers || []) - .reduce((sum, c) => sum + (c.memoryUsageBytes || 0), 0); - return Math.min(100, Math.max(0, (usedBytes / host().totalMemoryBytes) * 100)).toFixed(0); - })()}%`} - sublabel={(() => { - if (!host().totalMemoryBytes) return undefined; - const usedBytes = (host().containers || []) - .reduce((sum, c) => sum + (c.memoryUsageBytes || 0), 0); - return `${formatBytes(usedBytes)}/${formatBytes(host().totalMemoryBytes)}`; - })()} - type="memory" - /> -
- - {/* Uptime */} -
-
Host Uptime
-
- {host().uptimeSeconds ? formatUptime(host().uptimeSeconds) : '—'} -
-
-
-
-
- - {/* Containers Table */} - - - - - - - - - - - - - - - - - - - - - {(entry) => ( - - )} - - -
- Container - - Status - - CPU - - Memory - - Restarts -
-
-
-
- )} - -
-
+ } > - - - - - } - title="No Docker hosts configured" - description={ - - Deploy the Pulse Docker agent on at least one Docker host to light up this tab. As soon as an agent reports in, container metrics appear automatically. - - } - /> - + + + + + } + title="No Docker hosts configured" + description="Deploy the Pulse Docker agent on at least one Docker host to light up this tab. As soon as an agent reports in, container metrics appear automatically." + /> + diff --git a/frontend-modern/src/components/Docker/DockerSummaryStats.tsx b/frontend-modern/src/components/Docker/DockerSummaryStats.tsx new file mode 100644 index 0000000..1626ea6 --- /dev/null +++ b/frontend-modern/src/components/Docker/DockerSummaryStats.tsx @@ -0,0 +1,350 @@ +import type { Component } from 'solid-js'; +import { Show, createMemo } from 'solid-js'; +import type { DockerHost } from '@/types/api'; + +interface StatCardProps { + label: string; + value: number | string; + sublabel?: string; + variant?: 'default' | 'success' | 'warning' | 'error' | 'info'; + onClick?: () => void; + isActive?: boolean; +} + +const StatCard: Component = (props) => { + const baseClass = 'flex flex-col gap-1 px-4 py-3 rounded-lg border transition-all duration-200'; + + const variantClass = () => { + if (props.isActive) { + return 'bg-blue-100 dark:bg-blue-900/40 border-blue-300 dark:border-blue-700 ring-2 ring-blue-500/20'; + } + + switch (props.variant) { + case 'success': + return 'bg-green-50 dark:bg-green-950/20 border-green-200 dark:border-green-800'; + case 'warning': + return 'bg-yellow-50 dark:bg-yellow-950/20 border-yellow-200 dark:border-yellow-800'; + case 'error': + return 'bg-red-50 dark:bg-red-950/20 border-red-200 dark:border-red-800'; + case 'info': + return 'bg-blue-50 dark:bg-blue-950/20 border-blue-200 dark:border-blue-800'; + default: + return 'bg-white dark:bg-gray-800 border-gray-200 dark:border-gray-700'; + } + }; + + const hoverClass = () => props.onClick ? 'cursor-pointer hover:shadow-md hover:scale-[1.02]' : ''; + + return ( + + ); +}; + +export interface DockerSummaryStats { + hosts: { + total: number; + online: number; + degraded: number; + offline: number; + }; + containers: { + total: number; + running: number; + stopped: number; + error: number; + }; + services?: { + total: number; + healthy: number; + degraded: number; + }; + resources?: { + avgCpu: number | null; + avgMemory: number | null; + }; +} + +type SummaryFilter = { type: 'host-status' | 'container-state' | 'service-health'; value: string } | null; + +interface DockerSummaryStatsProps { + hosts: DockerHost[]; + onFilterChange?: (filter: SummaryFilter) => void; + activeFilter?: { type: string; value: string } | null; +} + +export const DockerSummaryStatsBar: Component = (props) => { + const stats = (): DockerSummaryStats => { + const hosts = props.hosts || []; + + // Host stats + let onlineHosts = 0; + let degradedHosts = 0; + let offlineHosts = 0; + + hosts.forEach((host) => { + const status = host.status?.toLowerCase() ?? 'unknown'; + switch (status) { + case 'online': + onlineHosts++; + break; + case 'degraded': + case 'warning': + case 'maintenance': + degradedHosts++; + break; + case 'offline': + case 'error': + case 'unreachable': + offlineHosts++; + break; + default: + degradedHosts++; + break; + } + }); + + // Container stats + let totalContainers = 0; + let runningContainers = 0; + let stoppedContainers = 0; + let errorContainers = 0; + + // Service stats + let totalServices = 0; + let healthyServices = 0; + let degradedServices = 0; + + // Resource stats + let totalCpu = 0; + let totalMemory = 0; + let cpuSamples = 0; + let memorySamples = 0; + + hosts.forEach(host => { + // Count containers + const containers = host.containers || []; + totalContainers += containers.length; + + containers.forEach(container => { + const state = container.state?.toLowerCase(); + if (state === 'running') { + runningContainers++; + // Sum CPU/Memory for running containers + if (typeof container.cpuPercent === 'number' && !Number.isNaN(container.cpuPercent)) { + totalCpu += container.cpuPercent; + cpuSamples++; + } + if (typeof container.memoryPercent === 'number' && !Number.isNaN(container.memoryPercent)) { + totalMemory += container.memoryPercent; + memorySamples++; + } + } else if (state === 'exited' || state === 'stopped' || state === 'created' || state === 'paused') { + stoppedContainers++; + } else if (state === 'restarting' || state === 'dead' || state === 'removing' || state === 'error' || state === 'failed') { + errorContainers++; + } + }); + + // Count services + const services = host.services || []; + totalServices += services.length; + + services.forEach(service => { + const desired = service.desiredTasks ?? 0; + const running = service.runningTasks ?? 0; + if (desired > 0 && running >= desired) { + healthyServices++; + } else if (desired > 0) { + degradedServices++; + } + }); + }); + + return { + hosts: { + total: hosts.length, + online: onlineHosts, + degraded: degradedHosts, + offline: offlineHosts, + }, + containers: { + total: totalContainers, + running: runningContainers, + stopped: stoppedContainers, + error: errorContainers, + }, + services: totalServices > 0 ? { + total: totalServices, + healthy: healthyServices, + degraded: degradedServices, + } : undefined, + resources: (cpuSamples > 0 || memorySamples > 0) ? { + avgCpu: cpuSamples > 0 ? totalCpu / cpuSamples : null, + avgMemory: memorySamples > 0 ? totalMemory / memorySamples : null, + } : undefined, + }; + }; + + const summary = createMemo(stats); + + const hostSublabel = () => { + const hosts = summary().hosts; + const parts = [`${hosts.online} online`]; + if (hosts.degraded > 0) { + parts.push(`${hosts.degraded} degraded`); + } + if (hosts.offline > 0) { + parts.push(`${hosts.offline} offline`); + } + return parts.join(', '); + }; + + const servicesSublabel = () => { + const services = summary().services; + if (!services) return ''; + const parts = [`${services.healthy} healthy`]; + if (services.degraded > 0) { + parts.push(`${services.degraded} degraded`); + } + return parts.join(', '); + }; + + const resourceValue = () => { + const avgCpu = summary().resources?.avgCpu ?? null; + return avgCpu !== null ? Math.round(avgCpu) : '—'; + }; + + const resourceSublabel = () => { + const avgMemory = summary().resources?.avgMemory ?? null; + if (avgMemory === null) return undefined; + return `${Math.round(avgMemory)}% mem`; + }; + + const resourceVariant = (): StatCardProps['variant'] => { + const avgCpu = summary().resources?.avgCpu ?? null; + if (avgCpu === null) return 'info'; + if (avgCpu > 80) return 'error'; + if (avgCpu > 60) return 'warning'; + return 'info'; + }; + + const isActive = (type: string, value: string) => { + return props.activeFilter?.type === type && props.activeFilter?.value === value; + }; + + return ( +
+
+

+ Overview +

+ + + +
+ +
+ {/* Hosts */} + + + 0}> + props.onFilterChange?.({ type: 'host-status', value: 'degraded' })} + isActive={isActive('host-status', 'degraded')} + /> + + + 0}> + props.onFilterChange?.({ type: 'host-status', value: 'offline' })} + isActive={isActive('host-status', 'offline')} + /> + + + {/* Containers */} + props.onFilterChange?.({ type: 'container-state', value: 'running' })} + isActive={isActive('container-state', 'running')} + /> + + 0}> + props.onFilterChange?.({ type: 'container-state', value: 'stopped' })} + isActive={isActive('container-state', 'stopped')} + /> + + + 0}> + props.onFilterChange?.({ type: 'container-state', value: 'error' })} + isActive={isActive('container-state', 'error')} + /> + + + {/* Services */} + + 0 ? 'warning' : 'success'} + /> + + + {/* Resources */} + + + +
+
+ ); +}; diff --git a/frontend-modern/src/components/Docker/DockerTree.tsx b/frontend-modern/src/components/Docker/DockerTree.tsx new file mode 100644 index 0000000..c20bd35 --- /dev/null +++ b/frontend-modern/src/components/Docker/DockerTree.tsx @@ -0,0 +1,331 @@ +import { Component, For, Show } from 'solid-js'; +import type { + DockerContainer, + DockerHost, + DockerService, + DockerTask, +} from '@/types/api'; +import { Card } from '@/components/shared/Card'; + +export type DockerTreeSelection = + | { type: 'host'; hostId: string; id: string } + | { type: 'service'; hostId: string; id: string } + | { type: 'task'; hostId: string; serviceKey: string; id: string } + | { type: 'container'; hostId: string; id: string }; + +export interface DockerTreeTaskEntry { + nodeId: string; + task: DockerTask; +} + +export interface DockerTreeServiceEntry { + key: string; + service: DockerService; + tasks: DockerTreeTaskEntry[]; +} + +export interface DockerTreeContainerEntry { + nodeId: string; + container: DockerContainer; +} + +export interface DockerTreeHostEntry { + host: DockerHost; + hostId: string; + containers: DockerContainer[]; + services: DockerTreeServiceEntry[]; + standaloneContainers: DockerTreeContainerEntry[]; +} + +interface ExpandState { + isExpanded: () => boolean; + toggle: () => void; + setExpanded: (value: boolean) => void; +} + +interface DockerTreeProps { + hosts: DockerTreeHostEntry[]; + selected?: DockerTreeSelection | null; + onSelect?: (selection: DockerTreeSelection) => void; + getHostState: (hostId: string) => ExpandState; + getServiceState: (serviceKey: string) => ExpandState; +} + +export const DockerTree: Component = (props) => { + const isNodeSelected = (node: DockerTreeSelection) => { + const current = props.selected; + if (!current) return false; + return ( + current.type === node.type && + current.hostId === node.hostId && + current.id === node.id + ); + }; + + const hostDisplayName = (host: DockerHost) => + host.displayName || host.hostname || host.id || 'Unknown host'; + + const hostStatusVariant = (host: DockerHost) => { + const status = host.status?.toLowerCase() ?? 'unknown'; + if (status === 'online') { + return 'bg-green-500'; + } + if ( + status === 'offline' || + status === 'error' || + status === 'down' || + status === 'unreachable' + ) { + return 'bg-red-500'; + } + return 'bg-yellow-500'; + }; + + const taskStatusVariant = (task: DockerTask) => { + const state = task.currentState?.toLowerCase() ?? ''; + if (state === 'running') return 'bg-green-500'; + if (state === 'failed' || state === 'error') return 'bg-red-500'; + return 'bg-yellow-500'; + }; + + const describeTaskLabel = (task: DockerTask) => { + if (task.slot !== undefined && task.slot !== null) { + return `${task.containerName || task.containerId || 'Task'}:${task.slot}`; + } + return task.containerName || task.containerId || task.id?.slice(0, 12) || 'Task'; + }; + + return ( + + + + ); +}; diff --git a/frontend-modern/src/components/Docker/DockerUnifiedTable.tsx b/frontend-modern/src/components/Docker/DockerUnifiedTable.tsx new file mode 100644 index 0000000..248b98d --- /dev/null +++ b/frontend-modern/src/components/Docker/DockerUnifiedTable.tsx @@ -0,0 +1,1240 @@ +import { Component, For, Show, batch, createSignal, createMemo, createEffect, onCleanup } from 'solid-js'; +import type { DockerHost, DockerContainer, DockerService, DockerTask } from '@/types/api'; +import { Card } from '@/components/shared/Card'; +import { ScrollableTable } from '@/components/shared/ScrollableTable'; +import { MetricBar } from '@/components/Dashboard/MetricBar'; +import { + DockerTree, + type DockerTreeHostEntry, + type DockerTreeSelection, + type DockerTreeServiceEntry, +} from './DockerTree'; + +interface DockerUnifiedTableProps { + hosts: DockerHost[]; + searchTerm?: string; + statsFilter?: { type: 'host-status' | 'container-state' | 'service-health'; value: string } | null; +} + +const findContainerForTask = (containers: DockerContainer[], task: DockerTask) => { + if (!containers.length) return undefined; + + const taskId = task.containerId?.toLowerCase() ?? ''; + const taskName = task.containerName?.toLowerCase() ?? ''; + const taskNameBase = taskName.split('.')[0] || taskName; + + return containers.find((container) => { + const id = container.id?.toLowerCase() ?? ''; + const name = container.name?.toLowerCase() ?? ''; + + const idMatch = + !!taskId && + (id === taskId || id.includes(taskId) || taskId.includes(id)); + + const nameMatch = + !!taskName && + (name === taskName || + name.includes(taskName) || + taskName.includes(name) || + (!!taskNameBase && (name === taskNameBase || name.includes(taskNameBase)))); + + return idMatch || nameMatch; + }); +}; + +const OFFLINE_HOST_STATUSES = new Set(['offline', 'error', 'unreachable', 'down', 'disconnected']); +const DEGRADED_HOST_STATUSES = new Set(['degraded', 'warning', 'maintenance', 'partial', 'initializing', 'unknown']); + +const STOPPED_CONTAINER_STATES = new Set(['exited', 'stopped', 'created', 'paused']); +const ERROR_CONTAINER_STATES = new Set(['restarting', 'dead', 'removing', 'failed', 'error', 'oomkilled', 'unhealthy']); + +// Persistent state for expanded hosts and services +const hostExpandState = new Map(); +const hostExpandSignals = new Map>>(); +const serviceExpandState = new Map(); +const serviceExpandSignals = new Map>>(); + +const getTaskNodeId = (serviceKey: string, task: DockerTask, index: number) => { + if (task.id) { + return `${serviceKey}:task:${task.id}`; + } + if (task.containerId) { + return `${serviceKey}:task:${task.containerId}`; + } + if (task.containerName) { + return `${serviceKey}:task:${task.containerName}`; + } + if (task.slot !== undefined && task.slot !== null) { + return `${serviceKey}:task:slot-${task.slot}`; + } + return `${serviceKey}:task:${index}`; +}; + +// Docker Host Group Header Component (matches NodeGroupHeader style) +interface DockerHostHeaderProps { + host: DockerHost; + colspan: number; + isExpanded: boolean; + onToggle: () => void; + isActive?: boolean; +} + +const DockerHostHeader: Component = (props) => { + const status = () => props.host.status?.toLowerCase() ?? 'unknown'; + const isOnline = () => status() === 'online'; + const isOffline = () => OFFLINE_HOST_STATUSES.has(status()); + const displayName = () => props.host.displayName || props.host.hostname || props.host.id; + + const totalContainers = () => (props.host.containers?.length || 0); + const runningContainers = () => + (props.host.containers?.filter((c) => c.state?.toLowerCase() === 'running').length || 0); + const totalServices = () => (props.host.services?.length || 0); + + return ( + + + + + + ); +}; + +// Service Row Component (expandable for task containers) +interface ServiceRowProps { + service: DockerService; + hostId: string; + tasks: DockerTreeServiceEntry['tasks']; + containers: DockerContainer[]; + isExpanded: boolean; + onToggle: () => void; + isSelected?: boolean; + rowRef?: (row: HTMLTableRowElement | null) => void; + selectedTaskId?: string | null; + onTaskMount?: (taskNodeId: string, row: HTMLTableRowElement) => void; + onTaskUnmount?: (taskNodeId: string) => void; +} + +const ServiceRow: Component = (props) => { + const desiredTasks = () => props.service.desiredTasks ?? 0; + const runningTasks = () => props.service.runningTasks ?? 0; + const isHealthy = () => desiredTasks() > 0 && runningTasks() >= desiredTasks(); + const hasTasks = () => props.tasks.length > 0; + + onCleanup(() => { + props.rowRef?.(null); + }); + + const healthBadge = () => { + if (desiredTasks() === 0) { + return ( + + No tasks + + ); + } + if (isHealthy()) { + return ( + + Healthy + + ); + } + return ( + + Degraded ({runningTasks()}/{desiredTasks()}) + + ); + }; + + return ( + <> + row && props.rowRef?.(row)} + class={`border-t border-gray-100 dark:border-gray-800 hover:bg-gray-50 dark:hover:bg-gray-800/50 ${ + props.isSelected ? 'bg-sky-50 dark:bg-sky-900/30' : '' + }`} + > + + + + +
+ {props.service.image || 'Image not specified'} +
+ +
+ {props.service.mode} +
+
+ + {healthBadge()} + + + {/* Task Containers Drawer */} + + + +
+

+ Task Containers ({props.tasks.length}) +

+
+ + + + + + + + + + + + + + {(taskEntry) => { + const task = taskEntry.task; + const currentState = task.currentState?.toLowerCase() || 'unknown'; + const stateBadge = () => { + if (currentState === 'running') { + return 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300'; + } + if (currentState === 'failed') { + return 'bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-300'; + } + return 'bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300'; + }; + + // Find the corresponding container for this task + const container = findContainerForTask(props.containers, task); + + const hasCpuData = () => + typeof container?.cpuPercent === 'number' && !Number.isNaN(container.cpuPercent); + const hasMemData = () => + typeof container?.memoryPercent === 'number' && !Number.isNaN(container.memoryPercent); + const cpuPercent = () => (hasCpuData() ? container!.cpuPercent : 0); + const memPercent = () => (hasMemData() ? container!.memoryPercent : 0); + + const taskLabel = () => { + const name = task.containerName || task.containerId?.slice(0, 12) || '—'; + if (task.slot !== undefined && task.slot !== null) { + return `${name}.${task.slot}`; + } + return name; + }; + + onCleanup(() => { + props.onTaskUnmount?.(taskEntry.nodeId); + }); + + return ( + row && props.onTaskMount?.(taskEntry.nodeId, row)} + class={`hover:bg-gray-100 dark:hover:bg-gray-800/70 ${ + props.selectedTaskId === taskEntry.nodeId ? 'bg-sky-50 dark:bg-sky-900/30' : '' + }`} + > + + + + + + + + ); + }} + + +
Task/ContainerNodeStateCPUMemoryStarted
+ {taskLabel()} + + {task.nodeName || task.nodeId || '—'} + + + {task.currentState || 'Unknown'} + + + —}> + + + + —}> + + + + {(() => { + const timestamp = task.startedAt || task.createdAt; + if (!timestamp) return '—'; + // Handle both Unix timestamps (number) and ISO strings (from backend time.Time) + const date = typeof timestamp === 'number' + ? new Date(timestamp * 1000) + : new Date(timestamp); + return date.toLocaleString(); + })()} +
+
+
+ + +
+ + ); +}; + +// Container Row Component +interface ContainerRowProps { + container: DockerContainer; + indent?: boolean; + isSelected?: boolean; + rowRef?: (row: HTMLTableRowElement | null) => void; +} + +const ContainerRow: Component = (props) => { + const formatPorts = () => { + if (!props.container.ports || props.container.ports.length === 0) return '—'; + return props.container.ports + .map((p) => { + if (p.publicPort) { + return `${p.publicPort}:${p.privatePort}/${p.protocol}`; + } + return `${p.privatePort}/${p.protocol}`; + }) + .join(', '); + }; + + const stateBadge = () => { + const state = props.container.state?.toLowerCase() || 'unknown'; + if (state === 'running') { + return ( + + Running + + ); + } + if (state === 'exited' || state === 'stopped') { + return ( + + Stopped + + ); + } + return ( + + {state} + + ); + }; + + const hasCpuData = () => + typeof props.container.cpuPercent === 'number' && !Number.isNaN(props.container.cpuPercent); + const hasMemData = () => + typeof props.container.memoryPercent === 'number' && !Number.isNaN(props.container.memoryPercent); + const cpuPercent = () => (hasCpuData() ? props.container.cpuPercent! : 0); + const memPercent = () => (hasMemData() ? props.container.memoryPercent! : 0); + + onCleanup(() => { + props.rowRef?.(null); + }); + + return ( + row && props.rowRef?.(row)} + class={`border-t border-gray-100 dark:border-gray-800 hover:bg-gray-50 dark:hover:bg-gray-800/50 ${ + props.isSelected ? 'bg-sky-50 dark:bg-sky-900/30' : '' + }`} + > + + {props.container.name || props.container.id?.slice(0, 12)} + + + + {props.container.image || 'Image not specified'} + + + {stateBadge()} + + —}> + + + + + —}> + + + + + {formatPorts()} + + + ); +}; + +export const DockerUnifiedTable: Component = (props) => { + // Track expanded state for each host + const getHostExpandState = (hostId: string) => { + if (!hostExpandState.has(hostId)) { + hostExpandState.set(hostId, true); + } + if (!hostExpandSignals.has(hostId)) { + hostExpandSignals.set( + hostId, + createSignal(hostExpandState.get(hostId) ?? true), + ); + } + + const [isExpanded, setIsExpanded] = hostExpandSignals.get(hostId)!; + + const setExpanded = (value: boolean) => + setIsExpanded(() => { + hostExpandState.set(hostId, value); + return value; + }); + + return { + isExpanded, + toggle: () => + setIsExpanded((prev) => { + const next = !prev; + hostExpandState.set(hostId, next); + return next; + }), + setExpanded, + }; + }; + + // Track expanded state for each service + const getServiceExpandState = (serviceKey: string) => { + if (!serviceExpandSignals.has(serviceKey)) { + serviceExpandSignals.set( + serviceKey, + createSignal(serviceExpandState.get(serviceKey) ?? false), + ); + } + + const [isExpanded, setIsExpanded] = serviceExpandSignals.get(serviceKey)!; + + const setExpanded = (value: boolean) => + setIsExpanded(() => { + serviceExpandState.set(serviceKey, value); + return value; + }); + + return { + isExpanded, + toggle: () => + setIsExpanded((prev) => { + const next = !prev; + serviceExpandState.set(serviceKey, next); + return next; + }), + setExpanded, + }; + }; + + const normalizeContainerState = (state?: string | null, status?: string | null) => { + const lowerState = state?.toLowerCase().trim(); + if (lowerState) return lowerState; + const lowerStatus = status?.toLowerCase().trim(); + if (!lowerStatus) return ''; + if (lowerStatus.startsWith('up')) return 'running'; + if (lowerStatus.startsWith('exited')) return 'exited'; + if (lowerStatus.startsWith('created')) return 'created'; + if (lowerStatus.startsWith('paused')) return 'paused'; + if (lowerStatus.includes('restarting')) return 'restarting'; + if (lowerStatus.includes('unhealthy')) return 'unhealthy'; + if (lowerStatus.includes('dead')) return 'dead'; + if (lowerStatus.includes('removing')) return 'removing'; + return lowerStatus; + }; + + const matchesContainerStateFilter = (filterValue: string, container?: DockerContainer | null) => { + if (!container) return false; + const state = normalizeContainerState(container.state, container.status); + if (filterValue === 'running') { + return state === 'running'; + } + if (filterValue === 'stopped') { + return STOPPED_CONTAINER_STATES.has(state); + } + if (filterValue === 'error') { + return ERROR_CONTAINER_STATES.has(state); + } + return true; + }; + + const matchesTaskStateFilter = (filterValue: string, task: DockerTask, container?: DockerContainer | null) => { + if (!filterValue) return true; + if (container && matchesContainerStateFilter(filterValue, container)) { + return true; + } + + const current = task.currentState?.toLowerCase() ?? ''; + if (filterValue === 'running') { + return current === 'running'; + } + if (filterValue === 'stopped') { + return current === 'complete' || current === 'shutdown' || current === 'stopped'; + } + if (filterValue === 'error') { + return current === 'failed' || current === 'error'; + } + return true; + }; + + const hostMatchesFilter = (host: DockerHost) => { + const filter = props.statsFilter; + if (!filter || filter.type !== 'host-status') { + return true; + } + const status = host.status?.toLowerCase() ?? 'unknown'; + switch (filter.value) { + case 'offline': + return OFFLINE_HOST_STATUSES.has(status); + case 'degraded': + return DEGRADED_HOST_STATUSES.has(status); + case 'online': + return status === 'online'; + default: + return true; + } + }; + + // Parse search terms + const searchTerms = createMemo(() => { + const term = props.searchTerm || ''; + return term + .toLowerCase() + .split(/[\s,]+/) + .map((t) => t.trim()) + .filter(Boolean); + }); + + // Check if a container matches search terms + const containerMatchesSearch = (host: DockerHost, container: DockerContainer) => { + const terms = searchTerms(); + if (terms.length === 0) return true; + + return terms.every((term) => { + const [prefix, value] = term.includes(':') ? term.split(/:(.+)/) : [null, term]; + const target = value || term; + + const tokens = [ + container.name, + container.image, + container.id, + container.state, + container.status, + host.displayName, + host.hostname, + ]; + + const hasToken = (list: (string | undefined)[]) => + list + .filter(Boolean) + .some((entry) => entry!.toLowerCase().includes(target)); + + if (prefix) { + switch (prefix) { + case 'host': + return hasToken([host.displayName, host.hostname]); + case 'name': + return hasToken([container.name]); + case 'image': + return hasToken([container.image]); + case 'state': + return hasToken([container.state, container.status]); + case 'id': + return hasToken([container.id]); + default: + return hasToken(tokens); + } + } + + return hasToken(tokens); + }); + }; + + // Check if a service matches search terms + const serviceMatchesSearch = (host: DockerHost, service: DockerService) => { + const terms = searchTerms(); + if (terms.length === 0) return true; + + return terms.every((term) => { + const [prefix, value] = term.includes(':') ? term.split(/:(.+)/) : [null, term]; + const target = value || term; + + const tokens = [ + service.name, + service.id, + service.image, + host.displayName, + host.hostname, + ]; + + const hasToken = (list: (string | undefined)[]) => + list + .filter(Boolean) + .some((entry) => entry!.toLowerCase().includes(target)); + + if (prefix) { + switch (prefix) { + case 'host': + return hasToken([host.displayName, host.hostname]); + case 'name': + case 'service': + return hasToken([service.name, service.id]); + case 'image': + return hasToken([service.image]); + default: + return hasToken(tokens); + } + } + + return hasToken(tokens); + }); + }; + + // Sort hosts alphabetically + const sortedHosts = createMemo(() => { + const hosts = props.hosts || []; + return [...hosts].sort((a, b) => { + const aName = a.displayName || a.hostname || a.id || ''; + const bName = b.displayName || b.hostname || b.id || ''; + return aName.localeCompare(bName); + }); + }); + + const containerFilterValue = () => + props.statsFilter?.type === 'container-state' ? props.statsFilter.value : null; + +const visibleHosts = createMemo(() => { + const results: DockerTreeHostEntry[] = []; + const hosts = sortedHosts(); + const filterValue = containerFilterValue(); + + hosts.forEach((host, index) => { + if (!hostMatchesFilter(host)) { + return; + } + + const hostId = + host.id || + host.hostname || + host.displayName || + `host-${index}`; + + const hostContainers = host.containers || []; + const hostTasks = host.tasks || []; + + const referencedContainers = new Set(); + hostTasks.forEach((task) => { + if (task.containerId) referencedContainers.add(task.containerId); + if (task.containerName) referencedContainers.add(task.containerName); + }); + + const standalone = hostContainers + .filter((container) => { + const id = container.id || ''; + const name = container.name || ''; + + if (referencedContainers.has(id) || referencedContainers.has(name)) { + return false; + } + + if (!containerMatchesSearch(host, container)) { + return false; + } + + if (filterValue && !matchesContainerStateFilter(filterValue, container)) { + return false; + } + + return true; + }) + .map((container, idx) => ({ + container, + nodeId: container.id + ? `${hostId}:container:${container.id}` + : container.name + ? `${hostId}:container:${container.name}` + : `${hostId}:container:${idx}`, + })); + + const services = (host.services || []).reduce((rows, service) => { + if (!serviceMatchesSearch(host, service)) { + return rows; + } + + const filteredTasks = hostTasks.filter((task) => { + const matchesService = + (task.serviceId && task.serviceId === service.id) || + (!task.serviceId && task.serviceName && task.serviceName === service.name); + if (!matchesService) return false; + + if (!filterValue) return true; + const container = findContainerForTask(hostContainers, task); + return matchesTaskStateFilter(filterValue, task, container); + }); + + if (filterValue && filteredTasks.length === 0) { + return rows; + } + + const identifier = service.id || service.name || 'service'; + const serviceKey = `${hostId}:${identifier}`; + + const tasks = filteredTasks.map((task, idx) => ({ + task, + nodeId: getTaskNodeId(serviceKey, task, idx), + })); + + rows.push({ + key: serviceKey, + service, + tasks, + }); + + return rows; + }, []); + + if (services.length === 0 && standalone.length === 0) { + return; + } + + results.push({ + host, + hostId, + containers: hostContainers, + services, + standaloneContainers: standalone, + }); + }); + + return results; +}); + + const [selectedNode, setSelectedNode] = createSignal(null); + const [isMobileTreeOpen, setIsMobileTreeOpen] = createSignal(false); + + const displayedHosts = createMemo(() => { + const hosts = visibleHosts(); + const selection = selectedNode(); + if (!selection) return hosts; + if (selection.type === 'host') { + const match = hosts.find((host) => host.hostId === selection.hostId); + return match ? [match] : hosts; + } + return hosts.filter((host) => host.hostId === selection.hostId); + }); + + const hostRefs = new Map(); + const serviceRefs = new Map(); + const taskRefs = new Map(); + const containerRefs = new Map(); + + const assignHostRef = (hostId: string, el: HTMLElement | null | undefined) => { + if (el) { + hostRefs.set(hostId, el); + } else { + hostRefs.delete(hostId); + } + }; + + const assignServiceRef = (serviceKey: string, el: HTMLTableRowElement | null | undefined) => { + if (el) { + serviceRefs.set(serviceKey, el); + } else { + serviceRefs.delete(serviceKey); + } + }; + + const registerTaskRef = (nodeId: string, row: HTMLTableRowElement) => { + taskRefs.set(nodeId, row); + }; + + const unregisterTaskRef = (nodeId: string) => { + taskRefs.delete(nodeId); + }; + + const assignContainerRef = (nodeId: string, el: HTMLTableRowElement | null | undefined) => { + if (el) { + containerRefs.set(nodeId, el); + } else { + containerRefs.delete(nodeId); + } + }; + + const scrollToSelection = (selection: DockerTreeSelection, smooth = true) => { + if (typeof window === 'undefined') return false; + + const verticalScroll = ( + element?: Element | null, + block: ScrollLogicalPosition = 'nearest', + ) => { + if (!element) return false; + const rect = element.getBoundingClientRect(); + const topAllowance = 96; + const bottomAllowance = window.innerHeight - 32; + + if (rect.top >= topAllowance && rect.bottom <= bottomAllowance) { + return true; + } + + element.scrollIntoView({ + behavior: smooth ? 'smooth' : 'auto', + block, + inline: 'nearest', + }); + return true; + }; + + if (selection.type === 'host') { + return verticalScroll(hostRefs.get(selection.hostId), 'start'); + } + + if (selection.type === 'service') { + return verticalScroll(serviceRefs.get(selection.id), 'nearest'); + } + + if (selection.type === 'task') { + return verticalScroll(taskRefs.get(selection.id), 'center'); + } + + if (selection.type === 'container') { + return verticalScroll(containerRefs.get(selection.id), 'nearest'); + } + + return false; + }; + + const handleTreeSelect = (selection: DockerTreeSelection) => { + batch(() => { + setSelectedNode(selection); + + const hostState = getHostExpandState(selection.hostId); + hostState.setExpanded(true); + + if (selection.type === 'task') { + const serviceState = getServiceExpandState(selection.serviceKey); + serviceState.setExpanded(true); + } + }); + + setIsMobileTreeOpen(false); + }; + + const buildSelectionFingerprint = (selection: DockerTreeSelection) => { + switch (selection.type) { + case 'host': + return `host:${selection.hostId}`; + case 'service': + return `service:${selection.hostId}:${selection.id}`; + case 'task': + return `task:${selection.hostId}:${selection.serviceKey}:${selection.id}`; + case 'container': + return `container:${selection.hostId}:${selection.id}`; + default: + return ''; + } + }; + + let lastScrollFingerprint = ''; + + const attemptScrollToSelection = ( + selection: DockerTreeSelection, + fingerprint: string, + remainingAttempts = 8, + ) => { + if (remainingAttempts <= 0) return; + const didScroll = scrollToSelection(selection, remainingAttempts === 8); + if (didScroll) { + lastScrollFingerprint = fingerprint; + } else if (typeof window !== 'undefined') { + requestAnimationFrame(() => + attemptScrollToSelection(selection, fingerprint, remainingAttempts - 1), + ); + } + }; + + createEffect(() => { + const selection = selectedNode(); + if (!selection) return; + const hosts = visibleHosts(); + const hostEntry = hosts.find((entry) => entry.hostId === selection.hostId); + if (!hostEntry) { + setSelectedNode(null); + return; + } + + if (selection.type === 'service') { + const exists = hostEntry.services.some((service) => service.key === selection.id); + if (!exists) { + setSelectedNode({ type: 'host', hostId: hostEntry.hostId, id: hostEntry.hostId }); + return; + } + } else if (selection.type === 'task') { + const serviceEntry = hostEntry.services.find((service) => service.key === selection.serviceKey); + if (!serviceEntry) { + setSelectedNode({ type: 'host', hostId: hostEntry.hostId, id: hostEntry.hostId }); + return; + } + const taskExists = serviceEntry.tasks.some((task) => task.nodeId === selection.id); + if (!taskExists) { + setSelectedNode({ type: 'service', hostId: hostEntry.hostId, id: serviceEntry.key }); + return; + } + } else if (selection.type === 'container') { + const exists = hostEntry.standaloneContainers.some((container) => container.nodeId === selection.id); + if (!exists) { + setSelectedNode({ type: 'host', hostId: hostEntry.hostId, id: hostEntry.hostId }); + return; + } + } + + const fingerprint = buildSelectionFingerprint(selection); + if (!fingerprint || fingerprint === lastScrollFingerprint) return; + + attemptScrollToSelection(selection, fingerprint); + }); + + const hasVisibleHosts = createMemo(() => visibleHosts().length > 0); + + return ( +
+ +
+ +
+
+ + + + + + +
+
setIsMobileTreeOpen(false)} + /> + +
+ + +
+ + {(entry) => { + const hostState = getHostExpandState(entry.hostId); + const currentSelection = () => selectedNode(); + const isHostActive = () => currentSelection()?.hostId === entry.hostId; + + const displayedServices = createMemo(() => { + const selection = currentSelection(); + if (!selection || selection.type === 'host') return entry.services; + if (selection.type === 'service') { + return entry.services.filter((service) => service.key === selection.id); + } + if (selection.type === 'task') { + return entry.services.filter((service) => service.key === selection.serviceKey); + } + return []; + }); + + const displayedStandaloneContainers = createMemo(() => { + const selection = currentSelection(); + if (!selection || selection.type === 'host') return entry.standaloneContainers; + if (selection.type === 'container') { + return entry.standaloneContainers.filter((container) => container.nodeId === selection.id); + } + return []; + }); + + const shouldShowServices = createMemo(() => displayedServices().length > 0); + const shouldShowContainers = createMemo(() => displayedStandaloneContainers().length > 0); + + return ( +
assignHostRef(entry.hostId, el)} + class="space-y-4 scroll-mt-28 min-w-0" + > + + + + + +
+
+ + +
+ + + + + + + + + + + + + + {(serviceEntry) => { + const serviceState = getServiceExpandState(serviceEntry.key); + const serviceSelected = () => { + const selection = selectedNode(); + if (!selection) return false; + if (selection.type === 'service') { + return selection.id === serviceEntry.key; + } + if (selection.type === 'task') { + return selection.serviceKey === serviceEntry.key; + } + return false; + }; + const selectedTaskId = () => { + const selection = selectedNode(); + if (selection?.type === 'task' && selection.serviceKey === serviceEntry.key) { + return selection.id; + } + return null; + }; + + onCleanup(() => { + serviceRefs.delete(serviceEntry.key); + }); + + return ( + assignServiceRef(serviceEntry.key, row)} + selectedTaskId={selectedTaskId()} + onTaskMount={registerTaskRef} + onTaskUnmount={unregisterTaskRef} + /> + ); + }} + + +
+ Service + + Image + + Status +
+
+
+
+ + + + + + + + + + + + + + + + + + {(containerEntry) => { + const isContainerSelected = () => + selectedNode()?.type === 'container' && + selectedNode()?.id === containerEntry.nodeId; + + return ( + assignContainerRef(containerEntry.nodeId, row)} + /> + ); + }} + + +
+ Container + + Image + + Status + + CPU + + Memory + + Ports +
+
+
+
+
+
+
+ ); + }} +
+
+
+ ); +}; diff --git a/frontend-modern/src/components/FirstRunSetup.tsx b/frontend-modern/src/components/FirstRunSetup.tsx index 7cf198c..239e435 100644 --- a/frontend-modern/src/components/FirstRunSetup.tsx +++ b/frontend-modern/src/components/FirstRunSetup.tsx @@ -8,7 +8,9 @@ import { SectionHeader } from '@/components/shared/SectionHeader'; import { showTokenReveal } from '@/stores/tokenReveal'; import type { APITokenRecord } from '@/api/security'; -export const FirstRunSetup: Component = () => { +export const FirstRunSetup: Component<{ force?: boolean; showLegacyBanner?: boolean }> = ( + props, +) => { const [username, setUsername] = createSignal('admin'); const [password, setPassword] = createSignal(''); const [confirmPassword, setConfirmPassword] = createSignal(''); @@ -110,6 +112,7 @@ export const FirstRunSetup: Component = () => { username: username(), password: finalPassword, apiToken: token, + force: props.force ?? false, }), }); @@ -214,6 +217,20 @@ IMPORTANT: Keep these credentials secure! return (
+ +
+

Authentication forced off via environment

+

+ Pulse detected the legacy DISABLE_AUTH flag. Complete the + setup below to rotate credentials, then remove the environment variable and restart Pulse. +

+

+ If you still need a temporary bypass after rotating, create .auth_recovery + in the Pulse data directory and restart. Remove the file and restart again once you regain access. +

+
+
+ {/* Logo/Header */}
diff --git a/frontend-modern/src/components/Login.tsx b/frontend-modern/src/components/Login.tsx index 5cccff3..b351cda 100644 --- a/frontend-modern/src/components/Login.tsx +++ b/frontend-modern/src/components/Login.tsx @@ -17,6 +17,10 @@ interface SecurityStatus { oidcIssuer?: string; oidcClientId?: string; oidcEnvOverrides?: Record; + disabled?: boolean; + deprecatedDisableAuth?: boolean; + message?: string; + apiTokenConfigured?: boolean; } export const Login: Component = (props) => { @@ -261,6 +265,10 @@ export const Login: Component = (props) => { // Debug logging console.log('[Login] Render - loadingAuth:', loadingAuth(), 'authStatus:', authStatus()); + const legacyDisableAuth = () => authStatus()?.deprecatedDisableAuth === true; + const showFirstRunSetup = () => + authStatus()?.hasAuthentication === false || legacyDisableAuth(); + return ( = (props) => { } > = (props) => {
} > - + diff --git a/frontend-modern/src/components/Settings/DockerAgents.tsx b/frontend-modern/src/components/Settings/DockerAgents.tsx index 5d87bba..1c8da1d 100644 --- a/frontend-modern/src/components/Settings/DockerAgents.tsx +++ b/frontend-modern/src/components/Settings/DockerAgents.tsx @@ -43,20 +43,6 @@ export const DockerAgents: Component = () => { const [latestRecord, setLatestRecord] = createSignal(null); const [tokenName, setTokenName] = createSignal(''); - const [expandedHosts, setExpandedHosts] = createSignal>(new Set()); - const isHostExpanded = (id: string) => expandedHosts().has(id); - const toggleHostExpanded = (id: string) => { - setExpandedHosts((prev) => { - const next = new Set(prev); - if (next.has(id)) { - next.delete(id); - } else { - next.add(id); - } - return next; - }); - }; - const pulseUrl = () => { if (typeof window !== 'undefined') { const protocol = window.location.protocol; @@ -818,19 +804,35 @@ WantedBy=multi-user.target`;
-
-

- Reporting Docker hosts ({dockerHosts().length}) -

- 0}> - - + + + + Open Docker monitoring + + 0}> + + +
Host Status - Containers - Docker Version - Agent Version + Agent & Docker Last Seen - + Actions {(host) => { const isOnline = host.status?.toLowerCase() === 'online'; - const runningContainers = host.containers?.filter(c => c.state?.toLowerCase() === 'running').length || 0; const displayName = getDisplayName(host); const commandStatus = host.command?.status ?? null; - const commandInProgress = commandStatus === 'queued' || commandStatus === 'dispatched' || commandStatus === 'acknowledged'; + const commandInProgress = + commandStatus === 'queued' || + commandStatus === 'dispatched' || + commandStatus === 'acknowledged'; const commandFailed = commandStatus === 'failed'; const commandCompleted = commandStatus === 'completed'; - const services = host.services ?? []; - const serviceCount = services.length; - const taskCount = host.tasks?.length ?? 0; - const swarm = host.swarm; - const expanded = isHostExpanded(host.id); - const unhealthyServiceCount = services.filter(service => { - const desired = service.desiredTasks ?? 0; - const running = service.runningTasks ?? 0; - return desired > 0 && running < desired; - }).length; - const missingReplicaCount = services.reduce((total, service) => { - const desired = service.desiredTasks ?? 0; - const running = service.runningTasks ?? 0; - if (desired <= 0 || running >= desired) { - return total; - } - return total + (desired - running); - }, 0); const offlineActionLabel = commandFailed ? 'Force remove host' : host.pendingUninstall ? 'Clean up pending host' : 'Remove offline host'; + const tokenRevoked = typeof host.tokenRevokedAt === 'number'; + const tokenRevokedRelative = tokenRevoked ? formatRelativeTime(host.tokenRevokedAt!) : ''; return ( - <> - - -
{host.displayName}
-
{host.hostname}
- -
- {host.os} - - - - {host.architecture} -
-
- 0}> -
- - - {swarm?.nodeRole ?? 'unknown'} - - scope: {swarm?.scope ?? 'node'} - - services: {serviceCount} - tasks: {taskCount} - 0}> - 0 - ? 'bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-300' - : 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-200' - }`} - title={ - unhealthyServiceCount > 0 - ? `${unhealthyServiceCount} service(s) missing ${missingReplicaCount} replica(s)` - : 'All reported services are meeting their desired replica count' - } - > - {unhealthyServiceCount > 0 - ? `${unhealthyServiceCount} degraded` - : 'All healthy'} - - -
-
- 0 || taskCount > 0}> - - - - -
+ + +
{displayName}
+
{host.hostname}
+ +
+ {host.os} + + + + {host.architecture} +
+
+ + + Hidden + + + +
+ Token revoked {tokenRevokedRelative} +
+
+ + +
- + Stopping @@ -985,7 +935,7 @@ WantedBy=multi-user.target`; - + Failed @@ -993,38 +943,39 @@ WantedBy=multi-user.target`; - + Stopped
+ +
+ Stop command queued; waiting for acknowledgement. +
+
- -
- {runningContainers} / {host.containers?.length || 0} + +
+ {host.dockerVersion ? `Docker ${host.dockerVersion}` : 'Docker version unavailable'}
-
running
- - -
{host.dockerVersion || '—'}
- - -
{host.agentVersion || '—'}
- every {host.intervalSeconds || 0}s + Agent {host.agentVersion ? `v${host.agentVersion}` : 'not reporting'} + + (every {host.intervalSeconds}s) +
- +
{host.lastSeen ? formatRelativeTime(host.lastSeen) : '—'}
- {host.lastSeen ? formatAbsoluteTime(host.lastSeen) : '—'} + {host.lastSeen ? formatAbsoluteTime(host.lastSeen) : 'Awaiting first report'}
- + - openRemoveModal(host)} + disabled={isRemovingHost(host.id)} + > + {isRemovingHost(host.id) ? 'Working…' : 'Remove'} + + } + > - } - > - - - }> + + } + >
- + +
{/* Content */}

- DISABLE_AUTH is set, or auth isn't configured yet. Set up password authentication to protect your Pulse instance. + + Authentication is currently disabled. Set up password authentication to protect your Pulse instance. + + } + > + Authentication settings are locked by the legacy{' '} + + DISABLE_AUTH + {' '} + environment variable. Remove it from your deployment and restart Pulse before enabling security from this page. +

- + { setShowQuickSecuritySetup(false); @@ -4297,13 +4326,22 @@ const Settings: Component = (props) => { > Change password - + +
User:{' '} @@ -4311,7 +4349,7 @@ const Settings: Component = (props) => {
- +
(); + export const ScrollableTable: Component = (props) => { const [showLeftFade, setShowLeftFade] = createSignal(false); const [showRightFade, setShowRightFade] = createSignal(false); let scrollContainer: HTMLDivElement | undefined; - const checkScroll = () => { + const checkScroll = (scrollLeftValue?: number) => { if (!scrollContainer) return; - const { scrollLeft, scrollWidth, clientWidth } = scrollContainer; + const scrollLeft = scrollLeftValue ?? scrollContainer.scrollLeft; + const { scrollWidth, clientWidth } = scrollContainer; setShowLeftFade(scrollLeft > 0); setShowRightFade(scrollLeft < scrollWidth - clientWidth - 1); }; onMount(() => { - checkScroll(); - window.addEventListener('resize', checkScroll); + const initialLeft = props.persistKey ? scrollPositions.get(props.persistKey) ?? 0 : 0; + if (scrollContainer) { + scrollContainer.scrollLeft = initialLeft; + } + checkScroll(initialLeft); + + const resizeHandler = () => checkScroll(); + window.addEventListener('resize', resizeHandler); onCleanup(() => { - window.removeEventListener('resize', checkScroll); + window.removeEventListener('resize', resizeHandler); }); }); createEffect(() => { if (scrollContainer) { - scrollContainer.addEventListener('scroll', checkScroll); - return () => scrollContainer?.removeEventListener('scroll', checkScroll); + const handler = () => { + if (!scrollContainer) return; + const left = scrollContainer.scrollLeft; + if (props.persistKey) { + scrollPositions.set(props.persistKey, left); + } + checkScroll(left); + }; + scrollContainer.addEventListener('scroll', handler, { passive: true }); + return () => scrollContainer?.removeEventListener('scroll', handler); } }); @@ -51,7 +69,9 @@ export const ScrollableTable: Component = (props) => { -
{props.children}
+
+ {props.children} +
{/* Right fade */} diff --git a/frontend-modern/src/hooks/useDebouncedValue.ts b/frontend-modern/src/hooks/useDebouncedValue.ts new file mode 100644 index 0000000..53da585 --- /dev/null +++ b/frontend-modern/src/hooks/useDebouncedValue.ts @@ -0,0 +1,22 @@ +import { createSignal, createEffect, onCleanup, Accessor } from 'solid-js'; + +/** + * Creates a debounced version of a signal value + * @param value - The signal accessor to debounce + * @param delay - Delay in milliseconds (default: 300ms) + * @returns Debounced signal accessor + */ +export function useDebouncedValue(value: Accessor, delay: number = 300): Accessor { + const [debouncedValue, setDebouncedValue] = createSignal(value()); + + createEffect(() => { + const currentValue = value(); + const timer = setTimeout(() => { + setDebouncedValue(() => currentValue); + }, delay); + + onCleanup(() => clearTimeout(timer)); + }); + + return debouncedValue; +} diff --git a/frontend-modern/src/stores/alertsActivation.ts b/frontend-modern/src/stores/alertsActivation.ts index ae9bcff..d61e229 100644 --- a/frontend-modern/src/stores/alertsActivation.ts +++ b/frontend-modern/src/stores/alertsActivation.ts @@ -136,7 +136,3 @@ export const useAlertsActivation = () => ({ deactivate, snooze, }); - -// Initialize on module load -refreshConfig(); -refreshActiveAlerts(); diff --git a/frontend-modern/src/types/api.ts b/frontend-modern/src/types/api.ts index f9ba00e..26982e3 100644 --- a/frontend-modern/src/types/api.ts +++ b/frontend-modern/src/types/api.ts @@ -207,10 +207,10 @@ export interface DockerTask { message?: string; containerId?: string; containerName?: string; - createdAt?: number; - updatedAt?: number; - startedAt?: number; - completedAt?: number; + createdAt?: number | string; // Unix timestamp or ISO string + updatedAt?: number | string; // Unix timestamp or ISO string + startedAt?: number | string; // Unix timestamp or ISO string + completedAt?: number | string; // Unix timestamp or ISO string } export interface DockerSwarmInfo { diff --git a/frontend-modern/src/types/config.ts b/frontend-modern/src/types/config.ts index 364c8a4..6dc0219 100644 --- a/frontend-modern/src/types/config.ts +++ b/frontend-modern/src/types/config.ts @@ -113,6 +113,9 @@ export interface SecurityStatus { proxyAuthLogoutURL?: string; authUsername?: string; authLastModified?: string; + disabled?: boolean; // legacy field (removed backend support) + deprecatedDisableAuth?: boolean; + message?: string; } /** diff --git a/internal/api/auth.go b/internal/api/auth.go index 02778d7..960c804 100644 --- a/internal/api/auth.go +++ b/internal/api/auth.go @@ -186,7 +186,7 @@ func min(a, b int) int { // CheckAuth checks both basic auth and API token func CheckAuth(cfg *config.Config, w http.ResponseWriter, r *http.Request) bool { - // Check proxy auth first if configured (even if DISABLE_AUTH is true) + // Check proxy auth first if configured if cfg.ProxyAuthSecret != "" { if valid, username, _ := CheckProxyAuth(cfg, r); valid { // Set username in response header for frontend @@ -198,7 +198,7 @@ func CheckAuth(cfg *config.Config, w http.ResponseWriter, r *http.Request) bool } } - // Check for OIDC session cookie (even if DISABLE_AUTH is true) + // Check for OIDC session cookie if cfg.OIDC != nil && cfg.OIDC.Enabled { if cookie, err := r.Cookie("pulse_session"); err == nil && cookie.Value != "" { if ValidateSession(cookie.Value) { @@ -212,13 +212,6 @@ func CheckAuth(cfg *config.Config, w http.ResponseWriter, r *http.Request) bool } } - // If auth is explicitly disabled, allow all access - // (but only after checking proxy and OIDC auth above) - if cfg.DisableAuth { - w.Header().Set("X-Auth-Disabled", "true") - return true - } - // If no auth is configured at all, allow access unless OIDC is enabled if cfg.AuthUser == "" && cfg.AuthPass == "" && !cfg.HasAPITokens() && cfg.ProxyAuthSecret == "" { if cfg.OIDC != nil && cfg.OIDC.Enabled { diff --git a/internal/api/diagnostics.go b/internal/api/diagnostics.go index 360efb2..a4a6cc2 100644 --- a/internal/api/diagnostics.go +++ b/internal/api/diagnostics.go @@ -53,7 +53,7 @@ type DiagnosticsInfo struct { // MemorySourceStat aggregates memory-source usage per instance. type MemorySourceStat struct { - Instance string `json:"instance"` + Instance string `json:"instance"` Source string `json:"source"` NodeCount int `json:"nodeCount"` LastUpdated string `json:"lastUpdated"` @@ -74,9 +74,9 @@ const diagnosticsCacheTTL = 45 * time.Second var ( diagnosticsMetricsOnce sync.Once - diagnosticsCacheMu sync.RWMutex - diagnosticsCache DiagnosticsInfo - diagnosticsCacheTimestamp time.Time + diagnosticsCacheMu sync.RWMutex + diagnosticsCache DiagnosticsInfo + diagnosticsCacheTimestamp time.Time diagnosticsCacheHits = prometheus.NewCounter(prometheus.CounterOpts{ Namespace: "pulse", @@ -639,7 +639,7 @@ func buildAPITokenDiagnostic(cfg *config.Config, monitor *monitoring.Monitor) *A } diag := &APITokenDiagnostic{ - Enabled: cfg.APITokenEnabled && !cfg.DisableAuth, + Enabled: cfg.APITokenEnabled, TokenCount: len(cfg.APITokens), } @@ -670,9 +670,7 @@ func buildAPITokenDiagnostic(cfg *config.Config, monitor *monitoring.Monitor) *A diag.RecommendTokenSetup = len(cfg.APITokens) == 0 diag.RecommendTokenRotation = envTokens || legacyToken - if cfg.DisableAuth { - appendNote("Authentication is disabled (DISABLE_AUTH=1). Re-enable it to use per-agent API tokens.") - } else if !cfg.APITokenEnabled && len(cfg.APITokens) > 0 { + if !cfg.APITokenEnabled && len(cfg.APITokens) > 0 { appendNote("API token authentication is currently disabled. Enable it under Settings → Security so agents can use dedicated tokens.") } else if diag.RecommendTokenSetup { appendNote("No API tokens are configured. Open Settings → Security to generate dedicated tokens for each automation or agent.") diff --git a/internal/api/router.go b/internal/api/router.go index 6f840c9..7372ab4 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -294,47 +294,6 @@ func (r *Router) setupRoutes() { if req.Method == http.MethodGet { w.Header().Set("Content-Type", "application/json") - // Check if auth is globally disabled - if r.config.DisableAuth { - // Even with auth disabled, check for OIDC sessions - oidcCfg := r.ensureOIDCConfig() - oidcUsername := "" - if oidcCfg != nil && oidcCfg.Enabled { - if cookie, err := req.Cookie("pulse_session"); err == nil && cookie.Value != "" { - if ValidateSession(cookie.Value) { - oidcUsername = GetSessionUsername(cookie.Value) - } - } - } - - // Even with auth disabled, report API token status for API access - apiTokenHint := r.config.PrimaryAPITokenHint() - - response := map[string]interface{}{ - "configured": false, - "disabled": true, - "message": "Authentication is disabled via DISABLE_AUTH environment variable", - "apiTokenConfigured": r.config.HasAPITokens(), - "apiTokenHint": apiTokenHint, - "hasAuthentication": false, - } - - // Add OIDC info if available - if oidcCfg != nil { - response["oidcEnabled"] = oidcCfg.Enabled - response["oidcIssuer"] = oidcCfg.IssuerURL - response["oidcClientId"] = oidcCfg.ClientID - response["oidcUsername"] = oidcUsername - response["oidcLogoutURL"] = oidcCfg.LogoutURL - if len(oidcCfg.EnvOverrides) > 0 { - response["oidcEnvOverrides"] = oidcCfg.EnvOverrides - } - } - - json.NewEncoder(w).Encode(response) - return - } - // Check for basic auth configuration // Check both environment variables and loaded config oidcCfg := r.ensureOIDCConfig() @@ -446,6 +405,11 @@ func (r *Router) setupRoutes() { } } + if r.config.DisableAuthEnvDetected { + status["deprecatedDisableAuth"] = true + status["message"] = "DISABLE_AUTH is deprecated and no longer disables authentication. Remove the environment variable and restart Pulse to manage authentication from the UI." + } + json.NewEncoder(w).Encode(status) } else { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) @@ -1172,31 +1136,6 @@ func (r *Router) ServeHTTP(w http.ResponseWriter, req *http.Request) { // Check if we need authentication needsAuth := true - // Check if auth is globally disabled - // BUT still check for API tokens if provided (for API access when auth is disabled) - if r.config.DisableAuth { - // Check if an API token was provided - providedToken := req.Header.Get("X-API-Token") - if providedToken == "" { - providedToken = req.URL.Query().Get("token") - } - - // If a valid API token is provided, allow access even with DisableAuth - if providedToken != "" && r.config.HasAPITokens() { - if _, ok := r.config.ValidateAPIToken(providedToken); ok { - needsAuth = false - w.Header().Set("X-Auth-Method", "api-token") - } else { - http.Error(w, "Invalid API token", http.StatusUnauthorized) - return - } - } else { - // No API token provided with DisableAuth - allow open access - needsAuth = false - w.Header().Set("X-Auth-Disabled", "true") - } - } - // Recovery mechanism: Check if recovery mode is enabled recoveryFile := filepath.Join(r.config.DataPath, ".auth_recovery") if _, err := os.Stat(recoveryFile); err == nil { @@ -1336,10 +1275,11 @@ func (r *Router) ServeHTTP(w http.ResponseWriter, req *http.Request) { // CSRF is only needed when using session-based auth // Only skip CSRF for initial setup when no auth is configured skipCSRF := false - if (req.URL.Path == "/api/security/quick-setup" || req.URL.Path == "/api/security/apply-restart") && - r.config.AuthUser == "" && r.config.AuthPass == "" { - // Only skip CSRF for initial setup and restart when no auth exists - skipCSRF = true + if req.URL.Path == "/api/security/quick-setup" || req.URL.Path == "/api/security/apply-restart" { + if (r.config.AuthUser == "" && r.config.AuthPass == "") || r.config.DisableAuthEnvDetected { + // Allow bootstrap or legacy recovery runs without CSRF token + skipCSRF = true + } } // Skip CSRF for setup-script-url endpoint (generates temporary tokens, not a state change) if req.URL.Path == "/api/setup-script-url" { @@ -2272,7 +2212,7 @@ func (r *Router) handleState(w http.ResponseWriter, req *http.Request) { log.Debug().Msg("[DEBUG] handleState: Before auth check") // Use standard auth check (supports both basic auth and API tokens) unless auth is disabled - if !r.config.DisableAuth && !CheckAuth(r.config, w, req) { + if !CheckAuth(r.config, w, req) { writeErrorResponse(w, http.StatusUnauthorized, "unauthorized", "Authentication required", nil) return diff --git a/internal/api/router_integration_test.go b/internal/api/router_integration_test.go index 95da961..d58f300 100644 --- a/internal/api/router_integration_test.go +++ b/internal/api/router_integration_test.go @@ -48,7 +48,6 @@ func newIntegrationServerWithConfig(t *testing.T, customize func(*config.Config) BackendPort: 7655, ConfigPath: tmpDir, DataPath: tmpDir, - DisableAuth: true, DemoMode: false, AllowedOrigins: "*", ConcurrentPolling: true, @@ -205,7 +204,6 @@ func TestProtectedEndpointsRequireAuthentication(t *testing.T) { } srv := newIntegrationServerWithConfig(t, func(cfg *config.Config) { - cfg.DisableAuth = false cfg.AuthUser = "admin" cfg.AuthPass = passwordHash }) @@ -252,7 +250,6 @@ func TestAPIOnlyModeRequiresToken(t *testing.T) { } srv := newIntegrationServerWithConfig(t, func(cfg *config.Config) { - cfg.DisableAuth = false cfg.AuthUser = "" cfg.AuthPass = "" cfg.APITokenEnabled = true @@ -352,33 +349,6 @@ func TestConfigNodesUsesMockTopology(t *testing.T) { } } -func TestSecurityStatusReflectsDisableAuth(t *testing.T) { - srv := newIntegrationServer(t) - - res, err := http.Get(srv.server.URL + "/api/security/status") - if err != nil { - t.Fatalf("security status request failed: %v", err) - } - defer res.Body.Close() - - if res.StatusCode != http.StatusOK { - t.Fatalf("unexpected status: got %d want %d", res.StatusCode, http.StatusOK) - } - - var payload map[string]any - if err := json.NewDecoder(res.Body).Decode(&payload); err != nil { - t.Fatalf("decode security status response: %v", err) - } - - disabled, ok := payload["disabled"].(bool) - if !ok { - t.Fatalf("security status response missing disabled flag") - } - if !disabled { - t.Fatalf("expected authentication disabled flag to be true") - } -} - func TestMockModeToggleEndpoint(t *testing.T) { srv := newIntegrationServer(t) @@ -432,7 +402,6 @@ func TestAuthenticatedEndpointsRequireToken(t *testing.T) { const apiToken = "test-token" srv := newIntegrationServerWithConfig(t, func(cfg *config.Config) { - cfg.DisableAuth = false cfg.APITokenEnabled = true record, err := config.NewAPITokenRecord(apiToken, "Integration test token", nil) if err != nil { @@ -606,7 +575,6 @@ func TestWebSocketSendsInitialState(t *testing.T) { func TestSessionCookieAllowsAuthenticatedAccess(t *testing.T) { srv := newIntegrationServerWithConfig(t, func(cfg *config.Config) { - cfg.DisableAuth = false cfg.APITokenEnabled = false hashedPass, err := internalauth.HashPassword("super-secure-pass") if err != nil { diff --git a/internal/api/security_setup_fix.go b/internal/api/security_setup_fix.go index 0100dd1..4a1657c 100644 --- a/internal/api/security_setup_fix.go +++ b/internal/api/security_setup_fix.go @@ -83,6 +83,10 @@ func handleQuickSecuritySetupFixed(r *Router) http.HandlerFunc { return } + if r.config.DisableAuthEnvDetected { + setupRequest.Force = true + } + if r.config.AuthUser != "" && r.config.AuthPass != "" && !setupRequest.Force { log.Info().Msg("Security setup skipped - password auth already configured") response := map[string]interface{}{ @@ -367,10 +371,8 @@ PULSE_AUDIT_LOG=true // HandleRegenerateAPIToken generates a new API token and updates the .env file func (r *Router) HandleRegenerateAPIToken(w http.ResponseWriter, rq *http.Request) { - // Only require authentication if auth is already configured AND not disabled - // This allows users to set up API-only access without password auth - // When auth is disabled, allow API token generation for API-only access - if !r.config.DisableAuth && (r.config.AuthUser != "" || r.config.AuthPass != "") && !CheckAuth(r.config, w, rq) { + // Require authentication when password auth is configured + if (r.config.AuthUser != "" || r.config.AuthPass != "") && !CheckAuth(r.config, w, rq) { return } @@ -522,7 +524,7 @@ func (r *Router) HandleValidateAPIToken(w http.ResponseWriter, rq *http.Request) // Require authentication to prevent unauthenticated token guessing oracle // Use same auth logic as regenerate-token endpoint - if !r.config.DisableAuth && (r.config.AuthUser != "" || r.config.AuthPass != "") && !CheckAuth(r.config, w, rq) { + if (r.config.AuthUser != "" || r.config.AuthPass != "") && !CheckAuth(r.config, w, rq) { return } diff --git a/internal/config/config.go b/internal/config/config.go index f220b3e..664f39f 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -110,15 +110,15 @@ type Config struct { LogCompress bool `envconfig:"LOG_COMPRESS" default:"true"` // Security settings - APIToken string `envconfig:"API_TOKEN"` - APITokenEnabled bool `envconfig:"API_TOKEN_ENABLED" default:"false"` - APITokens []APITokenRecord `json:"-"` - AuthUser string `envconfig:"PULSE_AUTH_USER"` - AuthPass string `envconfig:"PULSE_AUTH_PASS"` - DisableAuth bool `envconfig:"DISABLE_AUTH" default:"false"` - DemoMode bool `envconfig:"DEMO_MODE" default:"false"` // Read-only demo mode - AllowedOrigins string `envconfig:"ALLOWED_ORIGINS" default:"*"` - IframeEmbeddingAllow string `envconfig:"IFRAME_EMBEDDING_ALLOW" default:"SAMEORIGIN"` + APIToken string `envconfig:"API_TOKEN"` + APITokenEnabled bool `envconfig:"API_TOKEN_ENABLED" default:"false"` + APITokens []APITokenRecord `json:"-"` + AuthUser string `envconfig:"PULSE_AUTH_USER"` + AuthPass string `envconfig:"PULSE_AUTH_PASS"` + DisableAuthEnvDetected bool `json:"-"` + DemoMode bool `envconfig:"DEMO_MODE" default:"false"` // Read-only demo mode + AllowedOrigins string `envconfig:"ALLOWED_ORIGINS" default:"*"` + IframeEmbeddingAllow string `envconfig:"IFRAME_EMBEDDING_ALLOW" default:"SAMEORIGIN"` // Proxy authentication settings ProxyAuthSecret string `envconfig:"PROXY_AUTH_SECRET"` @@ -878,17 +878,20 @@ func Load() (*Config, error) { log.Info().Msg("Migrated legacy API token into token record store") } } - // Check if auth is disabled - disableAuthEnv := os.Getenv("DISABLE_AUTH") - log.Debug().Str("DISABLE_AUTH_ENV", disableAuthEnv).Msg("Checking DISABLE_AUTH environment variable") - if disableAuthEnv != "" { - cfg.DisableAuth = disableAuthEnv == "true" || disableAuthEnv == "1" - log.Debug().Bool("DisableAuth", cfg.DisableAuth).Msg("DisableAuth set from environment") - if cfg.DisableAuth { - log.Warn().Msg("⚠️ AUTHENTICATION DISABLED - Pulse is running without authentication!") + // Detect deprecated DISABLE_AUTH flag and strip it from the runtime env so downstream + // components behave as if it were never set. + if disableAuthEnv := os.Getenv("DISABLE_AUTH"); disableAuthEnv != "" { + cfg.DisableAuthEnvDetected = true + if err := os.Unsetenv("DISABLE_AUTH"); err != nil { + log.Warn(). + Str("DISABLE_AUTH", disableAuthEnv). + Err(err). + Msg("Failed to remove legacy DISABLE_AUTH environment variable; continuing with authentication enabled") + } else { + log.Warn(). + Str("DISABLE_AUTH", disableAuthEnv). + Msg("Removed legacy DISABLE_AUTH environment variable. Authentication remains enabled.") } - } else { - log.Debug().Bool("DisableAuth", cfg.DisableAuth).Msg("DISABLE_AUTH not set, DisableAuth remains") } // Check if demo mode is enabled diff --git a/internal/mock/generator.go b/internal/mock/generator.go index b70b5cb..2d27a42 100644 --- a/internal/mock/generator.go +++ b/internal/mock/generator.go @@ -1036,13 +1036,17 @@ func generateDockerHosts(config MockConfig) []models.DockerHost { containers[idx].CPUPercent = clampFloat(containers[idx].CPUPercent+35, 5, 190) } + // Determine initial status - only mark last host as offline for testing status := "online" + explicitlyOffline := false if hostCount > 2 && i == hostCount-1 { status = "offline" + explicitlyOffline = true } lastSeen := now.Add(-time.Duration(rand.Intn(20)) * time.Second) - if status == "offline" { + if explicitlyOffline { + // If host is explicitly offline, stop all containers and update metrics lastSeen = now.Add(-time.Duration(5+rand.Intn(20)) * time.Minute) for idx := range containers { exitCode := containers[idx].ExitCode @@ -1060,9 +1064,8 @@ func generateDockerHosts(config MockConfig) []models.DockerHost { containers[idx].FinishedAt = &finished containers[idx].Status = fmt.Sprintf("Exited (%d) %s ago", exitCode, formatDurationForStatus(now.Sub(finished))) } - } - - if status != "offline" { + } else { + // For hosts not explicitly offline, calculate status based on container health running := 0 unhealthy := 0 for _, ct := range containers { @@ -1075,15 +1078,17 @@ func generateDockerHosts(config MockConfig) []models.DockerHost { } } - if running == 0 { + // Only mark as offline if there are containers but none are running + // (Swarm-only hosts with no standalone containers should stay online) + if len(containers) > 0 && running == 0 { status = "offline" lastSeen = now.Add(-time.Duration(3+rand.Intn(5)) * time.Minute) - } else if unhealthy > 0 || float64(len(containers)-running)/float64(len(containers)) > 0.35 { + } else if unhealthy > 0 || (len(containers) > 0 && float64(len(containers)-running)/float64(len(containers)) > 0.35) { status = "degraded" if lastSeen.After(now.Add(-30 * time.Second)) { lastSeen = now.Add(-35 * time.Second) } - } else if status != "offline" { + } else { status = "online" } } @@ -3677,6 +3682,12 @@ func generateDockerServicesAndTasks(hostname string, containers []models.DockerC currentState = []string{"failed", "shutdown", "pending", "starting"}[rand.Intn(4)] } + // Create a unique container name for each task + taskContainerName := container.Name + if slots > 1 { + taskContainerName = fmt.Sprintf("%s.%d", container.Name, slot+1) + } + taskID := fmt.Sprintf("%s-task-%d", serviceID, slot) task := models.DockerTask{ ID: taskID, @@ -3687,26 +3698,30 @@ func generateDockerServicesAndTasks(hostname string, containers []models.DockerC NodeName: hostname, DesiredState: "running", CurrentState: currentState, - ContainerID: container.ID, - ContainerName: container.Name, + ContainerID: fmt.Sprintf("%s-%d", container.ID, slot), + ContainerName: taskContainerName, CreatedAt: now.Add(-time.Duration(rand.Intn(48)) * time.Hour), } - if container.StartedAt != nil { + // Set varied start times for each task (not all identical) + if currentState == "running" { + startTime := now.Add(-time.Duration(30+rand.Intn(3600*24)) * time.Second) + task.StartedAt = &startTime + } else if container.StartedAt != nil && (currentState == "failed" || currentState == "shutdown") { started := *container.StartedAt task.StartedAt = &started } - if container.FinishedAt != nil { - finished := *container.FinishedAt - task.CompletedAt = &finished - } - if currentState == "running" { - task.StartedAt = ptrTime(now.Add(-time.Duration(30+rand.Intn(3600)) * time.Second)) - } if currentState == "failed" || currentState == "shutdown" { task.Error = "container exit" task.Message = "Replica exited unexpectedly" + if container.FinishedAt != nil { + finished := *container.FinishedAt + task.CompletedAt = &finished + } else { + completedTime := now.Add(-time.Duration(rand.Intn(3600)) * time.Second) + task.CompletedAt = &completedTime + } } agg.tasks = append(agg.tasks, task) diff --git a/mock.env b/mock.env index 76bfa94..ac3fa6f 100644 --- a/mock.env +++ b/mock.env @@ -12,7 +12,7 @@ # Documentation: docs/development/MOCK_MODE.md # Enable/disable mock mode (false = use real Proxmox infrastructure) -PULSE_MOCK_MODE=true +PULSE_MOCK_MODE=false # Number of mock nodes to generate (mix of clustered and standalone) # First 5 nodes form a cluster, remaining nodes are standalone diff --git a/scripts/hot-dev.sh b/scripts/hot-dev.sh index bc60615..2b6858d 100755 --- a/scripts/hot-dev.sh +++ b/scripts/hot-dev.sh @@ -182,8 +182,23 @@ if [[ ${PULSE_MOCK_MODE:-false} == "true" ]]; then mkdir -p "$PULSE_DATA_DIR" echo "[hot-dev] Mock mode: Using isolated data directory: ${PULSE_DATA_DIR}" else - export PULSE_DATA_DIR=/etc/pulse - echo "[hot-dev] Production mode: Using production config: ${PULSE_DATA_DIR}" + DEV_CONFIG_DIR="${ROOT_DIR}/tmp/dev-config" + mkdir -p "$DEV_CONFIG_DIR" + export PULSE_DATA_DIR="${DEV_CONFIG_DIR}" + + DEV_KEY_FILE="${DEV_CONFIG_DIR}/.encryption.key" + if [[ ! -f "${DEV_KEY_FILE}" ]]; then + openssl rand -hex 32 > "${DEV_KEY_FILE}" + chmod 600 "${DEV_KEY_FILE}" + echo "[hot-dev] Generated dev encryption key at ${DEV_KEY_FILE}" + fi + + # Only set the encryption key if the user hasn't provided one explicitly + if [[ -z ${PULSE_ENCRYPTION_KEY:-} ]]; then + export PULSE_ENCRYPTION_KEY="$(<"${DEV_KEY_FILE}")" + fi + + echo "[hot-dev] Production mode: Using dev config directory: ${PULSE_DATA_DIR}" fi ./pulse & diff --git a/scripts/sync-production-config.sh b/scripts/sync-production-config.sh index 64cf0c5..a257029 100755 --- a/scripts/sync-production-config.sh +++ b/scripts/sync-production-config.sh @@ -18,8 +18,10 @@ echo " Source: $PROD_DIR" echo " Target: $DEV_DIR" echo "" -# CRITICAL: Always sync production encryption key to dev -# This ensures dev can decrypt production's encrypted config files +# Track whether we have the production encryption key available +HAVE_PROD_KEY=false + +# CRITICAL: Always sync production encryption key to dev when it exists if [ -f "$PROD_DIR/.encryption.key" ]; then if [ ! -f "$DEV_DIR/.encryption.key" ]; then cp -f "$PROD_DIR/.encryption.key" "$DEV_DIR/.encryption.key" @@ -37,10 +39,25 @@ if [ -f "$PROD_DIR/.encryption.key" ]; then echo "✓ Dev encryption key matches production" fi fi + HAVE_PROD_KEY=true +else + echo "⚠ Production encryption key not found. Using dev-only key." + if [ ! -f "$DEV_DIR/.encryption.key" ]; then + # Generate a dev-only encryption key so backend can start + openssl rand -hex 32 > "$DEV_DIR/.encryption.key" + chmod 600 "$DEV_DIR/.encryption.key" + echo "✓ Generated dev encryption key at $DEV_DIR/.encryption.key" + else + echo "✓ Reusing existing dev encryption key" + fi + # Remove encrypted artifacts that rely on the missing production key + find "$DEV_DIR" -maxdepth 1 -type f -name 'nodes.enc*' -exec rm -f {} \; + rm -f "$DEV_DIR/email.enc" "$DEV_DIR/webhooks.enc" + echo "✓ Cleared encrypted production artifacts from dev config" fi # Copy nodes configuration - WITH VALIDATION -if [ -f "$PROD_DIR/nodes.enc" ]; then +if [ "$HAVE_PROD_KEY" = true ] && [ -f "$PROD_DIR/nodes.enc" ]; then # Check if production nodes.enc is valid (not corrupted) # Only sync if destination doesn't exist OR production file is newer OR dev copy is corrupted SHOULD_SYNC=false @@ -84,6 +101,11 @@ elif [ -f "$PROD_DIR/nodes.json" ]; then echo "✓ Synced nodes configuration (unencrypted)" fi +# If we had to clear encrypted nodes, ensure we start from a clean slate +if [ "$HAVE_PROD_KEY" = false ]; then + rm -f "$DEV_DIR/nodes.json" +fi + # Copy system settings (but keep dev-specific log level) if [ -f "$PROD_DIR/system.json" ]; then cp -f "$PROD_DIR/system.json" "$DEV_DIR/system.json" @@ -96,15 +118,15 @@ if [ -f "$PROD_DIR/guest_metadata.json" ]; then echo "✓ Synced guest metadata" fi -# Copy email config if it exists -if [ -f "$PROD_DIR/email.enc" ]; then +# Copy email config if it exists and we have the key +if [ "$HAVE_PROD_KEY" = true ] && [ -f "$PROD_DIR/email.enc" ]; then cp -f "$PROD_DIR/email.enc" "$DEV_DIR/email.enc" chmod 600 "$DEV_DIR/email.enc" echo "✓ Synced email configuration" fi -# Copy webhook config if it exists -if [ -f "$PROD_DIR/webhooks.enc" ]; then +# Copy webhook config if it exists and we have the key +if [ "$HAVE_PROD_KEY" = true ] && [ -f "$PROD_DIR/webhooks.enc" ]; then cp -f "$PROD_DIR/webhooks.enc" "$DEV_DIR/webhooks.enc" chmod 600 "$DEV_DIR/webhooks.enc" echo "✓ Synced webhook configuration" @@ -122,4 +144,4 @@ mkdir -p "$DEV_DIR/alerts" 2>/dev/null || true echo "" echo "✓ Production config synced to dev environment" echo " Source: $PROD_DIR" -echo " Target: $DEV_DIR" \ No newline at end of file +echo " Target: $DEV_DIR" diff --git a/scripts/toggle-mock.sh b/scripts/toggle-mock.sh index b3b42dd..214e7cd 100755 --- a/scripts/toggle-mock.sh +++ b/scripts/toggle-mock.sh @@ -12,6 +12,8 @@ NC='\033[0m' # No Color MOCK_ENV_FILE="/opt/pulse/mock.env" ROOT_DIR="/opt/pulse" BACKEND_PORT=7656 +DEV_DATA_DIR="${ROOT_DIR}/tmp/dev-config" +DEV_KEY_FILE="${DEV_DATA_DIR}/.encryption.key" # Create default mock.env if it doesn't exist if [ ! -f "$MOCK_ENV_FILE" ]; then @@ -85,7 +87,15 @@ restart_backend() { if [ "$PULSE_MOCK_MODE" = "true" ]; then export PULSE_DATA_DIR=/opt/pulse/tmp/mock-data else - export PULSE_DATA_DIR=/etc/pulse + mkdir -p "$DEV_DATA_DIR" + export PULSE_DATA_DIR="$DEV_DATA_DIR" + if [ ! -f "$DEV_KEY_FILE" ]; then + openssl rand -hex 32 > "$DEV_KEY_FILE" + chmod 600 "$DEV_KEY_FILE" + fi + if [ -z "${PULSE_ENCRYPTION_KEY:-}" ]; then + export PULSE_ENCRYPTION_KEY="$(<"$DEV_KEY_FILE")" + fi fi export PORT=$BACKEND_PORT @@ -116,8 +126,8 @@ show_status() { echo " Docker containers/host: ${PULSE_MOCK_DOCKER_CONTAINERS:-0}" echo " Data dir: ${PULSE_DATA_DIR:-/opt/pulse/tmp/mock-data}" else - echo -e "${BLUE}Mock Mode: DISABLED${NC} (using real Proxmox nodes)" - echo " Data dir: /etc/pulse" + echo -e "${BLUE}Mock Mode: DISABLED${NC}" + echo " Data dir: ${DEV_DATA_DIR}" fi } @@ -152,7 +162,7 @@ disable_mock() { sed -i 's/PULSE_MOCK_MODE=.*/PULSE_MOCK_MODE=false/' "$MOCK_ENV_FILE" touch "$MOCK_ENV_FILE" echo -e "${GREEN}✓ Mock mode disabled!${NC}" - echo "Using real Proxmox nodes" + echo "Using local dev configuration" echo "" restart_backend }