diff --git a/frontend-modern/src/api/dockerHostMetadata.ts b/frontend-modern/src/api/dockerHostMetadata.ts new file mode 100644 index 0000000..8546634 --- /dev/null +++ b/frontend-modern/src/api/dockerHostMetadata.ts @@ -0,0 +1,40 @@ +// Docker Host Metadata API - for managing custom URLs on Docker hosts +import { apiFetchJSON } from '@/utils/apiClient'; + +export interface DockerHostMetadata { + customDisplayName?: string; + customUrl?: string; + notes?: string[]; +} + +export class DockerHostMetadataAPI { + private static baseUrl = '/api/docker/hosts/metadata'; + + // Get metadata for a specific Docker host + static async getMetadata(hostId: string): Promise { + return apiFetchJSON(`${this.baseUrl}/${encodeURIComponent(hostId)}`); + } + + // Get all Docker host metadata + static async getAllMetadata(): Promise> { + return apiFetchJSON(this.baseUrl); + } + + // Update metadata for a Docker host + static async updateMetadata( + hostId: string, + metadata: Partial, + ): Promise { + return apiFetchJSON(`${this.baseUrl}/${encodeURIComponent(hostId)}`, { + method: 'PUT', + body: JSON.stringify(metadata), + }); + } + + // Delete metadata for a Docker host + static async deleteMetadata(hostId: string): Promise { + await apiFetchJSON(`${this.baseUrl}/${encodeURIComponent(hostId)}`, { + method: 'DELETE', + }); + } +} diff --git a/frontend-modern/src/components/Dashboard/GuestRow.tsx b/frontend-modern/src/components/Dashboard/GuestRow.tsx index 0b3fe65..b608a10 100644 --- a/frontend-modern/src/components/Dashboard/GuestRow.tsx +++ b/frontend-modern/src/components/Dashboard/GuestRow.tsx @@ -10,6 +10,7 @@ import { StackedMemoryBar } from './StackedMemoryBar'; import { StatusDot } from '@/components/shared/StatusDot'; import { getGuestPowerIndicator, isGuestRunning } from '@/utils/status'; import { GuestMetadataAPI } from '@/api/guestMetadata'; +import { UrlEditPopover, createUrlEditState } from '@/components/shared/UrlEditPopover'; import { showSuccess, showError } from '@/utils/toast'; import { logger } from '@/utils/logger'; import { buildMetricKey } from '@/utils/metricsKeys'; @@ -516,28 +517,17 @@ export function GuestRow(props: GuestRowProps) { const [customUrl, setCustomUrl] = createSignal(props.customUrl); const [shouldAnimateIcon, setShouldAnimateIcon] = createSignal(false); - const [isEditingUrl, setIsEditingUrl] = createSignal(false); - const [editingUrlValue, setEditingUrlValue] = createSignal(''); - const [isSavingUrl, setIsSavingUrl] = createSignal(false); - let urlInputRef: HTMLInputElement | undefined; - // Focus input when editing starts - createEffect(() => { - if (isEditingUrl() && urlInputRef) { - urlInputRef.focus(); - urlInputRef.select(); - } - }); + // URL editing using shared hook + const urlEdit = createUrlEditState(); - const startEditingUrl = (event: MouseEvent) => { - event.stopPropagation(); - setEditingUrlValue(customUrl() || ''); - setIsEditingUrl(true); + const handleStartEditingUrl = (event: MouseEvent) => { + urlEdit.startEditing(guestId(), customUrl() || '', event); }; - const saveUrl = async () => { - const newUrl = editingUrlValue().trim(); - setIsSavingUrl(true); + const handleSaveUrl = async () => { + const newUrl = urlEdit.editingValue().trim(); + urlEdit.setIsSaving(true); try { await GuestMetadataAPI.updateMetadata(guestId(), { customUrl: newUrl }); @@ -549,7 +539,6 @@ export function GuestRow(props: GuestRowProps) { } setCustomUrl(newUrl || undefined); - setIsEditingUrl(false); if (props.onCustomUrlUpdate) { props.onCustomUrlUpdate(guestId(), newUrl); @@ -560,18 +549,36 @@ export function GuestRow(props: GuestRowProps) { } else { showSuccess('Guest URL cleared'); } + + urlEdit.finishEditing(); } catch (err: unknown) { const message = err instanceof Error ? err.message : 'Failed to save guest URL'; logger.error('Failed to save guest URL:', err); showError(message); - } finally { - setIsSavingUrl(false); + urlEdit.setIsSaving(false); } }; - const cancelEditingUrl = () => { - setIsEditingUrl(false); - setEditingUrlValue(''); + const handleDeleteUrl = async () => { + urlEdit.setIsSaving(true); + + try { + await GuestMetadataAPI.updateMetadata(guestId(), { customUrl: '' }); + + setCustomUrl(undefined); + + if (props.onCustomUrlUpdate) { + props.onCustomUrlUpdate(guestId(), ''); + } + + showSuccess('Guest URL removed'); + urlEdit.finishEditing(); + } catch (err: unknown) { + const message = err instanceof Error ? err.message : 'Failed to remove guest URL'; + logger.error('Failed to remove guest URL:', err); + showError(message); + urlEdit.setIsSaving(false); + } }; const ipAddresses = createMemo(() => props.guest.ipAddresses ?? []); @@ -776,423 +783,388 @@ export function GuestRow(props: GuestRowProps) { }; return ( - - {/* Name - always visible */} - -
-
- - - + + {/* Name - always visible */} + +
+
+ +
+ + {props.guest.name} + + + event.stopPropagation()} > - {props.guest.name} - - - event.stopPropagation()} + - - - - - - {/* Edit URL button - shows on hover */} - - {/* Show backup indicator in name cell only if backup column is hidden */} - - - - {/* AI context indicator - shows when row is selected for AI */} - - - - - - - + + + {/* Edit URL button - shows on hover */} + + {/* Show backup indicator in name cell only if backup column is hidden */} + + + + {/* AI context indicator - shows when row is selected for AI */} + + + + + + + +
+
+ + + + + + Lock: {lockLabel()} + + +
+ + + {/* Type */} + + +
+ + {isVM(props.guest) ? 'VM' : isOCIContainer() ? 'OCI' : 'LXC'} + +
+ +
+ + {/* VMID */} + + +
+ {props.guest.vmid} +
+ +
+ + {/* CPU */} + + + +
+ +
+
+ + +
+ + {/* Memory */} + + +
+ +
+ +
+
+ +
+ +
+ + {/* Disk */} + + + + + - +
} > - {/* URL editing mode */} -
- setEditingUrlValue(e.currentTarget.value)} - onKeyDown={(e) => { - if (e.key === 'Enter') { - e.preventDefault(); - saveUrl(); - } else if (e.key === 'Escape') { - e.preventDefault(); - cancelEditingUrl(); - } - }} - onClick={(e) => e.stopPropagation()} - placeholder="https://192.168.1.100:8080" - class="w-40 px-2 py-0.5 text-xs border border-blue-500 rounded bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-1 focus:ring-blue-500" - disabled={isSavingUrl()} - /> - - -
- -
- - - - - - Lock: {lockLabel()} - - - - - - {/* Type */} - - -
- - {isVM(props.guest) ? 'VM' : isOCIContainer() ? 'OCI' : 'LXC'} - -
- -
- - {/* VMID */} - - -
- {props.guest.vmid} -
- -
- - {/* CPU */} - - - -
- -
-
- - -
- - {/* Memory */} - - -
- -
- -
-
- -
- -
- - {/* Disk */} - - - - - - - - - } - > - -
- {formatPercent(diskPercent())} -
-
-
- - } - > - - -
-
- -
- - {/* IP Address with Network Tooltip */} - - -
- 0 || hasNetworkInterfaces()} fallback={-}> - - -
- -
- - {/* Uptime */} - - -
- - - - {formatUptime(props.guest.uptime, true)} - - - -
- -
- - {/* Node - NEW */} - - -
- - {props.guest.node} - -
- -
- - {/* Backup Status */} - - -
- - - - - - - -
- -
- - {/* Tags */} - - -
event.stopPropagation()}> - -
- -
- - {/* OS */} - - -
- -} + when={viewMode() === 'sparklines'} + fallback={ + + } > - {/* For OCI containers without guest agent, show image name in OS column */} - - {ociImage()} - + - } - > - + + + + + {/* IP Address with Network Tooltip */} + + +
+ 0 || hasNetworkInterfaces()} fallback={-}> + + +
+ +
+ + {/* Uptime */} + + +
+ + + + {formatUptime(props.guest.uptime, true)} + + + +
+ +
+ + {/* Node - NEW */} + + +
+ + {props.guest.node} + +
+ +
+ + {/* Backup Status */} + + +
+ + + + + - + +
+ +
+ + {/* Tags */} + + +
event.stopPropagation()}> + - -
- -
+
+ +
- {/* Disk Read */} - - -
- -}> - {formatSpeed(diskRead())} - -
- -
+ {/* OS */} + + +
+ -} + > + {/* For OCI containers without guest agent, show image name in OS column */} + + {ociImage()} + + + } + > + + +
+ +
- {/* Disk Write */} - - -
- -}> - {formatSpeed(diskWrite())} - -
- -
+ {/* Disk Read */} + + +
+ -}> + {formatSpeed(diskRead())} + +
+ +
- {/* Net In */} - - -
- -}> - {formatSpeed(networkIn())} - -
- -
+ {/* Disk Write */} + + +
+ -}> + {formatSpeed(diskWrite())} + +
+ +
- {/* Net Out */} - - -
- -}> - {formatSpeed(networkOut())} - -
- -
- + {/* Net In */} + + +
+ -}> + {formatSpeed(networkIn())} + +
+ +
+ + {/* Net Out */} + + +
+ -}> + {formatSpeed(networkOut())} + +
+ +
+ + + {/* URL editing popover - using shared component */} + + ); } diff --git a/frontend-modern/src/components/Docker/DockerHostSummaryTable.tsx b/frontend-modern/src/components/Docker/DockerHostSummaryTable.tsx index 43bc62a..cfe1dbc 100644 --- a/frontend-modern/src/components/Docker/DockerHostSummaryTable.tsx +++ b/frontend-modern/src/components/Docker/DockerHostSummaryTable.tsx @@ -13,6 +13,10 @@ import { useBreakpoint } from '@/hooks/useBreakpoint'; import { ResponsiveMetricCell, MetricText } from '@/components/shared/responsive'; import { EnhancedCPUBar } from '@/components/Dashboard/EnhancedCPUBar'; import { isAgentOutdated, getAgentVersionTooltip } from '@/utils/agentVersion'; +import { DockerHostMetadataAPI, type DockerHostMetadata } from '@/api/dockerHostMetadata'; +import { UrlEditPopover, createUrlEditState } from '@/components/shared/UrlEditPopover'; +import { showSuccess, showError } from '@/utils/toast'; +import { logger } from '@/utils/logger'; export interface DockerHostSummary { host: DockerHost; @@ -35,6 +39,8 @@ interface DockerHostSummaryTableProps { summaries: () => DockerHostSummary[]; selectedHostId: () => string | null; onSelect: (hostId: string) => void; + dockerHostMetadata?: Record; + onHostCustomUrlUpdate?: (hostId: string, url: string) => void; } type SortKey = 'name' | 'uptime' | 'cpu' | 'memory' | 'disk' | 'running' | 'lastSeen' | 'agent'; @@ -55,6 +61,9 @@ export const DockerHostSummaryTable: Component = (p const [sortDirection, setSortDirection] = createSignal('asc'); const { isMobile } = useBreakpoint(); + // URL editing state using shared hook + const urlEdit = createUrlEditState(); + const handleSort = (key: SortKey) => { if (sortKey() === key) { setSortDirection(sortDirection() === 'asc' ? 'desc' : 'asc'); @@ -125,6 +134,68 @@ export const DockerHostSummaryTable: Component = (p return list; }); + // URL editing functions + const getHostCustomUrl = (hostId: string) => { + return props.dockerHostMetadata?.[hostId]?.customUrl; + }; + + const handleStartEditingUrl = (hostId: string, event: MouseEvent) => { + const currentUrl = getHostCustomUrl(hostId) || ''; + urlEdit.startEditing(hostId, currentUrl, event); + }; + + const handleSaveUrl = async () => { + const hostId = urlEdit.editingId(); + if (!hostId) return; + + const newUrl = urlEdit.editingValue().trim(); + urlEdit.setIsSaving(true); + + try { + await DockerHostMetadataAPI.updateMetadata(hostId, { customUrl: newUrl }); + + if (props.onHostCustomUrlUpdate) { + props.onHostCustomUrlUpdate(hostId, newUrl); + } + + if (newUrl) { + showSuccess('Host URL saved'); + } else { + showSuccess('Host URL cleared'); + } + + urlEdit.finishEditing(); + } catch (err: unknown) { + const message = err instanceof Error ? err.message : 'Failed to save host URL'; + logger.error('Failed to save host URL:', err); + showError(message); + urlEdit.setIsSaving(false); + } + }; + + const handleDeleteUrl = async () => { + const hostId = urlEdit.editingId(); + if (!hostId) return; + + urlEdit.setIsSaving(true); + + try { + await DockerHostMetadataAPI.updateMetadata(hostId, { customUrl: '' }); + + if (props.onHostCustomUrlUpdate) { + props.onHostCustomUrlUpdate(hostId, ''); + } + + showSuccess('Host URL removed'); + urlEdit.finishEditing(); + } catch (err: unknown) { + const message = err instanceof Error ? err.message : 'Failed to remove host URL'; + logger.error('Failed to remove host URL:', err); + showError(message); + urlEdit.setIsSaving(false); + } + }; + const renderSortIndicator = (key: SortKey) => { if (sortKey() !== key) return null; return sortDirection() === 'asc' ? '▲' : '▼'; @@ -133,261 +204,308 @@ export const DockerHostSummaryTable: Component = (p // Agent version checking is now done via the shared utility that compares against server version return ( - - - - - - - - - - - - - - - - - - - {(summary) => { - const selected = props.selectedHostId() === summary.host.id; - const online = isHostOnline(summary.host); - const uptimeLabel = summary.uptimeSeconds ? formatUptime(summary.uptimeSeconds) : '—'; + <> + + +
handleSort('name')} - onKeyDown={(e) => e.key === 'Enter' && handleSort('name')} - tabIndex={0} - role="button" - aria-label={`Sort by host ${sortKey() === 'name' ? (sortDirection() === 'asc' ? 'ascending' : 'descending') : ''}`} - > - Host {renderSortIndicator('name')} - - Status - handleSort('cpu')} - > - CPU {renderSortIndicator('cpu')} - handleSort('memory')} - > - Memory {renderSortIndicator('memory')} - handleSort('disk')} - > - Disk {renderSortIndicator('disk')} - handleSort('running')} - > - Containers {renderSortIndicator('running')} - handleSort('uptime')} - > - Uptime {renderSortIndicator('uptime')} - handleSort('lastSeen')} - > - Last Update {renderSortIndicator('lastSeen')} - handleSort('agent')} - > - Agent {renderSortIndicator('agent')} -
+ + + + + + + + + + + + + + + + {(summary) => { + const selected = props.selectedHostId() === summary.host.id; + const online = isHostOnline(summary.host); + const uptimeLabel = summary.uptimeSeconds ? formatUptime(summary.uptimeSeconds) : '—'; - const rowStyle = () => { - const styles: Record = {}; - const shadows: string[] = []; + const rowStyle = () => { + const styles: Record = {}; + const shadows: string[] = []; - if (selected) { - shadows.push('0 0 0 1px rgba(59, 130, 246, 0.5)'); - shadows.push('0 2px 4px -1px rgba(0, 0, 0, 0.1)'); - } + if (selected) { + shadows.push('0 0 0 1px rgba(59, 130, 246, 0.5)'); + shadows.push('0 2px 4px -1px rgba(0, 0, 0, 0.1)'); + } - if (shadows.length > 0) { - styles['box-shadow'] = shadows.join(', '); - } - return styles; - }; + if (shadows.length > 0) { + styles['box-shadow'] = shadows.join(', '); + } + return styles; + }; - const rowClass = () => { - const baseHover = 'cursor-pointer transition-all duration-200 relative hover:bg-gray-50 dark:hover:bg-gray-700/50 hover:shadow-sm'; + const rowClass = () => { + const baseHover = 'group cursor-pointer transition-all duration-200 relative hover:bg-gray-50 dark:hover:bg-gray-700/50 hover:shadow-sm'; - if (selected) { - return 'cursor-pointer transition-all duration-200 relative bg-blue-50 dark:bg-blue-900/20 hover:bg-blue-100 dark:hover:bg-blue-900/30 hover:shadow-sm z-10'; - } + if (selected) { + return 'group cursor-pointer transition-all duration-200 relative bg-blue-50 dark:bg-blue-900/20 hover:bg-blue-100 dark:hover:bg-blue-900/30 hover:shadow-sm z-10'; + } - let className = baseHover; + let className = baseHover; - if (!online) { - className += ' opacity-60'; - } + if (!online) { + className += ' opacity-60'; + } - return className; - }; + return className; + }; - const agentOutdated = isAgentOutdated(summary.host.agentVersion); - const runtimeInfo = resolveHostRuntime(summary.host); - const runtimeVersion = summary.host.runtimeVersion || summary.host.dockerVersion; - const metricsKey = buildMetricKey('dockerHost', summary.host.id); - const hostStatus = createMemo(() => getDockerHostStatusIndicator(summary.host)); + const agentOutdated = isAgentOutdated(summary.host.agentVersion); + const runtimeInfo = resolveHostRuntime(summary.host); + const runtimeVersion = summary.host.runtimeVersion || summary.host.dockerVersion; + const metricsKey = buildMetricKey('dockerHost', summary.host.id); + const hostStatus = createMemo(() => getDockerHostStatusIndicator(summary.host)); - return ( - props.onSelect(summary.host.id)} - > - props.onSelect(summary.host.id)} + > + + + + + + + + + - - - - - - - - - - ); - }} - - -
handleSort('name')} + onKeyDown={(e) => e.key === 'Enter' && handleSort('name')} + tabIndex={0} + role="button" + aria-label={`Sort by host ${sortKey() === 'name' ? (sortDirection() === 'asc' ? 'ascending' : 'descending') : ''}`} + > + Host {renderSortIndicator('name')} + + Status + handleSort('cpu')} + > + CPU {renderSortIndicator('cpu')} + handleSort('memory')} + > + Memory {renderSortIndicator('memory')} + handleSort('disk')} + > + Disk {renderSortIndicator('disk')} + handleSort('running')} + > + Containers {renderSortIndicator('running')} + handleSort('uptime')} + > + Uptime {renderSortIndicator('uptime')} + handleSort('lastSeen')} + > + Last Update {renderSortIndicator('lastSeen')} + handleSort('agent')} + > + Agent {renderSortIndicator('agent')} +
-
- - - {getDisplayName(summary.host)} - - -
+
+ + + {getDisplayName(summary.host)} - - +
+
+ {renderDockerStatusBadge(summary.host.status)} +
+
+ +
+ +
+
+ +
+ + + + +
+ 0} + fallback={} + > + + +
+
+
+ + {uptimeLabel} - - - v{runtimeVersion} +
+
+
+ —} + > + {(relative) => ( + + {relative()} + + )} + +
+
+
+ —} + > + {(version) => ( + + {version()} + + )} + + + + ⚠
- -
-
- {renderDockerStatusBadge(summary.host.status)} -
-
- -
- -
-
- -
- - - - -
- 0} - fallback={} - > - - -
-
-
- - {uptimeLabel} - -
-
-
- —} - > - {(relative) => ( - - {relative()} - - )} - -
-
-
- —} - > - {(version) => ( - - {version()} - - )} - - - - ⚠ - - -
-
-
-
+ + + ); + }} + + + + + + + {/* URL editing popover - using shared component */} + + ); }; diff --git a/frontend-modern/src/components/Docker/DockerHosts.tsx b/frontend-modern/src/components/Docker/DockerHosts.tsx index d3c8ce5..abe775c 100644 --- a/frontend-modern/src/components/Docker/DockerHosts.tsx +++ b/frontend-modern/src/components/Docker/DockerHosts.tsx @@ -12,11 +12,13 @@ import { useWebSocket } from '@/App'; import { useDebouncedValue } from '@/hooks/useDebouncedValue'; import { formatBytes, formatRelativeTime } from '@/utils/format'; import { DockerMetadataAPI, type DockerMetadata } from '@/api/dockerMetadata'; +import { DockerHostMetadataAPI, type DockerHostMetadata } from '@/api/dockerHostMetadata'; import { logger } from '@/utils/logger'; import { STORAGE_KEYS } from '@/utils/localStorage'; import { DEGRADED_HEALTH_STATUSES, OFFLINE_HEALTH_STATUSES } from '@/utils/status'; type DockerMetadataRecord = Record; +type DockerHostMetadataRecord = Record; interface DockerHostsProps { hosts: DockerHost[]; @@ -40,10 +42,27 @@ export const DockerHosts: Component = (props) => { return {}; }; + // Load docker host metadata from localStorage + const loadInitialDockerHostMetadata = (): DockerHostMetadataRecord => { + try { + const cached = localStorage.getItem(STORAGE_KEYS.DOCKER_METADATA + '_hosts'); + if (cached) { + return JSON.parse(cached); + } + } catch (err) { + logger.warn('Failed to parse cached docker host metadata', err); + } + return {}; + }; + const [dockerMetadata, setDockerMetadata] = createSignal( loadInitialDockerMetadata(), ); + const [dockerHostMetadata, setDockerHostMetadata] = createSignal( + loadInitialDockerHostMetadata(), + ); + const sortedHosts = createMemo(() => { const hosts = props.hosts || []; return [...hosts].sort((a, b) => { @@ -207,9 +226,58 @@ export const DockerHosts: Component = (props) => { .catch((err) => { logger.debug('Failed to load docker metadata', err); }); + + // Load docker host metadata from API + DockerHostMetadataAPI.getAllMetadata() + .then((metadata) => { + setDockerHostMetadata(metadata || {}); + try { + localStorage.setItem(STORAGE_KEYS.DOCKER_METADATA + '_hosts', JSON.stringify(metadata || {})); + } catch (err) { + logger.warn('Failed to cache docker host metadata', err); + } + }) + .catch((err) => { + logger.debug('Failed to load docker host metadata', err); + }); }); onCleanup(() => document.removeEventListener('keydown', handleKeyDown)); + // Handler to update docker host custom URL + const handleHostCustomUrlUpdate = (hostId: string, url: string) => { + const trimmedUrl = url.trim(); + const nextUrl = trimmedUrl === '' ? undefined : trimmedUrl; + + setDockerHostMetadata((prev) => { + const updated = { ...prev }; + if (nextUrl === undefined) { + // Remove URL but keep other metadata fields + if (updated[hostId]) { + const { customUrl: _removed, ...rest } = updated[hostId]; + if (Object.keys(rest).length === 0 || (Object.keys(rest).length === 1 && !rest.customDisplayName && !rest.notes?.length)) { + delete updated[hostId]; + } else { + updated[hostId] = rest; + } + } + } else { + updated[hostId] = { + ...(prev[hostId] || {}), + customUrl: nextUrl, + }; + } + + // Cache to localStorage + try { + localStorage.setItem(STORAGE_KEYS.DOCKER_METADATA + '_hosts', JSON.stringify(updated)); + } catch (err) { + logger.warn('Failed to cache docker host metadata', err); + } + + return updated; + }); + }; + // Handler to update docker resource custom URL const handleCustomUrlUpdate = (resourceId: string, url: string) => { const trimmedUrl = url.trim(); @@ -407,6 +475,8 @@ export const DockerHosts: Component = (props) => { summaries={filteredHostSummaries} selectedHostId={selectedHostId} onSelect={handleHostSelect} + dockerHostMetadata={dockerHostMetadata()} + onHostCustomUrlUpdate={handleHostCustomUrlUpdate} /> diff --git a/frontend-modern/src/components/Docker/DockerUnifiedTable.tsx b/frontend-modern/src/components/Docker/DockerUnifiedTable.tsx index 0ea79bc..62147fe 100644 --- a/frontend-modern/src/components/Docker/DockerUnifiedTable.tsx +++ b/frontend-modern/src/components/Docker/DockerUnifiedTable.tsx @@ -26,6 +26,7 @@ import { usePersistentSignal } from '@/hooks/usePersistentSignal'; import { ResponsiveMetricCell } from '@/components/shared/responsive'; import { useBreakpoint } from '@/hooks/useBreakpoint'; import { StackedMemoryBar } from '@/components/Dashboard/StackedMemoryBar'; +import { UrlEditPopover } from '@/components/shared/UrlEditPopover'; import type { ColumnConfig } from '@/types/responsive'; const typeBadgeClass = (type: 'container' | 'service' | 'task' | 'unknown') => { @@ -145,6 +146,7 @@ const [currentlyExpandedRowId, setCurrentlyExpandedRowId] = createSignal(null); const dockerEditingValues = new Map(); const [dockerEditingValuesVersion, setDockerEditingValuesVersion] = createSignal(0); +const [dockerPopoverPosition, setDockerPopoverPosition] = createSignal<{ top: number; left: number } | null>(null); const toLower = (value?: string | null) => value?.toLowerCase() ?? ''; @@ -994,6 +996,12 @@ const DockerContainerRow: Component<{ const startEditingUrl = (event: MouseEvent) => { event.stopPropagation(); + event.preventDefault(); + + // Calculate popover position from the button + const button = event.currentTarget as HTMLElement; + const rect = button.getBoundingClientRect(); + setDockerPopoverPosition({ top: rect.bottom + 4, left: Math.max(8, rect.left - 100) }); // If another resource is being edited, save it first const currentEditing = currentlyEditingDockerResourceId(); @@ -1057,6 +1065,7 @@ const DockerContainerRow: Component<{ dockerEditingValues.delete(resourceId()); setDockerEditingValuesVersion(v => v + 1); setCurrentlyEditingDockerResourceId(null); + setDockerPopoverPosition(null); // If URL hasn't changed, don't save if (newUrl === (customUrl() || '')) return; @@ -1100,6 +1109,7 @@ const DockerContainerRow: Component<{ dockerEditingValues.delete(resourceId()); setDockerEditingValuesVersion(v => v + 1); setCurrentlyEditingDockerResourceId(null); + setDockerPopoverPosition(null); // If there was a URL set, delete it if (customUrl()) { @@ -1127,6 +1137,7 @@ const DockerContainerRow: Component<{ dockerEditingValues.delete(resourceId()); setDockerEditingValuesVersion(v => v + 1); setCurrentlyEditingDockerResourceId(null); + setDockerPopoverPosition(null); }; const cpuPercent = () => Math.max(0, Math.min(100, container.cpuPercent ?? 0)); @@ -1189,89 +1200,68 @@ const DockerContainerRow: Component<{ size="xs" />
- - - {container.name || container.id} - - - {(name) => ( - - Pod: {name()} - - - infra - - +
+ + {container.name || container.id} + + + {(name) => ( + + Pod: {name()} + + + infra - )} - - - event.stopPropagation()} - > - - - - - - {/* Edit URL button - shows on hover */} - - - - - {hostDisplayName()} - - - {/* AI context indicator - shows when container is selected for AI */} - - - - - - - -
- } - > -
- { dockerEditingValues.set(resourceId(), e.currentTarget.value); setDockerEditingValuesVersion(v => v + 1); }} - onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); saveUrl(); } else if (e.key === 'Escape') { e.preventDefault(); cancelEditingUrl(); } }} - onClick={(e) => e.stopPropagation()} - placeholder="https://example.com:8080" - class="flex-1 min-w-0 px-2 py-0.5 text-sm border border-blue-500 rounded bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500" - /> - - -
-
+
+ + )} + + + event.stopPropagation()} + > + + + + + + {/* Edit URL button - shows on hover */} + + + + + {hostDisplayName()} + + + {/* AI context indicator - shows when container is selected for AI */} + + + + + + + +
@@ -1414,6 +1404,21 @@ const DockerContainerRow: Component<{ + {/* URL editing popover - using shared component */} + { dockerEditingValues.set(resourceId(), value); setDockerEditingValuesVersion(v => v + 1); }} + onSave={saveUrl} + onCancel={cancelEditingUrl} + onDelete={deleteUrl} + /> + @@ -1921,6 +1926,12 @@ const DockerServiceRow: Component<{ const startEditingUrl = (event: MouseEvent) => { event.stopPropagation(); + event.preventDefault(); + + // Calculate popover position from the button + const button = event.currentTarget as HTMLElement; + const rect = button.getBoundingClientRect(); + setDockerPopoverPosition({ top: rect.bottom + 4, left: Math.max(8, rect.left - 100) }); // If another resource is being edited, save it first const currentEditing = currentlyEditingDockerResourceId(); @@ -1984,6 +1995,7 @@ const DockerServiceRow: Component<{ dockerEditingValues.delete(resourceId()); setDockerEditingValuesVersion(v => v + 1); setCurrentlyEditingDockerResourceId(null); + setDockerPopoverPosition(null); // If URL hasn't changed, don't save if (newUrl === (customUrl() || '')) return; @@ -2027,6 +2039,7 @@ const DockerServiceRow: Component<{ dockerEditingValues.delete(resourceId()); setDockerEditingValuesVersion(v => v + 1); setCurrentlyEditingDockerResourceId(null); + setDockerPopoverPosition(null); // If there was a URL set, delete it if (customUrl()) { @@ -2054,6 +2067,7 @@ const DockerServiceRow: Component<{ dockerEditingValues.delete(resourceId()); setDockerEditingValuesVersion(v => v + 1); setCurrentlyEditingDockerResourceId(null); + setDockerPopoverPosition(null); }; const badge = serviceHealthBadge(service); @@ -2085,82 +2099,61 @@ const DockerServiceRow: Component<{ size="xs" />
- - - {service.name || service.id || 'Service'} - - - event.stopPropagation()} - > - - - - - - {/* Edit URL button - shows on hover */} - - - - Stack: {service.stack} - - - - - - {hostDisplayName()} - - - {/* AI context indicator - shows when service is selected for AI */} - - - - - - - -
- } - > -
- { dockerEditingValues.set(resourceId(), e.currentTarget.value); setDockerEditingValuesVersion(v => v + 1); }} - onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); saveUrl(); } else if (e.key === 'Escape') { e.preventDefault(); cancelEditingUrl(); } }} - onClick={(e) => e.stopPropagation()} - placeholder="https://example.com:8080" - class="flex-1 min-w-0 px-2 py-0.5 text-sm border border-blue-500 rounded bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500" - /> - - -
-
+
+ + {service.name || service.id || 'Service'} + + + event.stopPropagation()} + > + + + + + + {/* Edit URL button - shows on hover */} + + + + Stack: {service.stack} + + + + + + {hostDisplayName()} + + + {/* AI context indicator - shows when service is selected for AI */} + + + + + + + +
@@ -2241,6 +2234,21 @@ const DockerServiceRow: Component<{ + {/* URL editing popover - using shared component */} + { dockerEditingValues.set(resourceId(), value); setDockerEditingValuesVersion(v => v + 1); }} + onSave={saveUrl} + onCancel={cancelEditingUrl} + onDelete={deleteUrl} + /> + diff --git a/frontend-modern/src/components/Hosts/HostsOverview.tsx b/frontend-modern/src/components/Hosts/HostsOverview.tsx index 74884dd..7594efc 100644 --- a/frontend-modern/src/components/Hosts/HostsOverview.tsx +++ b/frontend-modern/src/components/Hosts/HostsOverview.tsx @@ -20,6 +20,8 @@ import { aiChatStore } from '@/stores/aiChat'; import { STORAGE_KEYS } from '@/utils/localStorage'; import { useResourcesAsLegacy } from '@/hooks/useResources'; import { HostMetadataAPI, type HostMetadata } from '@/api/hostMetadata'; +import { UrlEditPopover, createUrlEditState } from '@/components/shared/UrlEditPopover'; +import { showSuccess, showError } from '@/utils/toast'; import { logger } from '@/utils/logger'; // Column definition for hosts table @@ -43,13 +45,13 @@ export const HOST_COLUMNS: HostColumnDef[] = [ { id: 'disk', label: 'Disk', priority: 'essential', width: '140px', sortKey: 'disk' }, // Secondary - visible on md+, toggleable - { id: 'temp', label: 'Temp', icon: , priority: 'secondary', width: '50px', toggleable: true }, - { id: 'uptime', label: 'Uptime', icon: , priority: 'secondary', width: '65px', toggleable: true, sortKey: 'uptime' }, + { id: 'temp', label: 'Temp', icon: , priority: 'secondary', width: '50px', toggleable: true }, + { id: 'uptime', label: 'Uptime', icon: , priority: 'secondary', width: '65px', toggleable: true, sortKey: 'uptime' }, { id: 'agent', label: 'Agent', priority: 'secondary', width: '60px', toggleable: true }, // Supplementary - visible on lg+, toggleable // Note: CPU count and load average removed - they're shown in the EnhancedCPUBar tooltip - { id: 'ip', label: 'IP', icon: , priority: 'supplementary', width: '50px', toggleable: true }, + { id: 'ip', label: 'IP', icon: , priority: 'supplementary', width: '50px', toggleable: true }, // Detailed - visible on xl+, toggleable { id: 'arch', label: 'Arch', priority: 'detailed', width: '55px', toggleable: true }, @@ -914,41 +916,43 @@ const HostRow: Component = (props) => { // Check if this host is in AI context const isInAIContext = createMemo(() => aiChatStore.enabled && aiChatStore.hasContextItem(host.id)); - // URL editing state - const [isEditingUrl, setIsEditingUrl] = createSignal(false); - const [editingUrlValue, setEditingUrlValue] = createSignal(''); - const [isSavingUrl, setIsSavingUrl] = createSignal(false); - let urlInputRef: HTMLInputElement | undefined; + // URL editing using shared hook + const urlEdit = createUrlEditState(); - // Start editing URL - const startEditingUrl = (e: MouseEvent) => { - e.stopPropagation(); - setEditingUrlValue(props.customUrl || ''); - setIsEditingUrl(true); - // Focus input after render - setTimeout(() => urlInputRef?.focus(), 0); + const handleStartEditingUrl = (e: MouseEvent) => { + urlEdit.startEditing(host.id, props.customUrl || '', e); }; - // Save URL - const saveUrl = async () => { - const url = editingUrlValue().trim(); - setIsSavingUrl(true); + const handleSaveUrl = async () => { + const url = urlEdit.editingValue().trim(); + urlEdit.setIsSaving(true); try { if (url) { await props.onUpdateCustomUrl(host.id, url); + showSuccess('Host URL saved'); } else { await props.onDeleteCustomUrl(host.id); + showSuccess('Host URL removed'); } - setIsEditingUrl(false); - } finally { - setIsSavingUrl(false); + urlEdit.finishEditing(); + } catch (err) { + const message = err instanceof Error ? err.message : 'Failed to save URL'; + showError(message); + urlEdit.setIsSaving(false); } }; - // Cancel editing - const cancelEditingUrl = () => { - setIsEditingUrl(false); - setEditingUrlValue(''); + const handleDeleteUrl = async () => { + urlEdit.setIsSaving(true); + try { + await props.onDeleteCustomUrl(host.id); + showSuccess('Host URL removed'); + urlEdit.finishEditing(); + } catch (err) { + const message = err instanceof Error ? err.message : 'Failed to remove URL'; + showError(message); + urlEdit.setIsSaving(false); + } }; // Build context for AI - includes routing fields @@ -999,287 +1003,252 @@ const HostRow: Component = (props) => { }; return ( - - {/* Host Name - always visible */} - -
- - -
-

