From a9d771331325820e9607902c45ee90006cd210d9 Mon Sep 17 00:00:00 2001 From: rcourtman Date: Tue, 3 Feb 2026 22:53:26 +0000 Subject: [PATCH] fix(discovery): persist custom URL from discovery tab for Docker and Kubernetes The DiscoveryTab component for Docker containers/services and Kubernetes pods was not receiving the customUrl and onCustomUrlChange props, causing saved URLs to be lost when the drawer was reopened. Changes: - Add guest metadata management to DockerUnifiedTable (fetches on mount, caches, listens for external changes) - Pass customUrl and onCustomUrlChange props to DiscoveryTab in DockerContainerRow and DockerServiceRow - Add guest metadata management to KubernetesClusters component - Update PodRow to receive and pass metadata props to DiscoveryTab The fix ensures that when users click 'Use this' on a suggested URL and save, the URL persists correctly across Docker containers, Swarm services, and K8s pods. --- .../components/Docker/DockerUnifiedTable.tsx | 81 ++++++++++++++++- .../Kubernetes/KubernetesClusters.tsx | 87 ++++++++++++++++--- 2 files changed, 153 insertions(+), 15 deletions(-) diff --git a/frontend-modern/src/components/Docker/DockerUnifiedTable.tsx b/frontend-modern/src/components/Docker/DockerUnifiedTable.tsx index cea9e06903..1783a55e8a 100644 --- a/frontend-modern/src/components/Docker/DockerUnifiedTable.tsx +++ b/frontend-modern/src/components/Docker/DockerUnifiedTable.tsx @@ -1,4 +1,4 @@ -import { Component, For, Show, createMemo, createSignal, createEffect, Accessor } from 'solid-js'; +import { Component, For, Show, createMemo, createSignal, createEffect, Accessor, onMount } from 'solid-js'; import type { DockerHost, DockerContainer, DockerService, DockerTask } from '@/types/api'; import { Card } from '@/components/shared/Card'; import { EmptyState } from '@/components/shared/EmptyState'; @@ -36,6 +36,21 @@ import type { ColumnConfig } from '@/types/responsive'; import { DiscoveryTab } from '@/components/Discovery/DiscoveryTab'; import { HistoryChart } from '@/components/shared/HistoryChart'; import type { HistoryTimeRange } from '@/api/charts'; +import { GuestMetadataAPI, type GuestMetadata } from '@/api/guestMetadata'; +import { logger } from '@/utils/logger'; + +type GuestMetadataRecord = Record; + +// Module-level cache for guest metadata to persist across component remounts +let cachedDockerGuestMetadata: GuestMetadataRecord | null = null; + +const getDockerGuestMetadataCache = (): GuestMetadataRecord => { + return cachedDockerGuestMetadata ?? {}; +}; + +const setDockerGuestMetadataCache = (metadata: GuestMetadataRecord) => { + cachedDockerGuestMetadata = metadata; +}; const typeBadgeClass = (type: 'container' | 'service' | 'task' | 'dockerHost' | 'unknown') => { switch (type) { @@ -788,6 +803,8 @@ const DockerContainerRow: Component<{ showHostContext?: boolean; resourceIndentClass?: string; batchUpdateState?: Record; + guestMetadata?: GuestMetadataRecord; + onCustomUrlChange?: (guestId: string, url: string) => void; }> = (props) => { const { host, container } = props.row; const runtimeInfo = resolveHostRuntime(host); @@ -1590,6 +1607,8 @@ const DockerContainerRow: Component<{ resourceId={container.id} hostname={container.name} guestId={container.id} + customUrl={props.guestMetadata?.[container.id]?.customUrl} + onCustomUrlChange={(url) => props.onCustomUrlChange?.(container.id, url)} /> @@ -1603,9 +1622,10 @@ const DockerContainerRow: Component<{ const DockerServiceRow: Component<{ row: Extract; isMobile: Accessor; - onCustomUrlUpdate?: (resourceId: string, url: string) => void; showHostContext?: boolean; resourceIndentClass?: string; + guestMetadata?: GuestMetadataRecord; + onCustomUrlChange?: (guestId: string, url: string) => void; }> = (props) => { const { host, service, tasks } = props.row; const rowId = buildRowId(host, props.row); @@ -1940,6 +1960,9 @@ const DockerServiceRow: Component<{ + + + @@ -1988,6 +2011,8 @@ const DockerServiceRow: Component<{ resourceId={service.id || service.name || ''} hostname={service.name || service.id || 'Service'} guestId={resourceId()} + customUrl={props.guestMetadata?.[resourceId()]?.customUrl} + onCustomUrlChange={(url) => props.onCustomUrlChange?.(resourceId(), url)} /> @@ -2010,6 +2035,54 @@ const DockerUnifiedTable: Component = (props) => { // Use the breakpoint hook for responsive behavior const { isMobile } = useBreakpoint(); + // Guest metadata for tracking custom URLs + const [guestMetadata, setGuestMetadata] = createSignal(getDockerGuestMetadataCache()); + + // Load guest metadata on mount + onMount(async () => { + try { + const metadata = await GuestMetadataAPI.getAllMetadata(); + setGuestMetadata(metadata ?? {}); + setDockerGuestMetadataCache(metadata ?? {}); + } catch (err) { + logger.debug('Failed to load guest metadata for Docker', err); + } + + // Listen for metadata changes from other sources (e.g., AI, other tabs) + const handleMetadataChanged = async () => { + try { + const metadata = await GuestMetadataAPI.getAllMetadata(); + setGuestMetadata(metadata ?? {}); + setDockerGuestMetadataCache(metadata ?? {}); + } catch (err) { + logger.debug('Failed to refresh guest metadata', err); + } + }; + + window.addEventListener('pulse:metadata-changed', handleMetadataChanged); + return () => window.removeEventListener('pulse:metadata-changed', handleMetadataChanged); + }); + + // Handle custom URL changes from discovery tab + const handleCustomUrlChange = (guestId: string, url: string) => { + const trimmed = url.trim(); + setGuestMetadata((prev) => { + const updated = { ...prev }; + if (trimmed) { + updated[guestId] = { ...updated[guestId], id: guestId, customUrl: trimmed }; + } else if (updated[guestId]) { + const { customUrl: _, ...rest } = updated[guestId]; + if (Object.keys(rest).length > 1) { + updated[guestId] = rest as GuestMetadata; + } else { + delete updated[guestId]; + } + } + setDockerGuestMetadataCache(updated); + return updated; + }); + }; + // Caches for stable object references to prevent re-animations const rowCache = new Map(); const tasksCache = new Map(); @@ -2347,6 +2420,8 @@ const DockerUnifiedTable: Component = (props) => { showHostContext={!grouped} resourceIndentClass={grouped ? GROUPED_RESOURCE_INDENT : UNGROUPED_RESOURCE_INDENT} batchUpdateState={props.batchUpdateState} + guestMetadata={guestMetadata()} + onCustomUrlChange={handleCustomUrlChange} /> ) : ( @@ -2355,6 +2430,8 @@ const DockerUnifiedTable: Component = (props) => { isMobile={isMobile} showHostContext={!grouped} resourceIndentClass={grouped ? GROUPED_RESOURCE_INDENT : UNGROUPED_RESOURCE_INDENT} + guestMetadata={guestMetadata()} + onCustomUrlChange={handleCustomUrlChange} /> ); }; diff --git a/frontend-modern/src/components/Kubernetes/KubernetesClusters.tsx b/frontend-modern/src/components/Kubernetes/KubernetesClusters.tsx index 2eb30c55c4..3c8434e617 100644 --- a/frontend-modern/src/components/Kubernetes/KubernetesClusters.tsx +++ b/frontend-modern/src/components/Kubernetes/KubernetesClusters.tsx @@ -1,5 +1,5 @@ import type { Component } from 'solid-js'; -import { For, Show, createMemo, createSignal, createEffect, onMount } from 'solid-js'; +import { For, Show, createMemo, createSignal, createEffect, onMount, Accessor } from 'solid-js'; import { AIAPI } from '@/api/ai'; import Sparkles from 'lucide-solid/icons/sparkles'; import ExternalLink from 'lucide-solid/icons/external-link'; @@ -24,6 +24,21 @@ import { DEGRADED_HEALTH_STATUSES, OFFLINE_HEALTH_STATUSES, type StatusIndicator import { DiscoveryTab } from '@/components/Discovery/DiscoveryTab'; import { HistoryChart } from '@/components/shared/HistoryChart'; import type { HistoryTimeRange } from '@/api/charts'; +import { GuestMetadataAPI, type GuestMetadata } from '@/api/guestMetadata'; +import { logger } from '@/utils/logger'; + +type GuestMetadataRecord = Record; + +// Module-level cache for guest metadata to persist across component remounts +let cachedK8sGuestMetadata: GuestMetadataRecord | null = null; + +const getK8sGuestMetadataCache = (): GuestMetadataRecord => { + return cachedK8sGuestMetadata ?? {}; +}; + +const setK8sGuestMetadataCache = (metadata: GuestMetadataRecord) => { + cachedK8sGuestMetadata = metadata; +}; // Global state for expanded row const [expandedRowId, setExpandedRowId] = createSignal(null); @@ -155,11 +170,12 @@ const PodRow: Component<{ cluster: KubernetesCluster; pod: KubernetesPod; columns: { isColumnVisible: (id: string) => boolean }; + guestMetadata: Accessor; + onCustomUrlChange: (guestId: string, url: string) => void; }> = (props) => { const rowId = `${props.cluster.id}:${props.pod.uid}`; const isExpanded = createMemo(() => expandedRowId() === rowId); const [activeTab, setActiveTab] = createSignal<'overview' | 'discovery'>('overview'); - const [customUrl, setCustomUrl] = createSignal(undefined); const [historyRange, setHistoryRange] = createSignal('1h'); const toggle = (e: MouseEvent) => { @@ -358,8 +374,8 @@ const PodRow: Component<{ resourceId={props.pod.uid} guestId={props.pod.uid} hostname={props.pod.name} - customUrl={customUrl()} - onCustomUrlChange={setCustomUrl} + customUrl={props.guestMetadata()[props.pod.uid]?.customUrl} + onCustomUrlChange={(url) => props.onCustomUrlChange(props.pod.uid, url)} /> @@ -391,6 +407,9 @@ export const KubernetesClusters: Component = (props) => // Column visibility for pods table const podColumns = useColumnVisibility('k8s-pod-columns', POD_COLUMNS); + // Guest metadata for tracking custom URLs + const [guestMetadata, setGuestMetadata] = createSignal(getK8sGuestMetadataCache()); + // Sorting state with persistence type SortKey = 'name' | 'status' | 'namespace' | 'cluster' | 'age' | 'restarts' | 'ready' | 'replicas'; type SortDir = 'asc' | 'desc'; @@ -478,6 +497,28 @@ export const KubernetesClusters: Component = (props) => onMount(() => { void loadLicenseStatus(); void loadAiSettings(); + + // Load guest metadata + GuestMetadataAPI.getAllMetadata().then((metadata) => { + setGuestMetadata(metadata ?? {}); + setK8sGuestMetadataCache(metadata ?? {}); + }).catch((err) => { + logger.debug('Failed to load guest metadata for K8s', err); + }); + + // Listen for metadata changes from other sources + const handleMetadataChanged = async () => { + try { + const metadata = await GuestMetadataAPI.getAllMetadata(); + setGuestMetadata(metadata ?? {}); + setK8sGuestMetadataCache(metadata ?? {}); + } catch (err) { + logger.debug('Failed to refresh guest metadata', err); + } + }; + + window.addEventListener('pulse:metadata-changed', handleMetadataChanged); + return () => window.removeEventListener('pulse:metadata-changed', handleMetadataChanged); }); createEffect(() => { @@ -776,6 +817,26 @@ export const KubernetesClusters: Component = (props) => setViewMode('clusters'); }; + // Handle custom URL changes from discovery tab + const handleK8sCustomUrlChange = (guestId: string, url: string) => { + const trimmed = url.trim(); + setGuestMetadata((prev) => { + const updated = { ...prev }; + if (trimmed) { + updated[guestId] = { ...updated[guestId], id: guestId, customUrl: trimmed }; + } else if (updated[guestId]) { + const { customUrl: _, ...rest } = updated[guestId]; + if (Object.keys(rest).length > 1) { + updated[guestId] = rest as GuestMetadata; + } else { + delete updated[guestId]; + } + } + setK8sGuestMetadataCache(updated); + return updated; + }); + }; + return (
0}> @@ -1295,15 +1356,15 @@ export const KubernetesClusters: Component = (props) => No pods match the current filters. }> - {({ cluster, pod }) => { - return ( - - ); - }} + {({ cluster, pod }) => ( + + )}