diff --git a/frontend-modern/src/components/Dashboard/GuestDrawer.discovery.test.tsx b/frontend-modern/src/components/Dashboard/GuestDrawer.discovery.test.tsx new file mode 100644 index 000000000..1dcbe11ea --- /dev/null +++ b/frontend-modern/src/components/Dashboard/GuestDrawer.discovery.test.tsx @@ -0,0 +1,65 @@ +import { cleanup, fireEvent, render, screen } from '@solidjs/testing-library'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { GuestDrawer } from './GuestDrawer'; + +vi.mock('../Discovery/DiscoveryTab', () => ({ + DiscoveryTab: () =>
Discovery content
, +})); + +vi.mock('./DiskList', () => ({ + DiskList: () =>
, +})); + +vi.mock('../shared/HistoryChart', () => ({ + HistoryChart: () =>
, +})); + +vi.mock('@/stores/license', () => ({ + hasFeature: () => true, +})); + +describe('GuestDrawer discovery activation', () => { + afterEach(() => { + cleanup(); + }); + + it('does not mount DiscoveryTab until the discovery tab is opened', async () => { + render(() => ( + undefined} + /> + )); + + expect(screen.queryByTestId('discovery-tab')).not.toBeInTheDocument(); + + fireEvent.click(screen.getByRole('button', { name: 'Discovery' })); + + expect(await screen.findByTestId('discovery-tab')).toBeInTheDocument(); + }); +}); diff --git a/frontend-modern/src/components/Dashboard/GuestDrawer.tsx b/frontend-modern/src/components/Dashboard/GuestDrawer.tsx index a593194f0..8a73884ef 100644 --- a/frontend-modern/src/components/Dashboard/GuestDrawer.tsx +++ b/frontend-modern/src/components/Dashboard/GuestDrawer.tsx @@ -107,12 +107,16 @@ export const GuestDrawer: Component = (props) => { }; const [activeTab, setActiveTab] = createSignal<'overview' | 'discovery'>('overview'); + const [discoveryActivated, setDiscoveryActivated] = createSignal(false); // All tabs are always rendered (hidden via CSS) to avoid any DOM // mount/unmount during tab switches. Mounting new components inside // a -rendered table row causes SolidJS to recreate the row, // which detaches the element and resets the scroll container. const switchTab = (tab: 'overview' | 'discovery') => { + if (tab === 'discovery') { + setDiscoveryActivated(true); + } setActiveTab(tab); }; @@ -558,33 +562,34 @@ export const GuestDrawer: Component = (props) => {
- {/* Always rendered, hidden via CSS. Wrapped in a local Suspense - so DiscoveryTab's createResource loading state doesn't bubble - up to the app-level Suspense and replace the entire page. */} + {/* Defer discovery initialization until the tab is first opened, then keep it + mounted so subsequent tab switches don't churn the row. */}
- -
- - Loading discovery... - -
- } - > - props.onCustomUrlChange?.(guestId(), url)} - /> -
+ + +
+ + Loading discovery... + +
+ } + > + props.onCustomUrlChange?.(guestId(), url)} + /> +
+
); diff --git a/frontend-modern/src/components/Hosts/HostDrawer.tsx b/frontend-modern/src/components/Hosts/HostDrawer.tsx index c3d3975cd..2c95356df 100644 --- a/frontend-modern/src/components/Hosts/HostDrawer.tsx +++ b/frontend-modern/src/components/Hosts/HostDrawer.tsx @@ -17,6 +17,7 @@ interface HostDrawerProps { export const HostDrawer: Component = (props) => { const [activeTab, setActiveTab] = createSignal<'overview' | 'discovery'>('overview'); + const [discoveryActivated, setDiscoveryActivated] = createSignal(false); const [historyRange, setHistoryRange] = useDrawerHistoryRange(`host:${props.host.id}`); const [editingUrl, setEditingUrl] = createSignal(false); const [urlInput, setUrlInput] = createSignal(''); @@ -25,6 +26,9 @@ export const HostDrawer: Component = (props) => { const metricsResource = { type: 'host' as ResourceType, id: props.host.id }; const switchTab = (tab: 'overview' | 'discovery') => { + if (tab === 'discovery') { + setDiscoveryActivated(true); + } setActiveTab(tab); }; @@ -529,27 +533,29 @@ export const HostDrawer: Component = (props) => { class={activeTab() === 'discovery' ? '' : 'hidden'} style={{ 'overflow-anchor': 'none' }} > - -
- - Loading discovery... - -
- } - > - props.onCustomUrlChange?.(props.host.id, url)} - commandsEnabled={props.host.commandsEnabled} - /> -
+ + +
+ + Loading discovery... + +
+ } + > + props.onCustomUrlChange?.(props.host.id, url)} + commandsEnabled={props.host.commandsEnabled} + /> +
+
); diff --git a/frontend-modern/src/components/Kubernetes/KubernetesClusters.tsx b/frontend-modern/src/components/Kubernetes/KubernetesClusters.tsx index 3c8434e61..e09ac37a0 100644 --- a/frontend-modern/src/components/Kubernetes/KubernetesClusters.tsx +++ b/frontend-modern/src/components/Kubernetes/KubernetesClusters.tsx @@ -20,7 +20,11 @@ import { ScrollableTable } from '@/components/shared/ScrollableTable'; import { StatusDot } from '@/components/shared/StatusDot'; import { ColumnPicker } from '@/components/shared/ColumnPicker'; import { formatRelativeTime, formatBytes } from '@/utils/format'; -import { DEGRADED_HEALTH_STATUSES, OFFLINE_HEALTH_STATUSES, type StatusIndicator } from '@/utils/status'; +import { + DEGRADED_HEALTH_STATUSES, + OFFLINE_HEALTH_STATUSES, + type StatusIndicator, +} from '@/utils/status'; import { DiscoveryTab } from '@/components/Discovery/DiscoveryTab'; import { HistoryChart } from '@/components/shared/HistoryChart'; import type { HistoryTimeRange } from '@/api/charts'; @@ -111,29 +115,50 @@ const summarizeDeployments = (deployments: KubernetesDeployment[] | undefined) = const getPodStatusBadge = (pod: KubernetesPod) => { if (isPodHealthy(pod)) { - return { class: 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300', label: 'Running' }; + return { + class: 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300', + label: 'Running', + }; } const phase = normalize(pod.phase); if (phase === 'pending') { - return { class: 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/40 dark:text-yellow-300', label: 'Pending' }; + return { + class: 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/40 dark:text-yellow-300', + label: 'Pending', + }; } if (phase === 'failed') { - return { class: 'bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-300', label: 'Failed' }; + return { + class: 'bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-300', + label: 'Failed', + }; } if (phase === 'succeeded') { - return { class: 'bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300', label: 'Completed' }; + return { + class: 'bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300', + label: 'Completed', + }; } // Check for CrashLoopBackOff or other container issues const containers = pod.containers ?? []; const crashingContainer = containers.find((c) => c.reason?.toLowerCase().includes('crash')); if (crashingContainer) { - return { class: 'bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-300', label: 'CrashLoop' }; + return { + class: 'bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-300', + label: 'CrashLoop', + }; } const waitingContainer = containers.find((c) => normalize(c.state) === 'waiting'); if (waitingContainer) { - return { class: 'bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-300', label: waitingContainer.reason || 'Waiting' }; + return { + class: 'bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-300', + label: waitingContainer.reason || 'Waiting', + }; } - return { class: 'bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-300', label: pod.phase || 'Unknown' }; + return { + class: 'bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-300', + label: pod.phase || 'Unknown', + }; }; // Get primary container image (first container) @@ -176,16 +201,17 @@ const PodRow: Component<{ const rowId = `${props.cluster.id}:${props.pod.uid}`; const isExpanded = createMemo(() => expandedRowId() === rowId); const [activeTab, setActiveTab] = createSignal<'overview' | 'discovery'>('overview'); + const [discoveryActivated, setDiscoveryActivated] = createSignal(false); const [historyRange, setHistoryRange] = createSignal('1h'); const toggle = (e: MouseEvent) => { if ((e.target as HTMLElement).closest('a, button, input')) return; - setExpandedRowId(prev => prev === rowId ? null : rowId); + setExpandedRowId((prev) => (prev === rowId ? null : rowId)); }; const statusBadge = () => getPodStatusBadge(props.pod); const containers = () => props.pod.containers ?? []; - const readyContainers = () => containers().filter(c => c.ready).length; + const readyContainers = () => containers().filter((c) => c.ready).length; return ( <> @@ -194,7 +220,9 @@ const PodRow: Component<{ onClick={toggle} > -
{props.pod.name}
+
+ {props.pod.name} +
{props.pod.nodeName || 'unscheduled'}
@@ -212,27 +240,43 @@ const PodRow: Component<{ - + {statusBadge().label} - + {readyContainers()}/{containers().length} - 0} fallback={0}> - {props.pod.restarts} + 0} + fallback={0} + > + + {props.pod.restarts} + - + {getPrimaryImage(props.pod)} @@ -253,14 +297,21 @@ const PodRow: Component<{ @@ -274,7 +325,9 @@ const PodRow: Component<{

Details

ID
-
{props.pod.uid}
+
+ {props.pod.uid} +
QoS Class
{props.pod.qosClass || '—'}
@@ -284,7 +337,9 @@ const PodRow: Component<{
-

Containers

+

+ Containers +

{containers().length}
@@ -293,9 +348,13 @@ const PodRow: Component<{
{container.name} - {container.state} + + {container.state} + +
+
+ {container.image}
-
{container.image}
Restarts: {container.restartCount}
@@ -308,7 +367,13 @@ const PodRow: Component<{
- + @@ -316,7 +381,12 @@ const PodRow: Component<{ value={historyRange()} onChange={(e) => setHistoryRange(e.currentTarget.value as HistoryTimeRange)} class="text-[11px] font-medium pl-2 pr-6 py-1 rounded-md border border-gray-200 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-200 cursor-pointer focus:ring-1 focus:ring-blue-500 focus:border-blue-500 appearance-none" - style={{ "background-image": "url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%239ca3af' stroke-width='2'%3E%3Cpath d='M6 9l6 6 6-6'/%3E%3C/svg%3E\")", "background-repeat": "no-repeat", "background-position": "right 6px center" }} + style={{ + 'background-image': + "url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%239ca3af' stroke-width='2'%3E%3Cpath d='M6 9l6 6 6-6'/%3E%3C/svg%3E\")", + 'background-repeat': 'no-repeat', + 'background-position': 'right 6px center', + }} > @@ -368,15 +438,17 @@ const PodRow: Component<{
- props.onCustomUrlChange(props.pod.uid, url)} - /> + + props.onCustomUrlChange(props.pod.uid, url)} + /> +
@@ -402,16 +474,30 @@ export const KubernetesClusters: Component = (props) => const [analysisLoading, setAnalysisLoading] = createSignal(false); const [analysisResult, setAnalysisResult] = createSignal(''); const [analysisError, setAnalysisError] = createSignal(''); - const [analysisMeta, setAnalysisMeta] = createSignal<{ model: string; inputTokens: number; outputTokens: number } | null>(null); + const [analysisMeta, setAnalysisMeta] = createSignal<{ + model: string; + inputTokens: number; + outputTokens: number; + } | null>(null); // Column visibility for pods table const podColumns = useColumnVisibility('k8s-pod-columns', POD_COLUMNS); // Guest metadata for tracking custom URLs - const [guestMetadata, setGuestMetadata] = createSignal(getK8sGuestMetadataCache()); + const [guestMetadata, setGuestMetadata] = createSignal( + getK8sGuestMetadataCache(), + ); // Sorting state with persistence - type SortKey = 'name' | 'status' | 'namespace' | 'cluster' | 'age' | 'restarts' | 'ready' | 'replicas'; + type SortKey = + | 'name' + | 'status' + | 'namespace' + | 'cluster' + | 'age' + | 'restarts' + | 'ready' + | 'replicas'; type SortDir = 'asc' | 'desc'; const [sortKey, setSortKey] = usePersistentSignal('k8s-sort-key', 'name'); const [sortDirection, setSortDirection] = usePersistentSignal('k8s-sort-dir', 'asc'); @@ -425,7 +511,8 @@ export const KubernetesClusters: Component = (props) => } }; - const sortIndicator = (key: SortKey) => sortKey() === key ? (sortDirection() === 'asc' ? ' ▲' : ' ▼') : ''; + const sortIndicator = (key: SortKey) => + sortKey() === key ? (sortDirection() === 'asc' ? ' ▲' : ' ▼') : ''; // Search input ref for keyboard focus let searchInputRef: HTMLInputElement | undefined; @@ -499,12 +586,14 @@ export const KubernetesClusters: Component = (props) => 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); - }); + 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 () => { @@ -663,9 +752,15 @@ export const KubernetesClusters: Component = (props) => return filtered.sort((a, b) => { let cmp = 0; switch (key) { - case 'name': cmp = getClusterDisplayName(a).localeCompare(getClusterDisplayName(b)); break; - case 'status': cmp = (normalize(a.status) === 'online' ? 0 : 1) - (normalize(b.status) === 'online' ? 0 : 1); break; - default: cmp = getClusterDisplayName(a).localeCompare(getClusterDisplayName(b)); + case 'name': + cmp = getClusterDisplayName(a).localeCompare(getClusterDisplayName(b)); + break; + case 'status': + cmp = + (normalize(a.status) === 'online' ? 0 : 1) - (normalize(b.status) === 'online' ? 0 : 1); + break; + default: + cmp = getClusterDisplayName(a).localeCompare(getClusterDisplayName(b)); } return dir === 'desc' ? -cmp : cmp; }); @@ -703,10 +798,17 @@ export const KubernetesClusters: Component = (props) => return filtered.sort((a, b) => { let cmp = 0; switch (key) { - case 'name': cmp = (a.node.name ?? '').localeCompare(b.node.name ?? ''); break; - case 'cluster': cmp = getClusterDisplayName(a.cluster).localeCompare(getClusterDisplayName(b.cluster)); break; - case 'status': cmp = (a.node.ready ? 0 : 1) - (b.node.ready ? 0 : 1); break; - default: cmp = (a.node.name ?? '').localeCompare(b.node.name ?? ''); + case 'name': + cmp = (a.node.name ?? '').localeCompare(b.node.name ?? ''); + break; + case 'cluster': + cmp = getClusterDisplayName(a.cluster).localeCompare(getClusterDisplayName(b.cluster)); + break; + case 'status': + cmp = (a.node.ready ? 0 : 1) - (b.node.ready ? 0 : 1); + break; + default: + cmp = (a.node.name ?? '').localeCompare(b.node.name ?? ''); } return dir === 'desc' ? -cmp : cmp; }); @@ -736,7 +838,7 @@ export const KubernetesClusters: Component = (props) => pod.nodeName ?? '', pod.phase ?? '', getClusterDisplayName(cluster), - ...(pod.containers ?? []).map(c => c.image ?? ''), + ...(pod.containers ?? []).map((c) => c.image ?? ''), ] .join(' ') .toLowerCase(); @@ -747,13 +849,26 @@ export const KubernetesClusters: Component = (props) => return filtered.sort((a, b) => { let cmp = 0; switch (key) { - case 'name': cmp = (a.pod.name ?? '').localeCompare(b.pod.name ?? ''); break; - case 'namespace': cmp = (a.pod.namespace ?? '').localeCompare(b.pod.namespace ?? ''); break; - case 'cluster': cmp = getClusterDisplayName(a.cluster).localeCompare(getClusterDisplayName(b.cluster)); break; - case 'restarts': cmp = (a.pod.restarts ?? 0) - (b.pod.restarts ?? 0); break; - case 'age': cmp = (a.pod.createdAt ?? 0) - (b.pod.createdAt ?? 0); break; - case 'status': cmp = (isPodHealthy(a.pod) ? 0 : 1) - (isPodHealthy(b.pod) ? 0 : 1); break; - default: cmp = (a.pod.name ?? '').localeCompare(b.pod.name ?? ''); + case 'name': + cmp = (a.pod.name ?? '').localeCompare(b.pod.name ?? ''); + break; + case 'namespace': + cmp = (a.pod.namespace ?? '').localeCompare(b.pod.namespace ?? ''); + break; + case 'cluster': + cmp = getClusterDisplayName(a.cluster).localeCompare(getClusterDisplayName(b.cluster)); + break; + case 'restarts': + cmp = (a.pod.restarts ?? 0) - (b.pod.restarts ?? 0); + break; + case 'age': + cmp = (a.pod.createdAt ?? 0) - (b.pod.createdAt ?? 0); + break; + case 'status': + cmp = (isPodHealthy(a.pod) ? 0 : 1) - (isPodHealthy(b.pod) ? 0 : 1); + break; + default: + cmp = (a.pod.name ?? '').localeCompare(b.pod.name ?? ''); } return dir === 'desc' ? -cmp : cmp; }); @@ -777,11 +892,7 @@ export const KubernetesClusters: Component = (props) => }) .filter(({ cluster, deployment }) => { if (!term) return true; - const haystack = [ - deployment.name, - deployment.namespace, - getClusterDisplayName(cluster), - ] + const haystack = [deployment.name, deployment.namespace, getClusterDisplayName(cluster)] .join(' ') .toLowerCase(); return haystack.includes(term); @@ -791,13 +902,28 @@ export const KubernetesClusters: Component = (props) => return filtered.sort((a, b) => { let cmp = 0; switch (key) { - case 'name': cmp = (a.deployment.name ?? '').localeCompare(b.deployment.name ?? ''); break; - case 'namespace': cmp = (a.deployment.namespace ?? '').localeCompare(b.deployment.namespace ?? ''); break; - case 'cluster': cmp = getClusterDisplayName(a.cluster).localeCompare(getClusterDisplayName(b.cluster)); break; - case 'replicas': cmp = (a.deployment.desiredReplicas ?? 0) - (b.deployment.desiredReplicas ?? 0); break; - case 'ready': cmp = (a.deployment.readyReplicas ?? 0) - (b.deployment.readyReplicas ?? 0); break; - case 'status': cmp = (isDeploymentHealthy(a.deployment) ? 0 : 1) - (isDeploymentHealthy(b.deployment) ? 0 : 1); break; - default: cmp = (a.deployment.name ?? '').localeCompare(b.deployment.name ?? ''); + case 'name': + cmp = (a.deployment.name ?? '').localeCompare(b.deployment.name ?? ''); + break; + case 'namespace': + cmp = (a.deployment.namespace ?? '').localeCompare(b.deployment.namespace ?? ''); + break; + case 'cluster': + cmp = getClusterDisplayName(a.cluster).localeCompare(getClusterDisplayName(b.cluster)); + break; + case 'replicas': + cmp = (a.deployment.desiredReplicas ?? 0) - (b.deployment.desiredReplicas ?? 0); + break; + case 'ready': + cmp = (a.deployment.readyReplicas ?? 0) - (b.deployment.readyReplicas ?? 0); + break; + case 'status': + cmp = + (isDeploymentHealthy(a.deployment) ? 0 : 1) - + (isDeploymentHealthy(b.deployment) ? 0 : 1); + break; + default: + cmp = (a.deployment.name ?? '').localeCompare(b.deployment.name ?? ''); } return dir === 'desc' ? -cmp : cmp; }); @@ -806,7 +932,11 @@ export const KubernetesClusters: Component = (props) => const isEmpty = createMemo(() => (props.clusters?.length ?? 0) === 0); const hasActiveFilters = createMemo( - () => search().trim() !== '' || statusFilter() !== 'all' || showHidden() || namespaceFilter() !== 'all', + () => + search().trim() !== '' || + statusFilter() !== 'all' || + showHidden() || + namespaceFilter() !== 'all', ); const handleReset = () => { @@ -851,7 +981,8 @@ export const KubernetesClusters: Component = (props) =>
- Generate deep health insights and actionable remediation for your clusters using Pulse's advanced analysis engine. + Generate deep health insights and actionable remediation for your clusters using + Pulse's advanced analysis engine.
@@ -870,7 +1001,9 @@ export const KubernetesClusters: Component = (props) =>
- Synchronizing Pulse Assistant & License... + + Synchronizing Pulse Assistant & License... +
@@ -883,9 +1016,12 @@ export const KubernetesClusters: Component = (props) =>
-

Power up your Kubernetes Fleet

+

+ Power up your Kubernetes Fleet +

- Pulse Pro brings advanced diagnostics to your Kubernetes clusters. Identify bottlenecks, security risks, and configuration drift in seconds. + Pulse Pro brings advanced diagnostics to your Kubernetes clusters. Identify + bottlenecks, security risks, and configuration drift in seconds.

= (props) => type="button" onClick={handleAnalyzeCluster} disabled={analysisLoading() || !analysisClusterId() || !aiConfigured()} - class={`px-3 py-1.5 text-xs font-medium rounded-lg transition-colors ${analysisLoading() || !analysisClusterId() || !aiConfigured() - ? 'bg-gray-200 dark:bg-gray-700 text-gray-500 dark:text-gray-400 cursor-not-allowed' - : 'bg-blue-600 text-white hover:bg-blue-700' - }`} + class={`px-3 py-1.5 text-xs font-medium rounded-lg transition-colors ${ + analysisLoading() || !analysisClusterId() || !aiConfigured() + ? 'bg-gray-200 dark:bg-gray-700 text-gray-500 dark:text-gray-400 cursor-not-allowed' + : 'bg-blue-600 text-white hover:bg-blue-700' + }`} > {analysisLoading() ? 'Analyzing...' : 'Analyze'} - Running analysis... + + Running analysis... +
@@ -937,16 +1076,15 @@ export const KubernetesClusters: Component = (props) =>
-
- {analysisError()} -
+
{analysisError()}
- Model: {analysisMeta()!.model} · Tokens: {analysisMeta()!.inputTokens + analysisMeta()!.outputTokens} + Model: {analysisMeta()!.model} · Tokens:{' '} + {analysisMeta()!.inputTokens + analysisMeta()!.outputTokens}
@@ -999,7 +1137,12 @@ export const KubernetesClusters: Component = (props) => aria-label="Clear search" > - + @@ -1012,40 +1155,44 @@ export const KubernetesClusters: Component = (props) => @@ -1058,39 +1205,53 @@ export const KubernetesClusters: Component = (props) =>
{/* Namespace Filter - only show for pods/deployments */} - 1}> + 1 + } + >