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.
This commit is contained in:
rcourtman 2026-02-03 22:53:26 +00:00
parent 7c1ebbecd5
commit a9d7713313
2 changed files with 153 additions and 15 deletions

View file

@ -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<string, GuestMetadata>;
// 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<string, 'updating' | 'queued' | 'error'>;
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)}
/>
</Show>
</div>
@ -1603,9 +1622,10 @@ const DockerContainerRow: Component<{
const DockerServiceRow: Component<{
row: Extract<DockerRow, { kind: 'service' }>;
isMobile: Accessor<boolean>;
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<{
<option value="6h">Last 6 hours</option>
<option value="12h">Last 12 hours</option>
<option value="24h">Last 24 hours</option>
<option value="7d">Last 7 days</option>
<option value="30d">Last 30 days</option>
<option value="90d">Last 90 days</option>
</select>
</div>
@ -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)}
/>
</Show>
</div>
@ -2010,6 +2035,54 @@ const DockerUnifiedTable: Component<DockerUnifiedTableProps> = (props) => {
// Use the breakpoint hook for responsive behavior
const { isMobile } = useBreakpoint();
// Guest metadata for tracking custom URLs
const [guestMetadata, setGuestMetadata] = createSignal<GuestMetadataRecord>(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<string, DockerRow>();
const tasksCache = new Map<string, DockerTask[]>();
@ -2347,6 +2420,8 @@ const DockerUnifiedTable: Component<DockerUnifiedTableProps> = (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<DockerUnifiedTableProps> = (props) => {
isMobile={isMobile}
showHostContext={!grouped}
resourceIndentClass={grouped ? GROUPED_RESOURCE_INDENT : UNGROUPED_RESOURCE_INDENT}
guestMetadata={guestMetadata()}
onCustomUrlChange={handleCustomUrlChange}
/>
);
};

View file

@ -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<string, GuestMetadata>;
// 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<string | null>(null);
@ -155,11 +170,12 @@ const PodRow: Component<{
cluster: KubernetesCluster;
pod: KubernetesPod;
columns: { isColumnVisible: (id: string) => boolean };
guestMetadata: Accessor<GuestMetadataRecord>;
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<string | undefined>(undefined);
const [historyRange, setHistoryRange] = createSignal<HistoryTimeRange>('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)}
/>
</div>
</div>
@ -391,6 +407,9 @@ export const KubernetesClusters: Component<KubernetesClustersProps> = (props) =>
// Column visibility for pods table
const podColumns = useColumnVisibility('k8s-pod-columns', POD_COLUMNS);
// Guest metadata for tracking custom URLs
const [guestMetadata, setGuestMetadata] = createSignal<GuestMetadataRecord>(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<KubernetesClustersProps> = (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<KubernetesClustersProps> = (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 (
<div class="space-y-4">
<Show when={clustersForAnalysis().length > 0}>
@ -1295,15 +1356,15 @@ export const KubernetesClusters: Component<KubernetesClustersProps> = (props) =>
<For each={filteredPods()} fallback={
<tr><td colSpan={8} class="px-4 py-8 text-center text-sm text-gray-500 dark:text-gray-400">No pods match the current filters.</td></tr>
}>
{({ cluster, pod }) => {
return (
<PodRow
cluster={cluster}
pod={pod}
columns={podColumns}
/>
);
}}
{({ cluster, pod }) => (
<PodRow
cluster={cluster}
pod={pod}
columns={podColumns}
guestMetadata={guestMetadata}
onCustomUrlChange={handleK8sCustomUrlChange}
/>
)}
</For>
</tbody>
</table>