- {host.displayName || host.hostname || host.id} + <> + + {/* Host Name - always visible */} + +

+ +
+
+

+ {host.displayName || host.hostname || host.id} +

+ +

+ {host.hostname}

- -

- {host.hostname} -

-
- -

- Updated {formatRelativeTime(host.lastSeen!)} -

-
-
- {/* Custom URL link */} - - event.stopPropagation()} - > - - - - - {/* Edit URL button - shows on hover */} - - {/* AI context indicator */} - - - - - - + +

+ Updated {formatRelativeTime(host.lastSeen!)} +

- } - > - {/* URL editing mode */} -
- setEditingUrlValue(e.currentTarget.value)} - onKeyDown={(e) => { - if (e.key === 'Enter') { - e.preventDefault(); - saveUrl(); - } else if (e.key === 'Escape') { - e.preventDefault(); - cancelEditingUrl(); - } - }} - onClick={(e) => e.stopPropagation()} - placeholder="https://192.168.1.100:8080" - class="w-40 px-2 py-0.5 text-xs border border-blue-500 rounded bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-1 focus:ring-blue-500" - disabled={isSavingUrl()} + {/* Custom URL link */} + + event.stopPropagation()} + > + + + + + + {/* Edit URL button - shows on hover */} + + {/* AI context indicator */} + + + + + + + +
+
+ + + {/* Platform */} + + +
+

