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)
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
}
}
}

View file

@ -2364,7 +2364,7 @@ const UnifiedBackups: Component = () => {
</td>
</Show>
<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) => {
const details = [];

View file

@ -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));

View file

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

View file

@ -53,7 +53,7 @@ export function EnhancedCPUBar(props: EnhancedCPUBarProps) {
// 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
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}
onMouseLeave={handleMouseLeave}
>

View file

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

View file

@ -141,7 +141,7 @@ export function StackedMemoryBar(props: StackedMemoryBarProps) {
fallback={
<div ref={containerRef} class="metric-text w-full h-4 flex items-center justify-center">
<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}
onMouseLeave={handleMouseLeave}
>

View file

@ -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 (
<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 || '—'}
</div>
);
@ -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 (
<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 || '—'}
</div>
);
@ -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)}

View file

@ -48,7 +48,7 @@ export function StackedContainerBar(props: StackedContainerBarProps) {
return (
<div ref={containerRef} class="metric-text w-full h-4 flex items-center justify-center">
<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}
onMouseLeave={handleMouseLeave}
>

View file

@ -89,7 +89,7 @@ function HostNetworkInfoCell(props: { networkInterfaces: NetworkInterface[] }) {
return (
<>
<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}
onMouseLeave={handleMouseLeave}
>
@ -220,7 +220,7 @@ function HostTemperatureCell(props: { sensors: Record<string, number> | null | u
return (
<>
<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}
onMouseLeave={handleMouseLeave}
>
@ -342,7 +342,7 @@ function HostRAIDStatusCell(props: HostRAIDStatusCellProps) {
return (
<>
<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}
onMouseLeave={handleMouseLeave}
>

View file

@ -4033,7 +4033,7 @@ const Settings: Component<SettingsProps> = (props) => {
Discovery subnet
</label>
<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."
>
<svg

View file

@ -57,7 +57,7 @@ export function EnhancedStorageBar(props: EnhancedStorageBarProps) {
return (
<div ref={containerRef} class="metric-text w-full h-4 flex items-center justify-center">
<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}
onMouseLeave={handleMouseLeave}
>

View file

@ -46,7 +46,7 @@ export const ZFSHealthMap: Component<ZFSHealthMapProps> = (props) => {
<For each={devices()}>
{(device) => (
<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)}
onMouseLeave={handleMouseLeave}
/>
@ -72,8 +72,8 @@ export const ZFSHealthMap: Component<ZFSHealthMapProps> = (props) => {
</div>
<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' :
hoveredDevice()?.state === 'DEGRADED' ? 'text-yellow-400' :
'text-red-400'
hoveredDevice()?.state === 'DEGRADED' ? 'text-yellow-400' :
'text-red-400'
}`}>
{hoveredDevice()?.state}
</span>

View file

@ -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<string[]>(
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