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
This commit is contained in:
rcourtman 2025-12-12 08:26:36 +00:00
parent 1a78e8846a
commit f57e45ed5f
14 changed files with 127 additions and 39 deletions

View file

@ -166,13 +166,25 @@ func main() {
dockerAgent, err = dockeragent.New(dockerCfg) dockerAgent, err = dockeragent.New(dockerCfg)
if err != nil { 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 { g.Go(func() error {
logger.Info().Msg("Docker agent module started") agent := initDockerWithRetry(ctx, dockerCfg, &logger)
return dockerAgent.Run(ctx) 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 // Mark as ready after all agents started
@ -416,3 +428,59 @@ func defaultLogLevel(envValue string) string {
} }
return envValue 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
}
}
}

View file

@ -2364,7 +2364,7 @@ const UnifiedBackups: Component = () => {
</td> </td>
</Show> </Show>
<td <td
class="hidden md:table-cell p-0.5 px-1.5 cursor-help align-middle" class="hidden md:table-cell p-0.5 px-1.5 align-middle"
onMouseEnter={(e) => { onMouseEnter={(e) => {
const details = []; const details = [];

View file

@ -278,9 +278,11 @@ export function Dashboard(props: DashboardProps) {
const [sortDirection, setSortDirection] = createSignal<'asc' | 'desc'>('asc'); const [sortDirection, setSortDirection] = createSignal<'asc' | 'desc'>('asc');
// Column visibility management // Column visibility management
// OS and IP columns are hidden by default since they require guest agent and may show dashes
const columnVisibility = useColumnVisibility( const columnVisibility = useColumnVisibility(
STORAGE_KEYS.DASHBOARD_HIDDEN_COLUMNS, 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 visibleColumns = columnVisibility.visibleColumns;
const visibleColumnIds = createMemo(() => visibleColumns().map(c => c.id)); const visibleColumnIds = createMemo(() => visibleColumns().map(c => c.id));

View file

@ -48,7 +48,7 @@ export function DiskList(props: DiskListProps) {
<Show <Show
when={props.disks && props.disks.length > 0} when={props.disks && props.disks.length > 0}
fallback={ fallback={
<span class="text-gray-400 text-sm cursor-help" title={getDiskStatusTooltip()}> <span class="text-gray-400 text-sm" title={getDiskStatusTooltip()}>
- -
</span> </span>
} }

View file

@ -53,7 +53,7 @@ export function EnhancedCPUBar(props: EnhancedCPUBarProps) {
// Progress bar mode - full width, flex centered like stacked bars // Progress bar mode - full width, flex centered like stacked bars
<div ref={containerRef} class="metric-text w-full h-4 flex items-center justify-center"> <div ref={containerRef} class="metric-text w-full h-4 flex items-center justify-center">
<div <div
class="relative w-full h-full overflow-hidden bg-gray-200 dark:bg-gray-600 rounded cursor-help" class="relative w-full h-full overflow-hidden bg-gray-200 dark:bg-gray-600 rounded"
onMouseEnter={handleMouseEnter} onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave} onMouseLeave={handleMouseLeave}
> >

View file

@ -123,7 +123,7 @@ function NetworkInfoCell(props: { ipAddresses: string[]; networkInterfaces: Gues
return ( return (
<> <>
<span <span
class="inline-flex items-center gap-1 text-xs text-gray-600 dark:text-gray-400 cursor-help" class="inline-flex items-center gap-1 text-xs text-gray-600 dark:text-gray-400"
onMouseEnter={handleMouseEnter} onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave} onMouseLeave={handleMouseLeave}
> >
@ -186,12 +186,18 @@ function NetworkInfoCell(props: { ipAddresses: string[]; networkInterfaces: Gues
{/* Fallback: just show IP list if no interface details */} {/* Fallback: just show IP list if no interface details */}
<Show when={!hasInterfaces() && props.ipAddresses.length > 0}> <Show when={!hasInterfaces() && props.ipAddresses.length > 0}>
<div class="flex flex-wrap gap-1 py-0.5"> <div class="py-1">
<For each={props.ipAddresses}> <div class="flex items-center gap-2 text-blue-400 font-medium">
{(ip) => ( <span>IP Addresses</span>
<span class="text-gray-300 font-mono">{ip}</span> <span class="text-[9px] text-gray-500 font-normal">No agent data</span>
)} </div>
</For> <div class="mt-0.5 flex flex-wrap gap-1">
<For each={props.ipAddresses}>
{(ip) => (
<span class="text-gray-300 font-mono">{ip}</span>
)}
</For>
</div>
</div> </div>
</Show> </Show>
</div> </div>
@ -267,7 +273,7 @@ function OSInfoCell(props: { osName: string; osVersion: string; agentVersion: st
return ( return (
<> <>
<span <span
class="inline-flex items-center gap-1 cursor-help" class="inline-flex items-center gap-1"
onMouseEnter={handleMouseEnter} onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave} onMouseLeave={handleMouseLeave}
> >
@ -328,7 +334,7 @@ function BackupStatusCell(props: { lastBackup: string | number | null | undefine
return ( return (
<> <>
<span <span
class={`inline-flex items-center gap-1 px-1.5 py-0.5 text-[10px] font-medium rounded cursor-help ${config().bgColor} ${config().color}`} class={`inline-flex items-center gap-1 px-1.5 py-0.5 text-[10px] font-medium rounded ${config().bgColor} ${config().color}`}
onMouseEnter={handleMouseEnter} onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave} onMouseLeave={handleMouseLeave}
> >
@ -991,7 +997,7 @@ export function GuestRow(props: GuestRowProps) {
when={hasDiskUsage()} when={hasDiskUsage()}
fallback={ fallback={
<div class="flex justify-center"> <div class="flex justify-center">
<span class="text-xs text-gray-400 cursor-help" title={getDiskStatusTooltip()}> <span class="text-xs text-gray-400" title={getDiskStatusTooltip()}>
- -
</span> </span>
</div> </div>

View file

@ -141,7 +141,7 @@ export function StackedMemoryBar(props: StackedMemoryBarProps) {
fallback={ fallback={
<div ref={containerRef} class="metric-text w-full h-4 flex items-center justify-center"> <div ref={containerRef} class="metric-text w-full h-4 flex items-center justify-center">
<div <div
class="relative w-full h-full overflow-hidden bg-gray-200 dark:bg-gray-600 rounded cursor-help" class="relative w-full h-full overflow-hidden bg-gray-200 dark:bg-gray-600 rounded"
onMouseEnter={handleMouseEnter} onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave} onMouseLeave={handleMouseLeave}
> >

View file

@ -128,7 +128,7 @@ interface DockerColumnDef extends ColumnConfig {
export const DOCKER_COLUMNS: DockerColumnDef[] = [ export const DOCKER_COLUMNS: DockerColumnDef[] = [
{ id: 'resource', label: 'Resource', priority: 'essential', minWidth: 'auto', flex: 1, sortKey: 'resource' }, { 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: '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' }, { 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) // Metric columns - need fixed width to match progress bar max-width (140px + padding)
// Note: Disk column removed - Docker API rarely provides this data // Note: Disk column removed - Docker API rarely provides this data
@ -1289,7 +1289,10 @@ const DockerContainerRow: Component<{
); );
case 'image': case 'image':
return ( return (
<div class="px-2 py-0.5 text-xs text-gray-700 dark:text-gray-300 whitespace-nowrap"> <div
class="px-2 py-0.5 text-xs text-gray-700 dark:text-gray-300 truncate max-w-[200px]"
title={container.image || undefined}
>
{container.image || '—'} {container.image || '—'}
</div> </div>
); );
@ -1403,7 +1406,7 @@ const DockerContainerRow: Component<{
"min-width": column.id === 'cpu' || column.id === 'memory' ? '140px' : undefined, "min-width": column.id === 'cpu' || column.id === 'memory' ? '140px' : undefined,
// Force resource column to respect truncation for very long names (issue #789) // Force resource column to respect truncation for very long names (issue #789)
"max-width": column.id === 'resource' ? '0' : undefined, "max-width": column.id === 'resource' ? '0' : undefined,
"width": column.id === 'resource' ? '40%' : undefined, "width": column.id === 'resource' ? '25%' : undefined,
}} }}
> >
{renderCell(column)} {renderCell(column)}
@ -2173,7 +2176,10 @@ const DockerServiceRow: Component<{
); );
case 'image': case 'image':
return ( return (
<div class="px-2 py-0.5 text-xs text-gray-700 dark:text-gray-300 whitespace-nowrap"> <div
class="px-2 py-0.5 text-xs text-gray-700 dark:text-gray-300 truncate max-w-[200px]"
title={service.image || undefined}
>
{service.image || '—'} {service.image || '—'}
</div> </div>
); );
@ -2230,7 +2236,7 @@ const DockerServiceRow: Component<{
"min-width": column.id === 'cpu' || column.id === 'memory' ? '140px' : undefined, "min-width": column.id === 'cpu' || column.id === 'memory' ? '140px' : undefined,
// Force resource column to respect truncation for very long names (issue #789) // Force resource column to respect truncation for very long names (issue #789)
"max-width": column.id === 'resource' ? '0' : undefined, "max-width": column.id === 'resource' ? '0' : undefined,
"width": column.id === 'resource' ? '40%' : undefined, "width": column.id === 'resource' ? '25%' : undefined,
}} }}
> >
{renderCell(column)} {renderCell(column)}

View file

@ -48,7 +48,7 @@ export function StackedContainerBar(props: StackedContainerBarProps) {
return ( return (
<div ref={containerRef} class="metric-text w-full h-4 flex items-center justify-center"> <div ref={containerRef} class="metric-text w-full h-4 flex items-center justify-center">
<div <div
class="relative w-full h-full overflow-hidden bg-gray-200 dark:bg-gray-600 rounded cursor-help" class="relative w-full h-full overflow-hidden bg-gray-200 dark:bg-gray-600 rounded"
onMouseEnter={handleMouseEnter} onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave} onMouseLeave={handleMouseLeave}
> >

View file

@ -89,7 +89,7 @@ function HostNetworkInfoCell(props: { networkInterfaces: NetworkInterface[] }) {
return ( return (
<> <>
<span <span
class="inline-flex items-center gap-1 text-xs text-gray-600 dark:text-gray-400 cursor-help" class="inline-flex items-center gap-1 text-xs text-gray-600 dark:text-gray-400"
onMouseEnter={handleMouseEnter} onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave} onMouseLeave={handleMouseLeave}
> >
@ -220,7 +220,7 @@ function HostTemperatureCell(props: { sensors: Record<string, number> | null | u
return ( return (
<> <>
<span <span
class={`inline-flex items-center text-xs whitespace-nowrap ${textColorClass()} ${hasSensors() ? 'cursor-help' : ''}`} class={`inline-flex items-center text-xs whitespace-nowrap ${textColorClass()}`}
onMouseEnter={handleMouseEnter} onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave} onMouseLeave={handleMouseLeave}
> >
@ -342,7 +342,7 @@ function HostRAIDStatusCell(props: HostRAIDStatusCellProps) {
return ( return (
<> <>
<span <span
class={`inline-flex items-center gap-1 text-xs whitespace-nowrap ${status().color} ${hasArrays() ? 'cursor-help' : ''}`} class={`inline-flex items-center gap-1 text-xs whitespace-nowrap ${status().color}`}
onMouseEnter={handleMouseEnter} onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave} onMouseLeave={handleMouseLeave}
> >

View file

@ -4033,7 +4033,7 @@ const Settings: Component<SettingsProps> = (props) => {
Discovery subnet Discovery subnet
</label> </label>
<span <span
class="text-gray-400 hover:text-gray-500 dark:text-gray-500 dark:hover:text-gray-300 cursor-help" class="text-gray-400 hover:text-gray-500 dark:text-gray-500 dark:hover:text-gray-300"
title="Use CIDR notation (comma-separated for multiple), e.g. 192.168.1.0/24, 10.0.0.0/24. Smaller ranges keep scans quick." title="Use CIDR notation (comma-separated for multiple), e.g. 192.168.1.0/24, 10.0.0.0/24. Smaller ranges keep scans quick."
> >
<svg <svg

View file

@ -57,7 +57,7 @@ export function EnhancedStorageBar(props: EnhancedStorageBarProps) {
return ( return (
<div ref={containerRef} class="metric-text w-full h-4 flex items-center justify-center"> <div ref={containerRef} class="metric-text w-full h-4 flex items-center justify-center">
<div <div
class="relative w-full max-w-[150px] h-full overflow-hidden bg-gray-200 dark:bg-gray-600 rounded cursor-help" class="relative w-full max-w-[150px] h-full overflow-hidden bg-gray-200 dark:bg-gray-600 rounded"
onMouseEnter={handleMouseEnter} onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave} onMouseLeave={handleMouseLeave}
> >

View file

@ -46,7 +46,7 @@ export const ZFSHealthMap: Component<ZFSHealthMapProps> = (props) => {
<For each={devices()}> <For each={devices()}>
{(device) => ( {(device) => (
<div <div
class={`w-2.5 h-3 rounded-sm cursor-help transition-colors duration-200 ${getDeviceColor(device)} ${isResilvering(device) ? 'animate-pulse' : ''}`} class={`w-2.5 h-3 rounded-sm transition-colors duration-200 ${getDeviceColor(device)} ${isResilvering(device) ? 'animate-pulse' : ''}`}
onMouseEnter={(e) => handleMouseEnter(e, device)} onMouseEnter={(e) => handleMouseEnter(e, device)}
onMouseLeave={handleMouseLeave} onMouseLeave={handleMouseLeave}
/> />
@ -72,8 +72,8 @@ export const ZFSHealthMap: Component<ZFSHealthMapProps> = (props) => {
</div> </div>
<div class="flex items-center gap-2 border-t border-gray-700/50 pt-1"> <div class="flex items-center gap-2 border-t border-gray-700/50 pt-1">
<span class={`font-semibold ${hoveredDevice()?.state === 'ONLINE' ? 'text-green-400' : <span class={`font-semibold ${hoveredDevice()?.state === 'ONLINE' ? 'text-green-400' :
hoveredDevice()?.state === 'DEGRADED' ? 'text-yellow-400' : hoveredDevice()?.state === 'DEGRADED' ? 'text-yellow-400' :
'text-red-400' 'text-red-400'
}`}> }`}>
{hoveredDevice()?.state} {hoveredDevice()?.state}
</span> </span>

View file

@ -30,20 +30,26 @@ function breakpointIndex(bp: Breakpoint): number {
* *
* @param storageKey - localStorage key for persisting user preferences * @param storageKey - localStorage key for persisting user preferences
* @param columns - Array of column definitions * @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( export function useColumnVisibility(
storageKey: string, storageKey: string,
columns: ColumnDef[] columns: ColumnDef[],
defaultHidden: string[] = []
) { ) {
const { breakpoint } = useBreakpoint(); const { breakpoint } = useBreakpoint();
// Get list of toggleable column IDs // Get list of toggleable column IDs
const toggleableIds = columns.filter(c => c.toggleable).map(c => c.id); 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 // Persist hidden columns to localStorage
// Use defaultHidden only if no user preference exists yet
const [hiddenColumns, setHiddenColumns] = usePersistentSignal<string[]>( const [hiddenColumns, setHiddenColumns] = usePersistentSignal<string[]>(
storageKey, storageKey,
[], hasUserPreference ? [] : defaultHidden,
{ {
serialize: (arr) => JSON.stringify(arr), serialize: (arr) => JSON.stringify(arr),
deserialize: (str) => { deserialize: (str) => {
@ -90,9 +96,9 @@ export function useColumnVisibility(
} }
}; };
// Reset to defaults (show all) // Reset to defaults (restore default hidden columns)
const resetToDefaults = () => { const resetToDefaults = () => {
setHiddenColumns([]); setHiddenColumns(defaultHidden);
}; };
// Compute visible columns based on breakpoint and user preferences // Compute visible columns based on breakpoint and user preferences