{host.platform || '—'}

+ +

+ {host.osName} {host.osVersion} +

+
+
+ +
+ + {/* CPU */} + + + +
+ +
+
+ + +
+ + {/* Memory */} + + + +
+ +
+
+ + +
+ + {/* Disk */} + + + +
+ +
+
+ -
-
- + +
- {/* Platform */} - - -
-

{host.platform || '—'}

- -

- {host.osName} {host.osVersion} -

-
-
- -
- - {/* CPU */} - - - -
- + {/* Temperature - shows primary temp with all sensors in tooltip */} + + +
+
-
- - - + + - {/* Memory */} - - - -
- + {/* Uptime */} + + +
+ —}> + + {formatUptime(host.uptimeSeconds!)} + +
-
- - - + + - {/* Disk */} - - - -
- + {/* Agent Version */} + + +
+ —}> + + {host.agentVersion} + +
-
- - - + + - {/* Temperature - shows primary temp with all sensors in tooltip */} - - -
- -
- -
+ {/* IP Address - uses icon + count with tooltip */} + + +
+ +
+ +
- {/* Uptime */} - - -
- —}> - - {formatUptime(host.uptimeSeconds!)} - - -
- -
+ {/* Architecture */} + + +
+ —}> + + {host.architecture} + + +
+ +
- {/* Agent Version */} - - -
- —}> - - {host.agentVersion} - - -
- -
+ {/* Kernel */} + + +
+ —}> + + {host.kernelVersion} + + +
+ +
- {/* IP Address - uses icon + count with tooltip */} - - -
- -
- -
+ {/* RAID Status */} + + +
+ +
+ +
+ - {/* Architecture */} - - -
- —}> - - {host.architecture} - - -
- -
- - {/* Kernel */} - - -
- —}> - - {host.kernelVersion} - - -
- -
- - {/* RAID Status */} - - -
- -
- -
- + {/* URL editing popover - using shared component */} + + ); }; diff --git a/frontend-modern/src/components/shared/UrlEditPopover.tsx b/frontend-modern/src/components/shared/UrlEditPopover.tsx new file mode 100644 index 0000000..29e8f9a --- /dev/null +++ b/frontend-modern/src/components/shared/UrlEditPopover.tsx @@ -0,0 +1,186 @@ +import { Component, Show, createSignal, createEffect } from 'solid-js'; + +export interface UrlEditPopoverProps { + /** Whether the popover is visible */ + isOpen: boolean; + /** Current URL value being edited */ + value: string; + /** Position of the popover (fixed positioning) */ + position: { top: number; left: number } | null; + /** Whether the URL is currently being saved */ + isSaving?: boolean; + /** Whether there's an existing URL that can be deleted */ + hasExistingUrl?: boolean; + /** Placeholder text for the input */ + placeholder?: string; + /** Help text shown below the input */ + helpText?: string; + /** Called when the input value changes */ + onValueChange: (value: string) => void; + /** Called when save is requested */ + onSave: () => void; + /** Called when cancel is requested */ + onCancel: () => void; + /** Called when delete is requested (only if hasExistingUrl is true) */ + onDelete?: () => void; +} + +/** + * A reusable URL editing popover component that provides a consistent + * experience across all tables for adding/editing custom URLs. + * + * Uses fixed positioning to escape overflow clipping from parent containers. + */ +export const UrlEditPopover: Component = (props) => { + let inputRef: HTMLInputElement | undefined; + + // Focus input when popover opens + createEffect(() => { + if (props.isOpen && inputRef) { + // Use setTimeout to ensure the element is in the DOM + setTimeout(() => { + inputRef?.focus(); + inputRef?.select(); + }, 0); + } + }); + + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Enter') { + e.preventDefault(); + props.onSave(); + } else if (e.key === 'Escape') { + e.preventDefault(); + props.onCancel(); + } + }; + + return ( + +
e.stopPropagation()} + > +
+ props.onValueChange(e.currentTarget.value)} + onKeyDown={handleKeyDown} + disabled={props.isSaving} + /> + + {/* Save button */} + + + {/* Delete button - only show if there's an existing URL */} + + + + + {/* Cancel button */} + +
+ + {/* Help text */} + +

