From f57e45ed5ffc4ef4d4b9ca261d310f862389a1f7 Mon Sep 17 00:00:00 2001 From: rcourtman Date: Fri, 12 Dec 2025 08:26:36 +0000 Subject: [PATCH] feat: Docker agent retry, UI column improvements, and IP tooltip enhancements - Add exponential backoff retry for Docker agent startup (main.go) - Fix Docker resource/image column widths with proper truncation - Unify IP tooltip styling across hosts and guests with detailed network info - Improve column visibility defaults and sticky column handling - Various component refinements for Dashboard, Storage, and Backups views --- cmd/pulse-agent/main.go | 80 +++++++++++++++++-- .../src/components/Backups/UnifiedBackups.tsx | 2 +- .../src/components/Dashboard/Dashboard.tsx | 4 +- .../src/components/Dashboard/DiskList.tsx | 2 +- .../components/Dashboard/EnhancedCPUBar.tsx | 2 +- .../src/components/Dashboard/GuestRow.tsx | 26 +++--- .../components/Dashboard/StackedMemoryBar.tsx | 2 +- .../components/Docker/DockerUnifiedTable.tsx | 16 ++-- .../components/Docker/StackedContainerBar.tsx | 2 +- .../src/components/Hosts/HostsOverview.tsx | 6 +- .../src/components/Settings/Settings.tsx | 2 +- .../components/Storage/EnhancedStorageBar.tsx | 2 +- .../src/components/Storage/ZFSHealthMap.tsx | 6 +- .../src/hooks/useColumnVisibility.ts | 14 +++- 14 files changed, 127 insertions(+), 39 deletions(-) diff --git a/cmd/pulse-agent/main.go b/cmd/pulse-agent/main.go index b9c90f6..7a20cb8 100644 --- a/cmd/pulse-agent/main.go +++ b/cmd/pulse-agent/main.go @@ -166,13 +166,25 @@ func main() { dockerAgent, err = dockeragent.New(dockerCfg) if err != nil { - logger.Fatal().Err(err).Msg("Failed to initialize docker agent") - } + // Docker isn't available yet - start retry loop in background + logger.Warn().Err(err).Msg("Docker not available, will retry with exponential backoff") - g.Go(func() error { - logger.Info().Msg("Docker agent module started") - return dockerAgent.Run(ctx) - }) + g.Go(func() error { + agent := initDockerWithRetry(ctx, dockerCfg, &logger) + if agent != nil { + dockerAgent = agent + logger.Info().Msg("Docker agent module started (after retry)") + return agent.Run(ctx) + } + // Docker never became available, continue without it + return nil + }) + } else { + g.Go(func() error { + logger.Info().Msg("Docker agent module started") + return dockerAgent.Run(ctx) + }) + } } // Mark as ready after all agents started @@ -416,3 +428,59 @@ func defaultLogLevel(envValue string) string { } return envValue } + +// initDockerWithRetry attempts to initialize the Docker agent with exponential backoff. +// It returns the agent when Docker becomes available, or nil if the context is cancelled. +// Retry intervals: 5s, 10s, 20s, 40s, 80s, 160s, then cap at 5 minutes. +func initDockerWithRetry(ctx context.Context, cfg dockeragent.Config, logger *zerolog.Logger) *dockeragent.Agent { + const ( + initialDelay = 5 * time.Second + maxDelay = 5 * time.Minute + multiplier = 2.0 + ) + + delay := initialDelay + attempt := 0 + + for { + select { + case <-ctx.Done(): + logger.Info().Msg("Docker retry cancelled, context done") + return nil + default: + } + + attempt++ + logger.Info(). + Int("attempt", attempt). + Str("next_retry", delay.String()). + Msg("Waiting to retry Docker connection") + + select { + case <-ctx.Done(): + logger.Info().Msg("Docker retry cancelled during wait") + return nil + case <-time.After(delay): + } + + agent, err := dockeragent.New(cfg) + if err == nil { + logger.Info(). + Int("attempts", attempt). + Msg("Successfully connected to Docker after retry") + return agent + } + + logger.Warn(). + Err(err). + Int("attempt", attempt). + Str("next_retry", (time.Duration(float64(delay) * multiplier)).String()). + Msg("Docker still not available, will retry") + + // Calculate next delay with exponential backoff, capped at maxDelay + delay = time.Duration(float64(delay) * multiplier) + if delay > maxDelay { + delay = maxDelay + } + } +} diff --git a/frontend-modern/src/components/Backups/UnifiedBackups.tsx b/frontend-modern/src/components/Backups/UnifiedBackups.tsx index e8f4bf2..64e18da 100644 --- a/frontend-modern/src/components/Backups/UnifiedBackups.tsx +++ b/frontend-modern/src/components/Backups/UnifiedBackups.tsx @@ -2364,7 +2364,7 @@ const UnifiedBackups: Component = () => { { const details = []; diff --git a/frontend-modern/src/components/Dashboard/Dashboard.tsx b/frontend-modern/src/components/Dashboard/Dashboard.tsx index d4c60ed..6d9e07e 100644 --- a/frontend-modern/src/components/Dashboard/Dashboard.tsx +++ b/frontend-modern/src/components/Dashboard/Dashboard.tsx @@ -278,9 +278,11 @@ export function Dashboard(props: DashboardProps) { const [sortDirection, setSortDirection] = createSignal<'asc' | 'desc'>('asc'); // Column visibility management + // OS and IP columns are hidden by default since they require guest agent and may show dashes const columnVisibility = useColumnVisibility( STORAGE_KEYS.DASHBOARD_HIDDEN_COLUMNS, - GUEST_COLUMNS as GuestColumnDef[] + GUEST_COLUMNS as GuestColumnDef[], + ['os', 'ip'] // Default hidden columns for cleaner first-run experience ); const visibleColumns = columnVisibility.visibleColumns; const visibleColumnIds = createMemo(() => visibleColumns().map(c => c.id)); diff --git a/frontend-modern/src/components/Dashboard/DiskList.tsx b/frontend-modern/src/components/Dashboard/DiskList.tsx index 042d8d1..1ae5cc3 100644 --- a/frontend-modern/src/components/Dashboard/DiskList.tsx +++ b/frontend-modern/src/components/Dashboard/DiskList.tsx @@ -48,7 +48,7 @@ export function DiskList(props: DiskListProps) { 0} fallback={ - + - } diff --git a/frontend-modern/src/components/Dashboard/EnhancedCPUBar.tsx b/frontend-modern/src/components/Dashboard/EnhancedCPUBar.tsx index 3606625..4b9fe2c 100644 --- a/frontend-modern/src/components/Dashboard/EnhancedCPUBar.tsx +++ b/frontend-modern/src/components/Dashboard/EnhancedCPUBar.tsx @@ -53,7 +53,7 @@ export function EnhancedCPUBar(props: EnhancedCPUBarProps) { // Progress bar mode - full width, flex centered like stacked bars
diff --git a/frontend-modern/src/components/Dashboard/GuestRow.tsx b/frontend-modern/src/components/Dashboard/GuestRow.tsx index adf4bdd..73fc590 100644 --- a/frontend-modern/src/components/Dashboard/GuestRow.tsx +++ b/frontend-modern/src/components/Dashboard/GuestRow.tsx @@ -123,7 +123,7 @@ function NetworkInfoCell(props: { ipAddresses: string[]; networkInterfaces: Gues return ( <> @@ -186,12 +186,18 @@ function NetworkInfoCell(props: { ipAddresses: string[]; networkInterfaces: Gues {/* Fallback: just show IP list if no interface details */} 0}> -
- - {(ip) => ( - {ip} - )} - +
+
+ IP Addresses + No agent data +
+
+ + {(ip) => ( + {ip} + )} + +
@@ -267,7 +273,7 @@ function OSInfoCell(props: { osName: string; osVersion: string; agentVersion: st return ( <> @@ -328,7 +334,7 @@ function BackupStatusCell(props: { lastBackup: string | number | null | undefine return ( <> @@ -991,7 +997,7 @@ export function GuestRow(props: GuestRowProps) { when={hasDiskUsage()} fallback={
- + -
diff --git a/frontend-modern/src/components/Dashboard/StackedMemoryBar.tsx b/frontend-modern/src/components/Dashboard/StackedMemoryBar.tsx index 1593d5f..36e2d81 100644 --- a/frontend-modern/src/components/Dashboard/StackedMemoryBar.tsx +++ b/frontend-modern/src/components/Dashboard/StackedMemoryBar.tsx @@ -141,7 +141,7 @@ export function StackedMemoryBar(props: StackedMemoryBarProps) { fallback={
diff --git a/frontend-modern/src/components/Docker/DockerUnifiedTable.tsx b/frontend-modern/src/components/Docker/DockerUnifiedTable.tsx index 87dad0f..fd8d862 100644 --- a/frontend-modern/src/components/Docker/DockerUnifiedTable.tsx +++ b/frontend-modern/src/components/Docker/DockerUnifiedTable.tsx @@ -128,7 +128,7 @@ interface DockerColumnDef extends ColumnConfig { export const DOCKER_COLUMNS: DockerColumnDef[] = [ { id: 'resource', label: 'Resource', priority: 'essential', minWidth: 'auto', flex: 1, sortKey: 'resource' }, { id: 'type', label: 'Type', priority: 'essential', minWidth: 'auto', maxWidth: 'auto', sortKey: 'type' }, - { id: 'image', label: 'Image / Stack', priority: 'essential', minWidth: 'auto', maxWidth: 'auto', sortKey: 'image' }, + { id: 'image', label: 'Image / Stack', priority: 'essential', minWidth: '80px', maxWidth: '200px', sortKey: 'image' }, { id: 'status', label: 'Status', priority: 'essential', minWidth: 'auto', maxWidth: 'auto', sortKey: 'status' }, // Metric columns - need fixed width to match progress bar max-width (140px + padding) // Note: Disk column removed - Docker API rarely provides this data @@ -1289,7 +1289,10 @@ const DockerContainerRow: Component<{ ); case 'image': return ( -
+
{container.image || '—'}
); @@ -1403,7 +1406,7 @@ const DockerContainerRow: Component<{ "min-width": column.id === 'cpu' || column.id === 'memory' ? '140px' : undefined, // Force resource column to respect truncation for very long names (issue #789) "max-width": column.id === 'resource' ? '0' : undefined, - "width": column.id === 'resource' ? '40%' : undefined, + "width": column.id === 'resource' ? '25%' : undefined, }} > {renderCell(column)} @@ -2173,7 +2176,10 @@ const DockerServiceRow: Component<{ ); case 'image': return ( -
+
{service.image || '—'}
); @@ -2230,7 +2236,7 @@ const DockerServiceRow: Component<{ "min-width": column.id === 'cpu' || column.id === 'memory' ? '140px' : undefined, // Force resource column to respect truncation for very long names (issue #789) "max-width": column.id === 'resource' ? '0' : undefined, - "width": column.id === 'resource' ? '40%' : undefined, + "width": column.id === 'resource' ? '25%' : undefined, }} > {renderCell(column)} diff --git a/frontend-modern/src/components/Docker/StackedContainerBar.tsx b/frontend-modern/src/components/Docker/StackedContainerBar.tsx index 690abf5..fbedf3a 100644 --- a/frontend-modern/src/components/Docker/StackedContainerBar.tsx +++ b/frontend-modern/src/components/Docker/StackedContainerBar.tsx @@ -48,7 +48,7 @@ export function StackedContainerBar(props: StackedContainerBarProps) { return (
diff --git a/frontend-modern/src/components/Hosts/HostsOverview.tsx b/frontend-modern/src/components/Hosts/HostsOverview.tsx index eb08cf4..844d4bf 100644 --- a/frontend-modern/src/components/Hosts/HostsOverview.tsx +++ b/frontend-modern/src/components/Hosts/HostsOverview.tsx @@ -89,7 +89,7 @@ function HostNetworkInfoCell(props: { networkInterfaces: NetworkInterface[] }) { return ( <> @@ -220,7 +220,7 @@ function HostTemperatureCell(props: { sensors: Record | null | u return ( <> @@ -342,7 +342,7 @@ function HostRAIDStatusCell(props: HostRAIDStatusCellProps) { return ( <> diff --git a/frontend-modern/src/components/Settings/Settings.tsx b/frontend-modern/src/components/Settings/Settings.tsx index 316c98d..d8fddc1 100644 --- a/frontend-modern/src/components/Settings/Settings.tsx +++ b/frontend-modern/src/components/Settings/Settings.tsx @@ -4033,7 +4033,7 @@ const Settings: Component = (props) => { Discovery subnet
diff --git a/frontend-modern/src/components/Storage/ZFSHealthMap.tsx b/frontend-modern/src/components/Storage/ZFSHealthMap.tsx index 03417d5..20f7d39 100644 --- a/frontend-modern/src/components/Storage/ZFSHealthMap.tsx +++ b/frontend-modern/src/components/Storage/ZFSHealthMap.tsx @@ -46,7 +46,7 @@ export const ZFSHealthMap: Component = (props) => { {(device) => (
handleMouseEnter(e, device)} onMouseLeave={handleMouseLeave} /> @@ -72,8 +72,8 @@ export const ZFSHealthMap: Component = (props) => {
{hoveredDevice()?.state} diff --git a/frontend-modern/src/hooks/useColumnVisibility.ts b/frontend-modern/src/hooks/useColumnVisibility.ts index eecf6c1..0a23c78 100644 --- a/frontend-modern/src/hooks/useColumnVisibility.ts +++ b/frontend-modern/src/hooks/useColumnVisibility.ts @@ -30,20 +30,26 @@ function breakpointIndex(bp: Breakpoint): number { * * @param storageKey - localStorage key for persisting user preferences * @param columns - Array of column definitions + * @param defaultHidden - Optional array of column IDs to hide by default (only used if no user preference exists) */ export function useColumnVisibility( storageKey: string, - columns: ColumnDef[] + columns: ColumnDef[], + defaultHidden: string[] = [] ) { const { breakpoint } = useBreakpoint(); // Get list of toggleable column IDs const toggleableIds = columns.filter(c => c.toggleable).map(c => c.id); + // Check if user has any saved preference + const hasUserPreference = typeof window !== 'undefined' && window.localStorage.getItem(storageKey) !== null; + // Persist hidden columns to localStorage + // Use defaultHidden only if no user preference exists yet const [hiddenColumns, setHiddenColumns] = usePersistentSignal( storageKey, - [], + hasUserPreference ? [] : defaultHidden, { serialize: (arr) => JSON.stringify(arr), deserialize: (str) => { @@ -90,9 +96,9 @@ export function useColumnVisibility( } }; - // Reset to defaults (show all) + // Reset to defaults (restore default hidden columns) const resetToDefaults = () => { - setHiddenColumns([]); + setHiddenColumns(defaultHidden); }; // Compute visible columns based on breakpoint and user preferences