@skyhook-io/radar-app 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +67 -0
- package/package.json +80 -0
- package/src/App.tsx +1538 -0
- package/src/RadarApp.tsx +145 -0
- package/src/api/apiResources.ts +28 -0
- package/src/api/client.ts +2583 -0
- package/src/api/config.ts +116 -0
- package/src/api/traffic.ts +139 -0
- package/src/components/ConnectionErrorView.tsx +272 -0
- package/src/components/ContextSwitcher.tsx +481 -0
- package/src/components/DebugOverlay.tsx +94 -0
- package/src/components/UserMenu.tsx +87 -0
- package/src/components/audit/AuditSettingsDialog.tsx +162 -0
- package/src/components/audit/AuditView.tsx +123 -0
- package/src/components/cost/CostTrendChart.tsx +388 -0
- package/src/components/cost/CostView.tsx +545 -0
- package/src/components/dock/BottomDock.tsx +96 -0
- package/src/components/dock/DockContext.tsx +11 -0
- package/src/components/dock/LocalTerminalTab.tsx +22 -0
- package/src/components/dock/LogsTab.tsx +26 -0
- package/src/components/dock/NodeTerminalTab.tsx +50 -0
- package/src/components/dock/TerminalTab.tsx +42 -0
- package/src/components/dock/TrafficFlowListTab.tsx +18 -0
- package/src/components/dock/WorkloadLogsTab.tsx +23 -0
- package/src/components/dock/index.ts +2 -0
- package/src/components/gitops/GitOpsActions.tsx +1 -0
- package/src/components/gitops/GitOpsStatusBadge.tsx +1 -0
- package/src/components/gitops/ManagedResourcesList.tsx +1 -0
- package/src/components/gitops/SyncCountdown.tsx +1 -0
- package/src/components/gitops/index.ts +4 -0
- package/src/components/helm/ChartBrowser.tsx +580 -0
- package/src/components/helm/HelmReleaseDrawer.tsx +774 -0
- package/src/components/helm/HelmView.tsx +475 -0
- package/src/components/helm/InstallWizard.tsx +1060 -0
- package/src/components/helm/ManifestDiffViewer.tsx +91 -0
- package/src/components/helm/ManifestViewer.tsx +61 -0
- package/src/components/helm/OwnedResources.tsx +465 -0
- package/src/components/helm/RevisionHistory.tsx +167 -0
- package/src/components/helm/ValuesDiffPreview.tsx +190 -0
- package/src/components/helm/ValuesViewer.tsx +365 -0
- package/src/components/helm/helm-utils.ts +37 -0
- package/src/components/home/ActivitySummary.tsx +262 -0
- package/src/components/home/CertificateHealthCard.tsx +105 -0
- package/src/components/home/ClusterHealthCard.tsx +483 -0
- package/src/components/home/CostCard.tsx +112 -0
- package/src/components/home/HealthRing.tsx +1 -0
- package/src/components/home/HelmSummary.tsx +129 -0
- package/src/components/home/HomeView.tsx +224 -0
- package/src/components/home/MCPSetupDialog.tsx +417 -0
- package/src/components/home/NetworkPolicyCoverageCard.tsx +109 -0
- package/src/components/home/TopologyPreview.tsx +219 -0
- package/src/components/home/TrafficSummary.tsx +154 -0
- package/src/components/logs/JsonLogLine.tsx +1 -0
- package/src/components/logs/LogCore.tsx +2 -0
- package/src/components/logs/LogsViewer.tsx +44 -0
- package/src/components/logs/WorkloadLogsViewer.tsx +40 -0
- package/src/components/logs/useLogBuffer.ts +2 -0
- package/src/components/logs/useLogSearch.ts +1 -0
- package/src/components/portforward/PortForwardButton.tsx +375 -0
- package/src/components/portforward/PortForwardManager.tsx +871 -0
- package/src/components/resource/PrometheusCharts.tsx +687 -0
- package/src/components/resource-drawer/ResourceDrawer.tsx +214 -0
- package/src/components/resources/ImageFilesystemModal.tsx +745 -0
- package/src/components/resources/PodFilesystemModal.tsx +407 -0
- package/src/components/resources/ResourceDetailDrawer.tsx +43 -0
- package/src/components/resources/ResourcesView.tsx +190 -0
- package/src/components/resources/drawer-components.tsx +1 -0
- package/src/components/resources/file-browser-utils.ts +35 -0
- package/src/components/resources/renderers/AlertRenderer.tsx +1 -0
- package/src/components/resources/renderers/ArgoApplicationRenderer.tsx +17 -0
- package/src/components/resources/renderers/CNPGBackupRenderer.tsx +1 -0
- package/src/components/resources/renderers/CNPGClusterRenderer.tsx +1 -0
- package/src/components/resources/renderers/CNPGPoolerRenderer.tsx +1 -0
- package/src/components/resources/renderers/CNPGScheduledBackupRenderer.tsx +1 -0
- package/src/components/resources/renderers/CertificateRenderer.tsx +1 -0
- package/src/components/resources/renderers/CertificateRequestRenderer.tsx +1 -0
- package/src/components/resources/renderers/ChallengeRenderer.tsx +1 -0
- package/src/components/resources/renderers/ClusterComplianceReportRenderer.tsx +1 -0
- package/src/components/resources/renderers/ClusterExternalSecretRenderer.tsx +1 -0
- package/src/components/resources/renderers/ClusterIssuerRenderer.tsx +1 -0
- package/src/components/resources/renderers/ConfigAuditReportRenderer.tsx +1 -0
- package/src/components/resources/renderers/ConfigMapRenderer.tsx +1 -0
- package/src/components/resources/renderers/CronJobRenderer.tsx +1 -0
- package/src/components/resources/renderers/EventRenderer.tsx +1 -0
- package/src/components/resources/renderers/ExposedSecretReportRenderer.tsx +1 -0
- package/src/components/resources/renderers/ExternalSecretRenderer.tsx +1 -0
- package/src/components/resources/renderers/FluxHelmReleaseRenderer.tsx +1 -0
- package/src/components/resources/renderers/GRPCRouteRenderer.tsx +1 -0
- package/src/components/resources/renderers/GatewayClassRenderer.tsx +1 -0
- package/src/components/resources/renderers/GatewayRenderer.tsx +1 -0
- package/src/components/resources/renderers/GenericRenderer.tsx +1 -0
- package/src/components/resources/renderers/GitRepositoryRenderer.tsx +1 -0
- package/src/components/resources/renderers/HPARenderer.tsx +1 -0
- package/src/components/resources/renderers/HTTPRouteRenderer.tsx +1 -0
- package/src/components/resources/renderers/HelmRepositoryRenderer.tsx +1 -0
- package/src/components/resources/renderers/IngressClassRenderer.tsx +1 -0
- package/src/components/resources/renderers/IngressRenderer.tsx +1 -0
- package/src/components/resources/renderers/IstioAuthorizationPolicyRenderer.tsx +1 -0
- package/src/components/resources/renderers/IstioDestinationRuleRenderer.tsx +1 -0
- package/src/components/resources/renderers/IstioGatewayRenderer.tsx +1 -0
- package/src/components/resources/renderers/IstioPeerAuthenticationRenderer.tsx +1 -0
- package/src/components/resources/renderers/IstioServiceEntryRenderer.tsx +1 -0
- package/src/components/resources/renderers/IstioVirtualServiceRenderer.tsx +1 -0
- package/src/components/resources/renderers/JobRenderer.tsx +1 -0
- package/src/components/resources/renderers/KarpenterEC2NodeClassRenderer.tsx +1 -0
- package/src/components/resources/renderers/KarpenterNodeClaimRenderer.tsx +1 -0
- package/src/components/resources/renderers/KarpenterNodePoolRenderer.tsx +1 -0
- package/src/components/resources/renderers/KedaScaledJobRenderer.tsx +1 -0
- package/src/components/resources/renderers/KedaScaledObjectRenderer.tsx +1 -0
- package/src/components/resources/renderers/KedaTriggerAuthRenderer.tsx +1 -0
- package/src/components/resources/renderers/KnativeConfigurationRenderer.tsx +1 -0
- package/src/components/resources/renderers/KnativeEventingRenderer.tsx +1 -0
- package/src/components/resources/renderers/KnativeFlowRenderer.tsx +1 -0
- package/src/components/resources/renderers/KnativeNetworkingRenderer.tsx +1 -0
- package/src/components/resources/renderers/KnativeRevisionRenderer.tsx +1 -0
- package/src/components/resources/renderers/KnativeRouteRenderer.tsx +1 -0
- package/src/components/resources/renderers/KnativeServiceRenderer.tsx +1 -0
- package/src/components/resources/renderers/KnativeSourceRenderer.tsx +1 -0
- package/src/components/resources/renderers/KustomizationRenderer.tsx +1 -0
- package/src/components/resources/renderers/KyvernoPolicyReportRenderer.tsx +1 -0
- package/src/components/resources/renderers/LeaseRenderer.tsx +1 -0
- package/src/components/resources/renderers/NetworkPolicyRenderer.tsx +1 -0
- package/src/components/resources/renderers/NodeRenderer.tsx +44 -0
- package/src/components/resources/renderers/OCIRepositoryRenderer.tsx +1 -0
- package/src/components/resources/renderers/OrderRenderer.tsx +1 -0
- package/src/components/resources/renderers/PVCRenderer.tsx +1 -0
- package/src/components/resources/renderers/PersistentVolumeRenderer.tsx +1 -0
- package/src/components/resources/renderers/PodDisruptionBudgetRenderer.tsx +1 -0
- package/src/components/resources/renderers/PodMonitorRenderer.tsx +1 -0
- package/src/components/resources/renderers/PodRenderer.tsx +94 -0
- package/src/components/resources/renderers/PriorityClassRenderer.tsx +1 -0
- package/src/components/resources/renderers/PrometheusRuleRenderer.tsx +1 -0
- package/src/components/resources/renderers/ReplicaSetRenderer.tsx +1 -0
- package/src/components/resources/renderers/RoleBindingRenderer.tsx +1 -0
- package/src/components/resources/renderers/RoleRenderer.tsx +1 -0
- package/src/components/resources/renderers/RolloutRenderer.tsx +1 -0
- package/src/components/resources/renderers/RuntimeClassRenderer.tsx +1 -0
- package/src/components/resources/renderers/SbomReportRenderer.tsx +1 -0
- package/src/components/resources/renderers/SealedSecretRenderer.tsx +1 -0
- package/src/components/resources/renderers/SecretRenderer.tsx +1 -0
- package/src/components/resources/renderers/SecretStoreRenderer.tsx +1 -0
- package/src/components/resources/renderers/ServiceAccountRenderer.tsx +1 -0
- package/src/components/resources/renderers/ServiceMonitorRenderer.tsx +1 -0
- package/src/components/resources/renderers/ServiceRenderer.tsx +26 -0
- package/src/components/resources/renderers/SimpleRouteRenderer.tsx +1 -0
- package/src/components/resources/renderers/StorageClassRenderer.tsx +1 -0
- package/src/components/resources/renderers/TraefikIngressRouteRenderer.tsx +1 -0
- package/src/components/resources/renderers/VPARenderer.tsx +1 -0
- package/src/components/resources/renderers/VeleroBSLRenderer.tsx +1 -0
- package/src/components/resources/renderers/VeleroBackupRenderer.tsx +1 -0
- package/src/components/resources/renderers/VeleroRestoreRenderer.tsx +1 -0
- package/src/components/resources/renderers/VeleroScheduleRenderer.tsx +1 -0
- package/src/components/resources/renderers/VeleroVSLRenderer.tsx +1 -0
- package/src/components/resources/renderers/VulnerabilityReportRenderer.tsx +1 -0
- package/src/components/resources/renderers/WebhookConfigRenderer.tsx +1 -0
- package/src/components/resources/renderers/WorkflowRenderer.tsx +1 -0
- package/src/components/resources/renderers/WorkflowTemplateRenderer.tsx +1 -0
- package/src/components/resources/renderers/WorkloadRenderer.tsx +52 -0
- package/src/components/resources/renderers/argo-cells.tsx +1 -0
- package/src/components/resources/renderers/certmanager-cells.tsx +1 -0
- package/src/components/resources/renderers/cnpg-cells.tsx +1 -0
- package/src/components/resources/renderers/eso-cells.tsx +1 -0
- package/src/components/resources/renderers/flux-cells.tsx +1 -0
- package/src/components/resources/renderers/index.ts +91 -0
- package/src/components/resources/renderers/istio-cells.tsx +1 -0
- package/src/components/resources/renderers/karpenter-cells.tsx +1 -0
- package/src/components/resources/renderers/keda-cells.tsx +1 -0
- package/src/components/resources/renderers/knative-cells.tsx +1 -0
- package/src/components/resources/renderers/kyverno-cells.tsx +1 -0
- package/src/components/resources/renderers/prometheus-cells.tsx +1 -0
- package/src/components/resources/renderers/traefik-cells.tsx +1 -0
- package/src/components/resources/renderers/trivy-cells.tsx +1 -0
- package/src/components/resources/renderers/trivy-shared.tsx +1 -0
- package/src/components/resources/renderers/velero-cells.tsx +1 -0
- package/src/components/resources/resource-utils-argo.ts +2 -0
- package/src/components/resources/resource-utils-certmanager.ts +2 -0
- package/src/components/resources/resource-utils-cnpg.ts +2 -0
- package/src/components/resources/resource-utils-eso.ts +2 -0
- package/src/components/resources/resource-utils-flux.ts +2 -0
- package/src/components/resources/resource-utils-istio.ts +2 -0
- package/src/components/resources/resource-utils-karpenter.ts +2 -0
- package/src/components/resources/resource-utils-keda.ts +2 -0
- package/src/components/resources/resource-utils-knative.ts +2 -0
- package/src/components/resources/resource-utils-kyverno.ts +2 -0
- package/src/components/resources/resource-utils-prometheus.ts +2 -0
- package/src/components/resources/resource-utils-traefik.ts +1 -0
- package/src/components/resources/resource-utils-trivy.ts +2 -0
- package/src/components/resources/resource-utils-velero.ts +2 -0
- package/src/components/resources/resource-utils.ts +5 -0
- package/src/components/settings/SettingsDialog.tsx +537 -0
- package/src/components/shared/CreateResourceDialog.tsx +17 -0
- package/src/components/shared/EditableYamlView.tsx +24 -0
- package/src/components/shared/LargeClusterNamespacePicker.tsx +70 -0
- package/src/components/shared/ResourceRendererDispatch.tsx +31 -0
- package/src/components/timeline/DiffViewer.tsx +1 -0
- package/src/components/timeline/TimelineList.tsx +69 -0
- package/src/components/timeline/TimelineSwimlanes.tsx +1308 -0
- package/src/components/timeline/TimelineView.tsx +157 -0
- package/src/components/timeline/shared.tsx +1 -0
- package/src/components/traffic/TrafficFilterSidebar.tsx +571 -0
- package/src/components/traffic/TrafficFlowList.tsx +415 -0
- package/src/components/traffic/TrafficFlowListContext.tsx +68 -0
- package/src/components/traffic/TrafficGraph.tsx +1546 -0
- package/src/components/traffic/TrafficView.tsx +1213 -0
- package/src/components/traffic/TrafficWizard.tsx +386 -0
- package/src/components/traffic/index.ts +3 -0
- package/src/components/ui/CodeViewer.tsx +8 -0
- package/src/components/ui/CommandPalette.tsx +460 -0
- package/src/components/ui/ConfirmDialog.tsx +1 -0
- package/src/components/ui/DiagnosticsOverlay.tsx +619 -0
- package/src/components/ui/ErrorBoundary.tsx +46 -0
- package/src/components/ui/ForceDeleteConfirmDialog.tsx +1 -0
- package/src/components/ui/Markdown.tsx +108 -0
- package/src/components/ui/MetricsChart.tsx +1 -0
- package/src/components/ui/NamespaceSelector.tsx +436 -0
- package/src/components/ui/ResourceBar.tsx +1 -0
- package/src/components/ui/ShortcutHelpOverlay.tsx +301 -0
- package/src/components/ui/Toast.tsx +1 -0
- package/src/components/ui/Tooltip.tsx +1 -0
- package/src/components/ui/UpdateNotification.tsx +299 -0
- package/src/components/ui/YamlEditor.tsx +1 -0
- package/src/components/workload/WorkloadView.tsx +532 -0
- package/src/context/ConnectionContext.tsx +173 -0
- package/src/context/ContextSwitchContext.tsx +56 -0
- package/src/context/NavCustomization.tsx +62 -0
- package/src/context/ThemeContext.tsx +97 -0
- package/src/contexts/CapabilitiesContext.tsx +130 -0
- package/src/hooks/useAnimatedUnmount.ts +1 -0
- package/src/hooks/useDesktopDownload.ts +41 -0
- package/src/hooks/useEventSource.ts +262 -0
- package/src/hooks/useFavorites.ts +69 -0
- package/src/hooks/useKeyboardShortcuts.tsx +7 -0
- package/src/hooks/useRefreshAnimation.ts +1 -0
- package/src/index.css +243 -0
- package/src/index.ts +17 -0
- package/src/main.tsx +158 -0
- package/src/types/gitops.ts +2 -0
- package/src/types.ts +3 -0
- package/src/utils/animation.ts +2 -0
- package/src/utils/badge-colors.ts +2 -0
- package/src/utils/context-name.ts +2 -0
- package/src/utils/desktop-download.ts +66 -0
- package/src/utils/desktop-open-folder.ts +21 -0
- package/src/utils/format.ts +2 -0
- package/src/utils/log-format.ts +12 -0
- package/src/utils/navigation.ts +23 -0
- package/src/utils/resource-hierarchy.ts +2 -0
- package/src/utils/resource-icons.ts +2 -0
- package/src/utils/skeleton-yaml.ts +2 -0
- package/src/utils/traffic-colors.ts +54 -0
- package/src/vite-env.d.ts +1 -0
|
@@ -0,0 +1,745 @@
|
|
|
1
|
+
import { useState, useRef, useEffect, useMemo, useCallback } from 'react'
|
|
2
|
+
import { createPortal } from 'react-dom'
|
|
3
|
+
import { X, Folder, File, Link2, ChevronRight, ChevronDown, AlertTriangle, Loader2, Search, Download, HardDrive, Shield, ShieldCheck, Terminal, Copy, Check, RefreshCw } from 'lucide-react'
|
|
4
|
+
import { clsx } from 'clsx'
|
|
5
|
+
import { useImageMetadata, ApiError } from '../../api/client'
|
|
6
|
+
import type { FileNode, ImageFilesystem } from '../../types'
|
|
7
|
+
import { formatBytes } from '../../utils/format'
|
|
8
|
+
import { downloadBlob, filterTree } from './file-browser-utils'
|
|
9
|
+
import { apiUrl, getAuthHeaders, getCredentialsMode } from '../../api/config'
|
|
10
|
+
|
|
11
|
+
// Manual fetch function for filesystem (not a hook - gives us full control)
|
|
12
|
+
async function fetchImageFilesystem(
|
|
13
|
+
image: string,
|
|
14
|
+
namespace: string,
|
|
15
|
+
podName: string,
|
|
16
|
+
pullSecrets: string[]
|
|
17
|
+
): Promise<ImageFilesystem> {
|
|
18
|
+
const params = new URLSearchParams()
|
|
19
|
+
params.set('image', image)
|
|
20
|
+
if (namespace) params.set('namespace', namespace)
|
|
21
|
+
if (podName) params.set('pod', podName)
|
|
22
|
+
if (pullSecrets.length > 0) params.set('pullSecrets', pullSecrets.join(','))
|
|
23
|
+
|
|
24
|
+
const response = await fetch(apiUrl(`/images/inspect?${params.toString()}`), {
|
|
25
|
+
credentials: getCredentialsMode(),
|
|
26
|
+
headers: getAuthHeaders(),
|
|
27
|
+
})
|
|
28
|
+
if (!response.ok) {
|
|
29
|
+
const error = await response.json().catch(() => ({ error: 'Request failed' }))
|
|
30
|
+
throw new Error(error.error || `HTTP ${response.status}`)
|
|
31
|
+
}
|
|
32
|
+
return response.json()
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
interface ImageFilesystemModalProps {
|
|
36
|
+
open: boolean
|
|
37
|
+
onClose: () => void
|
|
38
|
+
image: string
|
|
39
|
+
namespace: string
|
|
40
|
+
podName: string
|
|
41
|
+
pullSecrets: string[]
|
|
42
|
+
onSwitchToPodFiles?: () => void
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function ImageFilesystemModal({
|
|
46
|
+
open,
|
|
47
|
+
onClose,
|
|
48
|
+
image,
|
|
49
|
+
namespace,
|
|
50
|
+
podName,
|
|
51
|
+
pullSecrets,
|
|
52
|
+
onSwitchToPodFiles,
|
|
53
|
+
}: ImageFilesystemModalProps) {
|
|
54
|
+
const dialogRef = useRef<HTMLDivElement>(null)
|
|
55
|
+
const [searchQuery, setSearchQuery] = useState('')
|
|
56
|
+
|
|
57
|
+
// Manual fetch state (no automatic React Query fetching)
|
|
58
|
+
const [filesystem, setFilesystem] = useState<ImageFilesystem | null>(null)
|
|
59
|
+
const [isLoadingFilesystem, setIsLoadingFilesystem] = useState(false)
|
|
60
|
+
const [filesystemError, setFilesystemError] = useState<Error | null>(null)
|
|
61
|
+
|
|
62
|
+
// First, fetch metadata (lightweight)
|
|
63
|
+
const {
|
|
64
|
+
data: metadata,
|
|
65
|
+
isLoading: isLoadingMetadata,
|
|
66
|
+
error: metadataError,
|
|
67
|
+
refetch: refetchMetadata,
|
|
68
|
+
} = useImageMetadata(image, namespace, podName, pullSecrets, open)
|
|
69
|
+
|
|
70
|
+
// Detect auth errors from metadata fetch
|
|
71
|
+
const isAuthError = metadataError instanceof ApiError && metadataError.status === 401
|
|
72
|
+
const registryType = isAuthError ? (metadataError.data?.registryType as string) : undefined
|
|
73
|
+
|
|
74
|
+
// Use cached filesystem from metadata if available
|
|
75
|
+
const displayFilesystem: ImageFilesystem | undefined = metadata?.cached
|
|
76
|
+
? metadata.filesystem
|
|
77
|
+
: filesystem || undefined
|
|
78
|
+
|
|
79
|
+
// Manual fetch triggered by user clicking "Download & View"
|
|
80
|
+
const handleApproveDownload = useCallback(async () => {
|
|
81
|
+
setIsLoadingFilesystem(true)
|
|
82
|
+
setFilesystemError(null)
|
|
83
|
+
try {
|
|
84
|
+
const result = await fetchImageFilesystem(image, namespace, podName, pullSecrets)
|
|
85
|
+
setFilesystem(result)
|
|
86
|
+
} catch (err) {
|
|
87
|
+
setFilesystemError(err instanceof Error ? err : new Error('Failed to fetch filesystem'))
|
|
88
|
+
} finally {
|
|
89
|
+
setIsLoadingFilesystem(false)
|
|
90
|
+
}
|
|
91
|
+
}, [image, namespace, podName, pullSecrets])
|
|
92
|
+
|
|
93
|
+
// Reset state when modal closes or image changes
|
|
94
|
+
useEffect(() => {
|
|
95
|
+
if (!open) {
|
|
96
|
+
setSearchQuery('')
|
|
97
|
+
setFilesystem(null)
|
|
98
|
+
setFilesystemError(null)
|
|
99
|
+
setIsLoadingFilesystem(false)
|
|
100
|
+
}
|
|
101
|
+
}, [open, image])
|
|
102
|
+
|
|
103
|
+
// Handle ESC key
|
|
104
|
+
useEffect(() => {
|
|
105
|
+
if (!open) return
|
|
106
|
+
const handleKeyDown = (e: KeyboardEvent) => {
|
|
107
|
+
if (e.key === 'Escape') { e.stopPropagation(); onClose() }
|
|
108
|
+
}
|
|
109
|
+
document.addEventListener('keydown', handleKeyDown, true)
|
|
110
|
+
return () => document.removeEventListener('keydown', handleKeyDown, true)
|
|
111
|
+
}, [open, onClose])
|
|
112
|
+
|
|
113
|
+
// Focus trap
|
|
114
|
+
useEffect(() => {
|
|
115
|
+
if (open && dialogRef.current) {
|
|
116
|
+
dialogRef.current.focus()
|
|
117
|
+
}
|
|
118
|
+
}, [open])
|
|
119
|
+
|
|
120
|
+
if (!open) return null
|
|
121
|
+
|
|
122
|
+
const error = metadataError || filesystemError
|
|
123
|
+
const isLoading = isLoadingMetadata || isLoadingFilesystem
|
|
124
|
+
// Show confirmation when: metadata loaded, not cached, no filesystem yet, no error
|
|
125
|
+
const showConfirmation = metadata && !metadata.cached && !filesystem && !isLoadingFilesystem && !error
|
|
126
|
+
const showFilesystem = displayFilesystem && displayFilesystem.root
|
|
127
|
+
|
|
128
|
+
return createPortal(
|
|
129
|
+
<div className="fixed inset-0 z-[100] flex items-center justify-center">
|
|
130
|
+
{/* Backdrop */}
|
|
131
|
+
<div className="absolute inset-0 bg-black/60 backdrop-blur-sm" onClick={onClose} />
|
|
132
|
+
|
|
133
|
+
{/* Modal */}
|
|
134
|
+
<div
|
|
135
|
+
ref={dialogRef}
|
|
136
|
+
tabIndex={-1}
|
|
137
|
+
className="relative dialog w-full max-w-4xl mx-4 max-h-[85vh] flex flex-col outline-none"
|
|
138
|
+
>
|
|
139
|
+
{/* Header */}
|
|
140
|
+
<div className="flex items-center justify-between p-4 border-b border-theme-border shrink-0">
|
|
141
|
+
<div className="flex-1 min-w-0">
|
|
142
|
+
<h3 className="text-lg font-semibold text-theme-text-primary">Image Filesystem</h3>
|
|
143
|
+
<p className="text-sm text-theme-text-secondary truncate mt-0.5" title={image}>
|
|
144
|
+
{image}
|
|
145
|
+
</p>
|
|
146
|
+
{(displayFilesystem?.platform || metadata?.platform) && (
|
|
147
|
+
<p className="text-xs text-theme-text-tertiary mt-1">
|
|
148
|
+
Platform: {displayFilesystem?.platform || metadata?.platform}
|
|
149
|
+
</p>
|
|
150
|
+
)}
|
|
151
|
+
</div>
|
|
152
|
+
<button
|
|
153
|
+
onClick={onClose}
|
|
154
|
+
className="p-2 text-theme-text-secondary hover:text-theme-text-primary hover:bg-theme-elevated rounded ml-4"
|
|
155
|
+
>
|
|
156
|
+
<X className="w-5 h-5" />
|
|
157
|
+
</button>
|
|
158
|
+
</div>
|
|
159
|
+
|
|
160
|
+
{/* Search bar - only show when filesystem is loaded */}
|
|
161
|
+
{showFilesystem && (
|
|
162
|
+
<div className="p-3 border-b border-theme-border shrink-0">
|
|
163
|
+
<div className="relative">
|
|
164
|
+
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-theme-text-tertiary" />
|
|
165
|
+
<input
|
|
166
|
+
type="text"
|
|
167
|
+
placeholder="Search files..."
|
|
168
|
+
value={searchQuery}
|
|
169
|
+
onChange={(e) => setSearchQuery(e.target.value)}
|
|
170
|
+
className="w-full pl-10 pr-4 py-2 bg-theme-base border border-theme-border rounded-lg text-sm text-theme-text-primary placeholder-theme-text-tertiary focus:outline-none focus:ring-2 focus:ring-blue-500"
|
|
171
|
+
/>
|
|
172
|
+
</div>
|
|
173
|
+
</div>
|
|
174
|
+
)}
|
|
175
|
+
|
|
176
|
+
{/* Content */}
|
|
177
|
+
<div className="flex-1 overflow-y-auto p-4">
|
|
178
|
+
{/* Loading state */}
|
|
179
|
+
{isLoading && (
|
|
180
|
+
<div className="flex flex-col items-center justify-center h-64">
|
|
181
|
+
<Loader2 className="w-8 h-8 text-blue-400 animate-spin" />
|
|
182
|
+
<span className="mt-3 text-theme-text-secondary">
|
|
183
|
+
{isLoadingMetadata ? 'Checking image...' : 'Downloading image layers...'}
|
|
184
|
+
</span>
|
|
185
|
+
{isLoadingFilesystem && metadata && (
|
|
186
|
+
<span className="mt-1 text-xs text-theme-text-tertiary">
|
|
187
|
+
This may take a moment for large images
|
|
188
|
+
</span>
|
|
189
|
+
)}
|
|
190
|
+
</div>
|
|
191
|
+
)}
|
|
192
|
+
|
|
193
|
+
{/* Auth error - show guidance instead of generic error */}
|
|
194
|
+
{isAuthError && (
|
|
195
|
+
<AuthenticationHelp
|
|
196
|
+
image={image}
|
|
197
|
+
registryType={registryType}
|
|
198
|
+
onRetry={() => refetchMetadata()}
|
|
199
|
+
/>
|
|
200
|
+
)}
|
|
201
|
+
|
|
202
|
+
{/* Non-auth errors keep the existing red error box */}
|
|
203
|
+
{error && !isAuthError && (
|
|
204
|
+
<div className="p-4 bg-red-500/10 border border-red-500/30 rounded-lg">
|
|
205
|
+
<div className="flex items-start gap-3">
|
|
206
|
+
<AlertTriangle className="w-5 h-5 text-red-400 shrink-0 mt-0.5" />
|
|
207
|
+
<div>
|
|
208
|
+
<div className="font-medium text-red-400">Failed to inspect image</div>
|
|
209
|
+
<div className="text-sm text-theme-text-secondary mt-1">
|
|
210
|
+
{error instanceof Error ? error.message : 'Unknown error'}
|
|
211
|
+
</div>
|
|
212
|
+
</div>
|
|
213
|
+
</div>
|
|
214
|
+
</div>
|
|
215
|
+
)}
|
|
216
|
+
|
|
217
|
+
{/* Confirmation dialog - image not cached */}
|
|
218
|
+
{showConfirmation && (
|
|
219
|
+
<DownloadConfirmation
|
|
220
|
+
metadata={metadata}
|
|
221
|
+
onConfirm={handleApproveDownload}
|
|
222
|
+
onCancel={onClose}
|
|
223
|
+
/>
|
|
224
|
+
)}
|
|
225
|
+
|
|
226
|
+
{/* Filesystem tree */}
|
|
227
|
+
{showFilesystem && (
|
|
228
|
+
<FileTreeView
|
|
229
|
+
root={displayFilesystem.root}
|
|
230
|
+
searchQuery={searchQuery}
|
|
231
|
+
image={image}
|
|
232
|
+
namespace={namespace}
|
|
233
|
+
podName={podName}
|
|
234
|
+
pullSecrets={pullSecrets}
|
|
235
|
+
/>
|
|
236
|
+
)}
|
|
237
|
+
</div>
|
|
238
|
+
|
|
239
|
+
{/* Footer with stats */}
|
|
240
|
+
<div className="p-3 border-t border-theme-border text-xs text-theme-text-tertiary flex items-center gap-4 shrink-0">
|
|
241
|
+
{displayFilesystem && (
|
|
242
|
+
<>
|
|
243
|
+
<span>{displayFilesystem.totalFiles.toLocaleString()} files</span>
|
|
244
|
+
<span>{formatBytes(displayFilesystem.totalSize)}</span>
|
|
245
|
+
{displayFilesystem.layers && <span>{displayFilesystem.layers.length} layers</span>}
|
|
246
|
+
{displayFilesystem.digest && (
|
|
247
|
+
<span className="truncate" title={displayFilesystem.digest}>
|
|
248
|
+
Digest: {displayFilesystem.digest.substring(0, 20)}...
|
|
249
|
+
</span>
|
|
250
|
+
)}
|
|
251
|
+
</>
|
|
252
|
+
)}
|
|
253
|
+
{onSwitchToPodFiles && (
|
|
254
|
+
<button
|
|
255
|
+
onClick={() => { onClose(); onSwitchToPodFiles() }}
|
|
256
|
+
className="ml-auto text-blue-400 hover:text-blue-300 hover:underline"
|
|
257
|
+
>
|
|
258
|
+
Browse live files from running pod →
|
|
259
|
+
</button>
|
|
260
|
+
)}
|
|
261
|
+
</div>
|
|
262
|
+
</div>
|
|
263
|
+
</div>,
|
|
264
|
+
document.body,
|
|
265
|
+
)
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// ============================================================================
|
|
269
|
+
// Download Confirmation Component
|
|
270
|
+
// ============================================================================
|
|
271
|
+
|
|
272
|
+
interface DownloadConfirmationProps {
|
|
273
|
+
metadata: {
|
|
274
|
+
image: string
|
|
275
|
+
digest: string
|
|
276
|
+
platform: string
|
|
277
|
+
totalSize: number
|
|
278
|
+
layerCount: number
|
|
279
|
+
authMethod: string
|
|
280
|
+
}
|
|
281
|
+
onConfirm: () => void
|
|
282
|
+
onCancel: () => void
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function DownloadConfirmation({ metadata, onConfirm, onCancel }: DownloadConfirmationProps) {
|
|
286
|
+
const isPublic = metadata.authMethod === 'anonymous'
|
|
287
|
+
const authCommand = !isPublic ? getAuthCommand(metadata.authMethod, metadata.image) : null
|
|
288
|
+
const [copied, setCopied] = useState(false)
|
|
289
|
+
|
|
290
|
+
const handleCopy = async () => {
|
|
291
|
+
if (!authCommand) return
|
|
292
|
+
try {
|
|
293
|
+
await navigator.clipboard.writeText(authCommand)
|
|
294
|
+
setCopied(true)
|
|
295
|
+
setTimeout(() => setCopied(false), 2000)
|
|
296
|
+
} catch (err) {
|
|
297
|
+
console.error('Failed to copy:', err)
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
return (
|
|
302
|
+
<div className="flex flex-col items-center justify-center py-8">
|
|
303
|
+
<HardDrive className="w-16 h-16 text-blue-400 mb-4" />
|
|
304
|
+
|
|
305
|
+
<h4 className="text-lg font-medium text-theme-text-primary mb-2">
|
|
306
|
+
Download Image Layers?
|
|
307
|
+
</h4>
|
|
308
|
+
|
|
309
|
+
<p className="text-sm text-theme-text-secondary text-center max-w-md mb-6">
|
|
310
|
+
This image is not cached locally. To view the filesystem, the image layers need to be downloaded.
|
|
311
|
+
</p>
|
|
312
|
+
|
|
313
|
+
{/* Image info */}
|
|
314
|
+
<div className="bg-theme-base border border-theme-border rounded-lg p-4 mb-6 w-full max-w-sm">
|
|
315
|
+
<div className="space-y-2 text-sm">
|
|
316
|
+
<div className="flex justify-between">
|
|
317
|
+
<span className="text-theme-text-tertiary">Download Size:</span>
|
|
318
|
+
<span className="text-theme-text-primary font-medium">{formatBytes(metadata.totalSize)}</span>
|
|
319
|
+
</div>
|
|
320
|
+
<div className="flex justify-between">
|
|
321
|
+
<span className="text-theme-text-tertiary">Layers:</span>
|
|
322
|
+
<span className="text-theme-text-primary">{metadata.layerCount}</span>
|
|
323
|
+
</div>
|
|
324
|
+
<div className="flex justify-between">
|
|
325
|
+
<span className="text-theme-text-tertiary">Platform:</span>
|
|
326
|
+
<span className="text-theme-text-primary">{metadata.platform}</span>
|
|
327
|
+
</div>
|
|
328
|
+
<div className="flex justify-between items-center">
|
|
329
|
+
<span className="text-theme-text-tertiary">Access:</span>
|
|
330
|
+
<span className={clsx(
|
|
331
|
+
'flex items-center gap-1',
|
|
332
|
+
isPublic ? 'text-green-400' : 'text-amber-400'
|
|
333
|
+
)}>
|
|
334
|
+
{isPublic ? (
|
|
335
|
+
<>
|
|
336
|
+
<ShieldCheck className="w-3.5 h-3.5" />
|
|
337
|
+
Public
|
|
338
|
+
</>
|
|
339
|
+
) : (
|
|
340
|
+
<>
|
|
341
|
+
<Shield className="w-3.5 h-3.5" />
|
|
342
|
+
{formatAuthMethod(metadata.authMethod)}
|
|
343
|
+
</>
|
|
344
|
+
)}
|
|
345
|
+
</span>
|
|
346
|
+
</div>
|
|
347
|
+
</div>
|
|
348
|
+
</div>
|
|
349
|
+
|
|
350
|
+
{/* Auth command for private registries */}
|
|
351
|
+
{authCommand && (
|
|
352
|
+
<div className="bg-theme-base border border-amber-500/30 rounded-lg p-4 mb-6 w-full max-w-lg">
|
|
353
|
+
<div className="flex items-center gap-2 text-amber-400 text-sm mb-2">
|
|
354
|
+
<Terminal className="w-4 h-4" />
|
|
355
|
+
<span className="font-medium">Authentication Command</span>
|
|
356
|
+
</div>
|
|
357
|
+
<p className="text-xs text-theme-text-secondary mb-3">
|
|
358
|
+
Run this command to configure authentication for this registry:
|
|
359
|
+
</p>
|
|
360
|
+
<div className="relative">
|
|
361
|
+
<pre className="bg-theme-elevated rounded p-3 text-xs text-theme-text-primary overflow-x-auto font-mono">
|
|
362
|
+
{authCommand}
|
|
363
|
+
</pre>
|
|
364
|
+
<button
|
|
365
|
+
onClick={handleCopy}
|
|
366
|
+
className="absolute top-2 right-2 p-1.5 text-theme-text-tertiary hover:text-theme-text-primary hover:bg-theme-base rounded transition-colors"
|
|
367
|
+
title="Copy to clipboard"
|
|
368
|
+
>
|
|
369
|
+
{copied ? (
|
|
370
|
+
<Check className="w-4 h-4 text-green-400" />
|
|
371
|
+
) : (
|
|
372
|
+
<Copy className="w-4 h-4" />
|
|
373
|
+
)}
|
|
374
|
+
</button>
|
|
375
|
+
</div>
|
|
376
|
+
</div>
|
|
377
|
+
)}
|
|
378
|
+
|
|
379
|
+
{/* Actions */}
|
|
380
|
+
<div className="flex gap-3">
|
|
381
|
+
<button
|
|
382
|
+
onClick={onCancel}
|
|
383
|
+
className="px-4 py-2 text-sm text-theme-text-secondary hover:text-theme-text-primary border border-theme-border rounded-lg hover:bg-theme-elevated transition-colors"
|
|
384
|
+
>
|
|
385
|
+
Cancel
|
|
386
|
+
</button>
|
|
387
|
+
<button
|
|
388
|
+
onClick={onConfirm}
|
|
389
|
+
className="px-4 py-2 text-sm btn-brand rounded-lg"
|
|
390
|
+
>
|
|
391
|
+
Download & View
|
|
392
|
+
</button>
|
|
393
|
+
</div>
|
|
394
|
+
</div>
|
|
395
|
+
)
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
// ============================================================================
|
|
399
|
+
// Authentication Help Component (shown on 401 errors)
|
|
400
|
+
// ============================================================================
|
|
401
|
+
|
|
402
|
+
interface AuthenticationHelpProps {
|
|
403
|
+
image: string
|
|
404
|
+
registryType?: string
|
|
405
|
+
onRetry: () => void
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
function AuthenticationHelp({ image, registryType, onRetry }: AuthenticationHelpProps) {
|
|
409
|
+
const [copied, setCopied] = useState(false)
|
|
410
|
+
const [retrying, setRetrying] = useState(false)
|
|
411
|
+
const registry = getRegistryHost(image)
|
|
412
|
+
const authMethod = registryType || 'generic'
|
|
413
|
+
const authCommand = getAuthCommand(authMethod, image)
|
|
414
|
+
|
|
415
|
+
const handleCopy = async () => {
|
|
416
|
+
if (!authCommand) return
|
|
417
|
+
try {
|
|
418
|
+
await navigator.clipboard.writeText(authCommand)
|
|
419
|
+
setCopied(true)
|
|
420
|
+
setTimeout(() => setCopied(false), 2000)
|
|
421
|
+
} catch (err) {
|
|
422
|
+
console.error('Failed to copy:', err)
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
const handleRetry = () => {
|
|
427
|
+
setRetrying(true)
|
|
428
|
+
onRetry()
|
|
429
|
+
// Reset after a short delay (the query state will update via React Query)
|
|
430
|
+
setTimeout(() => setRetrying(false), 1000)
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
return (
|
|
434
|
+
<div className="flex flex-col items-center justify-center py-8">
|
|
435
|
+
<Shield className="w-16 h-16 text-amber-400 mb-4" />
|
|
436
|
+
|
|
437
|
+
<h4 className="text-lg font-medium text-theme-text-primary mb-2">
|
|
438
|
+
Authentication Required
|
|
439
|
+
</h4>
|
|
440
|
+
|
|
441
|
+
<p className="text-sm text-theme-text-secondary text-center max-w-md mb-2">
|
|
442
|
+
This image is hosted on a private registry that requires authentication.
|
|
443
|
+
</p>
|
|
444
|
+
|
|
445
|
+
<p className="text-xs text-theme-text-tertiary text-center max-w-md mb-6">
|
|
446
|
+
Registry: <span className="font-mono text-theme-text-secondary">{registry}</span>
|
|
447
|
+
{registryType && registryType !== 'generic' && (
|
|
448
|
+
<> ({formatAuthMethod(registryType)})</>
|
|
449
|
+
)}
|
|
450
|
+
</p>
|
|
451
|
+
|
|
452
|
+
{/* Auth command */}
|
|
453
|
+
{authCommand && (
|
|
454
|
+
<div className="bg-theme-base border border-amber-500/30 rounded-lg p-4 mb-4 w-full max-w-lg">
|
|
455
|
+
<div className="flex items-center gap-2 text-amber-400 text-sm mb-2">
|
|
456
|
+
<Terminal className="w-4 h-4" />
|
|
457
|
+
<span className="font-medium">Run this command to authenticate</span>
|
|
458
|
+
</div>
|
|
459
|
+
<div className="relative">
|
|
460
|
+
<pre className="bg-theme-elevated rounded p-3 text-xs text-theme-text-primary overflow-x-auto font-mono">
|
|
461
|
+
{authCommand}
|
|
462
|
+
</pre>
|
|
463
|
+
<button
|
|
464
|
+
onClick={handleCopy}
|
|
465
|
+
className="absolute top-2 right-2 p-1.5 text-theme-text-tertiary hover:text-theme-text-primary hover:bg-theme-base rounded transition-colors"
|
|
466
|
+
title="Copy to clipboard"
|
|
467
|
+
>
|
|
468
|
+
{copied ? (
|
|
469
|
+
<Check className="w-4 h-4 text-green-400" />
|
|
470
|
+
) : (
|
|
471
|
+
<Copy className="w-4 h-4" />
|
|
472
|
+
)}
|
|
473
|
+
</button>
|
|
474
|
+
</div>
|
|
475
|
+
</div>
|
|
476
|
+
)}
|
|
477
|
+
|
|
478
|
+
{/* Explanation */}
|
|
479
|
+
<p className="text-xs text-theme-text-tertiary text-center max-w-md mb-6">
|
|
480
|
+
Radar uses your local Docker credentials (<span className="font-mono">~/.docker/config.json</span>).
|
|
481
|
+
Run the command above in your terminal, then click Retry.
|
|
482
|
+
</p>
|
|
483
|
+
|
|
484
|
+
{/* Retry button */}
|
|
485
|
+
<button
|
|
486
|
+
onClick={handleRetry}
|
|
487
|
+
disabled={retrying}
|
|
488
|
+
className="flex items-center gap-2 px-4 py-2 text-sm btn-brand rounded-lg"
|
|
489
|
+
>
|
|
490
|
+
<RefreshCw className={clsx('w-4 h-4', retrying && 'animate-spin')} />
|
|
491
|
+
{retrying ? 'Retrying...' : 'Retry'}
|
|
492
|
+
</button>
|
|
493
|
+
</div>
|
|
494
|
+
)
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
function formatAuthMethod(method: string): string {
|
|
498
|
+
switch (method) {
|
|
499
|
+
case 'google': return 'Google Cloud'
|
|
500
|
+
case 'aws': return 'AWS ECR'
|
|
501
|
+
case 'azure': return 'Azure ACR'
|
|
502
|
+
case 'github': return 'GitHub'
|
|
503
|
+
case 'docker': return 'Docker Hub'
|
|
504
|
+
case 'quay': return 'Quay.io'
|
|
505
|
+
case 'gitlab': return 'GitLab'
|
|
506
|
+
case 'credentials': return 'Authenticated'
|
|
507
|
+
default: return 'Authenticated'
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
function getRegistryHost(img: string): string {
|
|
512
|
+
// Handle images like "nginx" (Docker Hub official)
|
|
513
|
+
if (!img.includes('/') || !img.split('/')[0].includes('.')) {
|
|
514
|
+
return 'docker.io'
|
|
515
|
+
}
|
|
516
|
+
// Extract hostname from "hostname/path/image:tag"
|
|
517
|
+
return img.split('/')[0]
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
function getAuthCommand(authMethod: string, image: string): string | null {
|
|
521
|
+
const registry = getRegistryHost(image)
|
|
522
|
+
|
|
523
|
+
switch (authMethod) {
|
|
524
|
+
case 'google':
|
|
525
|
+
// Extract the specific registry (gcr.io, us-docker.pkg.dev, etc.)
|
|
526
|
+
return `gcloud auth configure-docker ${registry} --quiet`
|
|
527
|
+
|
|
528
|
+
case 'aws': {
|
|
529
|
+
// Extract region from ECR URL: <account>.dkr.ecr.<region>.amazonaws.com
|
|
530
|
+
const match = registry.match(/\.dkr\.ecr\.([^.]+)\.amazonaws\.com/)
|
|
531
|
+
const region = match ? match[1] : '<region>'
|
|
532
|
+
return `aws ecr get-login-password --region ${region} | docker login --username AWS --password-stdin ${registry}`
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
case 'azure': {
|
|
536
|
+
// Extract ACR name from <name>.azurecr.io
|
|
537
|
+
const acrMatch = registry.match(/^([^.]+)\.azurecr\.io/)
|
|
538
|
+
const acrName = acrMatch ? acrMatch[1] : '<acr-name>'
|
|
539
|
+
return `az acr login --name ${acrName}`
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
case 'github':
|
|
543
|
+
return `echo $GITHUB_TOKEN | docker login ghcr.io -u <username> --password-stdin`
|
|
544
|
+
|
|
545
|
+
case 'docker':
|
|
546
|
+
return `docker login`
|
|
547
|
+
|
|
548
|
+
case 'quay':
|
|
549
|
+
return `docker login quay.io`
|
|
550
|
+
|
|
551
|
+
case 'gitlab':
|
|
552
|
+
return `docker login registry.gitlab.com -u <username> -p <access-token>`
|
|
553
|
+
|
|
554
|
+
case 'generic':
|
|
555
|
+
case 'credentials':
|
|
556
|
+
return `docker login ${registry}`
|
|
557
|
+
|
|
558
|
+
default:
|
|
559
|
+
return `docker login ${registry}`
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
// ============================================================================
|
|
564
|
+
// File Tree View Component
|
|
565
|
+
// ============================================================================
|
|
566
|
+
|
|
567
|
+
interface FileTreeViewProps {
|
|
568
|
+
root: FileNode
|
|
569
|
+
searchQuery: string
|
|
570
|
+
image: string
|
|
571
|
+
namespace: string
|
|
572
|
+
podName: string
|
|
573
|
+
pullSecrets: string[]
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
function FileTreeView({ root, searchQuery, image, namespace, podName, pullSecrets }: FileTreeViewProps) {
|
|
577
|
+
const filteredRoot = useMemo(() => {
|
|
578
|
+
if (!searchQuery.trim()) return root
|
|
579
|
+
return filterTree(root, searchQuery.toLowerCase())
|
|
580
|
+
}, [root, searchQuery])
|
|
581
|
+
|
|
582
|
+
if (!filteredRoot || !filteredRoot.children || filteredRoot.children.length === 0) {
|
|
583
|
+
return (
|
|
584
|
+
<div className="text-center text-theme-text-tertiary py-8">
|
|
585
|
+
{searchQuery ? 'No files match your search' : 'Empty filesystem'}
|
|
586
|
+
</div>
|
|
587
|
+
)
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
return (
|
|
591
|
+
<div className="font-mono text-sm">
|
|
592
|
+
{filteredRoot.children.map((node) => (
|
|
593
|
+
<FileTreeNode
|
|
594
|
+
key={node.path}
|
|
595
|
+
node={node}
|
|
596
|
+
depth={0}
|
|
597
|
+
defaultExpanded={!searchQuery}
|
|
598
|
+
image={image}
|
|
599
|
+
namespace={namespace}
|
|
600
|
+
podName={podName}
|
|
601
|
+
pullSecrets={pullSecrets}
|
|
602
|
+
/>
|
|
603
|
+
))}
|
|
604
|
+
</div>
|
|
605
|
+
)
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
interface FileTreeNodeProps {
|
|
609
|
+
node: FileNode
|
|
610
|
+
depth: number
|
|
611
|
+
defaultExpanded?: boolean
|
|
612
|
+
image: string
|
|
613
|
+
namespace: string
|
|
614
|
+
podName: string
|
|
615
|
+
pullSecrets: string[]
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
function FileTreeNode({ node, depth, defaultExpanded = true, image, namespace, podName, pullSecrets }: FileTreeNodeProps) {
|
|
619
|
+
const [expanded, setExpanded] = useState(defaultExpanded && depth < 2)
|
|
620
|
+
const [downloading, setDownloading] = useState(false)
|
|
621
|
+
const isDir = node.type === 'dir'
|
|
622
|
+
const isSymlink = node.type === 'symlink'
|
|
623
|
+
const isFile = node.type === 'file'
|
|
624
|
+
|
|
625
|
+
const handleDownload = async (e: React.MouseEvent) => {
|
|
626
|
+
e.stopPropagation()
|
|
627
|
+
if (downloading) return
|
|
628
|
+
|
|
629
|
+
setDownloading(true)
|
|
630
|
+
try {
|
|
631
|
+
const params = new URLSearchParams()
|
|
632
|
+
params.set('image', image)
|
|
633
|
+
params.set('path', node.path)
|
|
634
|
+
if (namespace) params.set('namespace', namespace)
|
|
635
|
+
if (podName) params.set('pod', podName)
|
|
636
|
+
if (pullSecrets.length > 0) params.set('pullSecrets', pullSecrets.join(','))
|
|
637
|
+
|
|
638
|
+
const response = await fetch(apiUrl(`/images/file?${params.toString()}`), {
|
|
639
|
+
credentials: getCredentialsMode(),
|
|
640
|
+
headers: getAuthHeaders(),
|
|
641
|
+
})
|
|
642
|
+
if (!response.ok) {
|
|
643
|
+
throw new Error('Failed to download file')
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
const blob = await response.blob()
|
|
647
|
+
await downloadBlob(blob, node.name)
|
|
648
|
+
} catch (err) {
|
|
649
|
+
console.error('Download failed:', err)
|
|
650
|
+
} finally {
|
|
651
|
+
setDownloading(false)
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
const handleClick = () => {
|
|
656
|
+
if (isDir) {
|
|
657
|
+
setExpanded(!expanded)
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
return (
|
|
662
|
+
<div>
|
|
663
|
+
<div
|
|
664
|
+
className={clsx(
|
|
665
|
+
'flex items-center gap-1 py-0.5 px-1 rounded hover:bg-theme-elevated',
|
|
666
|
+
isDir && 'font-medium cursor-pointer'
|
|
667
|
+
)}
|
|
668
|
+
style={{ paddingLeft: `${depth * 16 + 4}px` }}
|
|
669
|
+
onClick={handleClick}
|
|
670
|
+
>
|
|
671
|
+
{isDir && (
|
|
672
|
+
<span className="w-4 h-4 flex items-center justify-center">
|
|
673
|
+
{expanded ? (
|
|
674
|
+
<ChevronDown className="w-3.5 h-3.5 text-theme-text-tertiary" />
|
|
675
|
+
) : (
|
|
676
|
+
<ChevronRight className="w-3.5 h-3.5 text-theme-text-tertiary" />
|
|
677
|
+
)}
|
|
678
|
+
</span>
|
|
679
|
+
)}
|
|
680
|
+
{!isDir && <span className="w-4" />}
|
|
681
|
+
|
|
682
|
+
{isDir ? (
|
|
683
|
+
<Folder className="w-4 h-4 text-amber-400 shrink-0" />
|
|
684
|
+
) : isSymlink ? (
|
|
685
|
+
<Link2 className="w-4 h-4 text-cyan-400 shrink-0" />
|
|
686
|
+
) : (
|
|
687
|
+
<File className="w-4 h-4 text-theme-text-tertiary shrink-0" />
|
|
688
|
+
)}
|
|
689
|
+
|
|
690
|
+
<span className="text-theme-text-primary truncate flex-1">{node.name}</span>
|
|
691
|
+
|
|
692
|
+
{isSymlink && node.linkTarget && (
|
|
693
|
+
<span className="text-xs text-cyan-400 truncate max-w-48">
|
|
694
|
+
-> {node.linkTarget}
|
|
695
|
+
</span>
|
|
696
|
+
)}
|
|
697
|
+
|
|
698
|
+
{!isDir && node.size !== undefined && (
|
|
699
|
+
<span className="text-xs text-theme-text-tertiary ml-2">
|
|
700
|
+
{formatBytes(node.size)}
|
|
701
|
+
</span>
|
|
702
|
+
)}
|
|
703
|
+
|
|
704
|
+
{node.permissions && (
|
|
705
|
+
<span className="text-xs text-theme-text-tertiary ml-2 font-normal">
|
|
706
|
+
{node.permissions}
|
|
707
|
+
</span>
|
|
708
|
+
)}
|
|
709
|
+
|
|
710
|
+
{isFile && (
|
|
711
|
+
<button
|
|
712
|
+
onClick={handleDownload}
|
|
713
|
+
disabled={downloading}
|
|
714
|
+
className="p-1 text-theme-text-tertiary hover:text-blue-400 hover:bg-theme-elevated rounded ml-1 disabled:opacity-50"
|
|
715
|
+
title="Download file"
|
|
716
|
+
>
|
|
717
|
+
{downloading ? (
|
|
718
|
+
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
|
719
|
+
) : (
|
|
720
|
+
<Download className="w-3.5 h-3.5" />
|
|
721
|
+
)}
|
|
722
|
+
</button>
|
|
723
|
+
)}
|
|
724
|
+
</div>
|
|
725
|
+
|
|
726
|
+
{isDir && expanded && node.children && (
|
|
727
|
+
<div>
|
|
728
|
+
{node.children.map((child) => (
|
|
729
|
+
<FileTreeNode
|
|
730
|
+
key={child.path}
|
|
731
|
+
node={child}
|
|
732
|
+
depth={depth + 1}
|
|
733
|
+
defaultExpanded={defaultExpanded}
|
|
734
|
+
image={image}
|
|
735
|
+
namespace={namespace}
|
|
736
|
+
podName={podName}
|
|
737
|
+
pullSecrets={pullSecrets}
|
|
738
|
+
/>
|
|
739
|
+
))}
|
|
740
|
+
</div>
|
|
741
|
+
)}
|
|
742
|
+
</div>
|
|
743
|
+
)
|
|
744
|
+
}
|
|
745
|
+
|