+ {props.helpText} +

+
+
+
+ ); +}; + +/** + * Hook to manage URL editing state. + * Provides all the state and handlers needed for the UrlEditPopover. + */ +export function createUrlEditState() { + const [isEditing, setIsEditing] = createSignal(false); + const [editingValue, setEditingValue] = createSignal(''); + const [isSaving, setIsSaving] = createSignal(false); + const [position, setPosition] = createSignal<{ top: number; left: number } | null>(null); + const [editingId, setEditingId] = createSignal(null); + + const startEditing = (id: string, currentValue: string, event: MouseEvent) => { + event.stopPropagation(); + event.preventDefault(); + + const button = event.currentTarget as HTMLElement; + const rect = button.getBoundingClientRect(); + + // Position below the button, slightly to the left for better visibility + setPosition({ + top: rect.bottom + 4, + left: Math.max(8, rect.left - 100) + }); + + setEditingValue(currentValue); + setEditingId(id); + setIsEditing(true); + }; + + const cancelEditing = () => { + setIsEditing(false); + setEditingValue(''); + setEditingId(null); + setPosition(null); + }; + + const finishEditing = () => { + setIsEditing(false); + setEditingValue(''); + setEditingId(null); + setPosition(null); + setIsSaving(false); + }; + + return { + isEditing, + editingValue, + setEditingValue, + isSaving, + setIsSaving, + position, + editingId, + startEditing, + cancelEditing, + finishEditing, + }; +} diff --git a/internal/api/docker_metadata.go b/internal/api/docker_metadata.go index 5e75bd9..22e63bb 100644 --- a/internal/api/docker_metadata.go +++ b/internal/api/docker_metadata.go @@ -162,3 +162,154 @@ func (h *DockerMetadataHandler) HandleDeleteMetadata(w http.ResponseWriter, r *h w.WriteHeader(http.StatusNoContent) } + +// HandleGetHostMetadata retrieves metadata for a Docker host or all hosts +func (h *DockerMetadataHandler) HandleGetHostMetadata(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + // Check if requesting specific host + path := r.URL.Path + // Handle both /api/docker/hosts/metadata and /api/docker/hosts/metadata/ + if path == "/api/docker/hosts/metadata" || path == "/api/docker/hosts/metadata/" { + // Get all host metadata + w.Header().Set("Content-Type", "application/json") + allMeta := h.store.GetAllHostMetadata() + if allMeta == nil { + // Return empty object instead of null + json.NewEncoder(w).Encode(make(map[string]*config.DockerHostMetadata)) + } else { + json.NewEncoder(w).Encode(allMeta) + } + return + } + + // Get specific host ID from path + hostID := strings.TrimPrefix(path, "/api/docker/hosts/metadata/") + + w.Header().Set("Content-Type", "application/json") + + if hostID != "" { + // Get specific Docker host metadata + meta := h.store.GetHostMetadata(hostID) + if meta == nil { + // Return empty metadata instead of 404 + json.NewEncoder(w).Encode(&config.DockerHostMetadata{}) + } else { + json.NewEncoder(w).Encode(meta) + } + } else { + // This shouldn't happen with current routing, but handle it anyway + http.Error(w, "Invalid request path", http.StatusBadRequest) + } +} + +// HandleUpdateHostMetadata updates metadata for a Docker host +func (h *DockerMetadataHandler) HandleUpdateHostMetadata(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPut && r.Method != http.MethodPost { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + hostID := strings.TrimPrefix(r.URL.Path, "/api/docker/hosts/metadata/") + if hostID == "" || hostID == "metadata" { + http.Error(w, "Host ID required", http.StatusBadRequest) + return + } + + // Limit request body to 16KB to prevent memory exhaustion + r.Body = http.MaxBytesReader(w, r.Body, 16*1024) + + var meta config.DockerHostMetadata + if err := json.NewDecoder(r.Body).Decode(&meta); err != nil { + http.Error(w, "Invalid request body", http.StatusBadRequest) + return + } + + // Validate URL if provided + if meta.CustomURL != "" { + // Parse and validate the URL + parsedURL, err := url.Parse(meta.CustomURL) + if err != nil { + http.Error(w, "Invalid URL format: "+err.Error(), http.StatusBadRequest) + return + } + + // Check scheme + if parsedURL.Scheme != "http" && parsedURL.Scheme != "https" { + http.Error(w, "URL must use http:// or https:// scheme", http.StatusBadRequest) + return + } + + // Check host is present and valid + if parsedURL.Host == "" { + http.Error(w, "Invalid URL: missing host/domain (e.g., use https://192.168.1.100:9000 or https://portainer.local)", http.StatusBadRequest) + return + } + + // Check for incomplete URLs like "https://portainer." + if strings.HasSuffix(parsedURL.Host, ".") && !strings.Contains(parsedURL.Host, "..") { + http.Error(w, "Incomplete URL: '"+meta.CustomURL+"' - please enter a complete domain or IP address", http.StatusBadRequest) + return + } + } + + // Get existing metadata to merge with new data + existing := h.store.GetHostMetadata(hostID) + if existing != nil { + // Merge: only update fields that are provided + if meta.CustomDisplayName != "" || existing.CustomDisplayName != "" { + if meta.CustomDisplayName == "" { + meta.CustomDisplayName = existing.CustomDisplayName + } + } + // CustomURL can be explicitly cleared, so we don't merge it unless updating + if meta.Notes == nil && existing.Notes != nil { + meta.Notes = existing.Notes + } + } + + if err := h.store.SetHostMetadata(hostID, &meta); err != nil { + log.Error().Err(err).Str("hostID", hostID).Msg("Failed to save Docker host metadata") + // Provide more specific error message + errMsg := "Failed to save metadata" + if strings.Contains(err.Error(), "permission") { + errMsg = "Permission denied - check file permissions" + } else if strings.Contains(err.Error(), "no space") { + errMsg = "Disk full - cannot save metadata" + } + http.Error(w, errMsg, http.StatusInternalServerError) + return + } + + log.Info().Str("hostID", hostID).Str("url", meta.CustomURL).Msg("Updated Docker host metadata") + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(&meta) +} + +// HandleDeleteHostMetadata removes metadata for a Docker host +func (h *DockerMetadataHandler) HandleDeleteHostMetadata(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodDelete { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + hostID := strings.TrimPrefix(r.URL.Path, "/api/docker/hosts/metadata/") + if hostID == "" || hostID == "metadata" { + http.Error(w, "Host ID required", http.StatusBadRequest) + return + } + + if err := h.store.SetHostMetadata(hostID, nil); err != nil { + log.Error().Err(err).Str("hostID", hostID).Msg("Failed to delete Docker host metadata") + http.Error(w, "Failed to delete metadata", http.StatusInternalServerError) + return + } + + log.Info().Str("hostID", hostID).Msg("Deleted Docker host metadata") + + w.WriteHeader(http.StatusNoContent) +} diff --git a/internal/config/docker_metadata.go b/internal/config/docker_metadata.go index 976fb19..3670485 100644 --- a/internal/config/docker_metadata.go +++ b/internal/config/docker_metadata.go @@ -21,7 +21,9 @@ type DockerMetadata struct { // DockerHostMetadata holds additional metadata for a Docker host type DockerHostMetadata struct { - CustomDisplayName string `json:"customDisplayName,omitempty"` // User-defined custom display name + CustomDisplayName string `json:"customDisplayName,omitempty"` // User-defined custom display name + CustomURL string `json:"customUrl,omitempty"` // Custom URL for administration (e.g., Portainer) + Notes []string `json:"notes,omitempty"` // User annotations for AI context } // dockerMetadataFile represents the on-disk format for Docker metadata @@ -107,8 +109,8 @@ func (s *DockerMetadataStore) SetHostMetadata(hostID string, meta *DockerHostMet s.mu.Lock() defer s.mu.Unlock() - if meta == nil || meta.CustomDisplayName == "" { - // If metadata is nil or custom display name is empty, delete the entry + // If metadata is nil or all fields are empty, delete the entry + if meta == nil || (meta.CustomDisplayName == "" && meta.CustomURL == "" && len(meta.Notes) == 0) { delete(s.hostMetadata, hostID) } else { s.hostMetadata[hostID] = meta @@ -116,6 +118,7 @@ func (s *DockerMetadataStore) SetHostMetadata(hostID string, meta *DockerHostMet // Save to disk return s.save() + } // Set updates or creates metadata for a Docker resource