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:
parent
1a78e8846a
commit
f57e45ed5f
14 changed files with 127 additions and 39 deletions
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 = [];
|
||||
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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)}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue