@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,571 @@
|
|
|
1
|
+
import { memo, useState, useRef } from 'react'
|
|
2
|
+
import { createPortal } from 'react-dom'
|
|
3
|
+
import {
|
|
4
|
+
ChevronDown,
|
|
5
|
+
Eye,
|
|
6
|
+
Layers,
|
|
7
|
+
Globe,
|
|
8
|
+
Cpu,
|
|
9
|
+
Network,
|
|
10
|
+
Clock,
|
|
11
|
+
Filter,
|
|
12
|
+
Info,
|
|
13
|
+
Puzzle,
|
|
14
|
+
} from 'lucide-react'
|
|
15
|
+
import { clsx } from 'clsx'
|
|
16
|
+
import { SEVERITY_BADGE } from '@skyhook-io/k8s-ui/utils/badge-colors'
|
|
17
|
+
import type { AddonMode } from './TrafficView'
|
|
18
|
+
import { getNamespaceColor } from '../../utils/traffic-colors'
|
|
19
|
+
|
|
20
|
+
// Fast tooltip component using portal to escape overflow
|
|
21
|
+
function Tooltip({ children, content }: { children: React.ReactNode; content: string }) {
|
|
22
|
+
const [show, setShow] = useState(false)
|
|
23
|
+
const [pos, setPos] = useState({ x: 0, y: 0 })
|
|
24
|
+
const ref = useRef<HTMLDivElement>(null)
|
|
25
|
+
|
|
26
|
+
const handleMouseEnter = () => {
|
|
27
|
+
if (ref.current) {
|
|
28
|
+
const rect = ref.current.getBoundingClientRect()
|
|
29
|
+
setPos({ x: rect.right + 8, y: rect.top + rect.height / 2 })
|
|
30
|
+
}
|
|
31
|
+
setShow(true)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
return (
|
|
35
|
+
<div
|
|
36
|
+
ref={ref}
|
|
37
|
+
className="inline-flex"
|
|
38
|
+
onMouseEnter={handleMouseEnter}
|
|
39
|
+
onMouseLeave={() => setShow(false)}
|
|
40
|
+
>
|
|
41
|
+
{children}
|
|
42
|
+
{show && createPortal(
|
|
43
|
+
<div
|
|
44
|
+
className="fixed z-[9999] pointer-events-none"
|
|
45
|
+
style={{ left: pos.x, top: pos.y, transform: 'translateY(-50%)' }}
|
|
46
|
+
>
|
|
47
|
+
<div className="bg-gray-900 text-white text-[10px] px-2 py-1.5 rounded shadow-lg max-w-[180px] leading-tight whitespace-normal">
|
|
48
|
+
{content}
|
|
49
|
+
</div>
|
|
50
|
+
</div>,
|
|
51
|
+
document.body
|
|
52
|
+
)}
|
|
53
|
+
</div>
|
|
54
|
+
)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Connection threshold options
|
|
58
|
+
const CONNECTION_THRESHOLDS = [
|
|
59
|
+
{ value: 0, label: 'All traffic' },
|
|
60
|
+
{ value: 100, label: '100+ connections' },
|
|
61
|
+
{ value: 1000, label: '1K+ connections' },
|
|
62
|
+
{ value: 10000, label: '10K+ connections' },
|
|
63
|
+
{ value: 100000, label: '100K+ connections' },
|
|
64
|
+
]
|
|
65
|
+
|
|
66
|
+
// Time range options
|
|
67
|
+
const TIME_RANGES = [
|
|
68
|
+
{ value: '1m', label: '1 minute' },
|
|
69
|
+
{ value: '5m', label: '5 minutes' },
|
|
70
|
+
{ value: '15m', label: '15 minutes' },
|
|
71
|
+
{ value: '1h', label: '1 hour' },
|
|
72
|
+
]
|
|
73
|
+
|
|
74
|
+
interface TrafficFilterSidebarProps {
|
|
75
|
+
// Filter state
|
|
76
|
+
hideSystem: boolean
|
|
77
|
+
setHideSystem: (v: boolean) => void
|
|
78
|
+
hideExternal: boolean
|
|
79
|
+
setHideExternal: (v: boolean) => void
|
|
80
|
+
minConnections: number
|
|
81
|
+
setMinConnections: (v: number) => void
|
|
82
|
+
|
|
83
|
+
// Display options
|
|
84
|
+
showNamespaceGroups: boolean
|
|
85
|
+
setShowNamespaceGroups: (v: boolean) => void
|
|
86
|
+
collapseInternet: boolean
|
|
87
|
+
setCollapseInternet: (v: boolean) => void
|
|
88
|
+
addonMode: AddonMode
|
|
89
|
+
setAddonMode: (v: AddonMode) => void
|
|
90
|
+
|
|
91
|
+
// Detection options
|
|
92
|
+
aggregateExternal: boolean
|
|
93
|
+
setAggregateExternal: (v: boolean) => void
|
|
94
|
+
detectServices: boolean
|
|
95
|
+
setDetectServices: (v: boolean) => void
|
|
96
|
+
|
|
97
|
+
// Time
|
|
98
|
+
timeRange: string
|
|
99
|
+
setTimeRange: (v: string) => void
|
|
100
|
+
|
|
101
|
+
// L7 filters (Hubble-only)
|
|
102
|
+
isHubble?: boolean
|
|
103
|
+
l7Protocol: string // 'all' | 'HTTP' | 'DNS' | 'TCP'
|
|
104
|
+
setL7Protocol: (v: string) => void
|
|
105
|
+
l7Methods: Set<string>
|
|
106
|
+
onToggleL7Method: (method: string) => void
|
|
107
|
+
l7StatusRanges: Set<string>
|
|
108
|
+
onToggleL7StatusRange: (range: string) => void
|
|
109
|
+
l7Verdicts: Set<string>
|
|
110
|
+
onToggleL7Verdict: (verdict: string) => void
|
|
111
|
+
dnsPattern: string
|
|
112
|
+
setDnsPattern: (v: string) => void
|
|
113
|
+
|
|
114
|
+
// Namespace filtering
|
|
115
|
+
namespaces: Array<{ name: string; nodeCount: number }>
|
|
116
|
+
hiddenNamespaces: Set<string>
|
|
117
|
+
onToggleNamespace: (ns: string) => void
|
|
118
|
+
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Compact toggle component with tooltip
|
|
122
|
+
function ToggleOption({
|
|
123
|
+
label,
|
|
124
|
+
description,
|
|
125
|
+
enabled,
|
|
126
|
+
onToggle,
|
|
127
|
+
icon: Icon,
|
|
128
|
+
}: {
|
|
129
|
+
label: string
|
|
130
|
+
description: string
|
|
131
|
+
enabled: boolean
|
|
132
|
+
onToggle: () => void
|
|
133
|
+
icon: typeof Eye
|
|
134
|
+
}) {
|
|
135
|
+
return (
|
|
136
|
+
<div className={clsx(
|
|
137
|
+
'flex items-center gap-2 px-2 py-1.5 rounded transition-colors',
|
|
138
|
+
enabled ? 'selection' : 'hover:bg-theme-elevated'
|
|
139
|
+
)}>
|
|
140
|
+
<button
|
|
141
|
+
onClick={onToggle}
|
|
142
|
+
className="flex-1 flex items-center gap-2 text-left"
|
|
143
|
+
>
|
|
144
|
+
<Icon className={clsx(
|
|
145
|
+
'w-3.5 h-3.5 shrink-0',
|
|
146
|
+
enabled ? 'selection-text' : 'text-theme-text-tertiary'
|
|
147
|
+
)} />
|
|
148
|
+
<span className={clsx(
|
|
149
|
+
'flex-1 text-xs',
|
|
150
|
+
enabled ? 'selection-text' : 'text-theme-text-primary'
|
|
151
|
+
)}>
|
|
152
|
+
{label}
|
|
153
|
+
</span>
|
|
154
|
+
</button>
|
|
155
|
+
<Tooltip content={description}>
|
|
156
|
+
<Info className="w-3 h-3 text-theme-text-tertiary hover:text-theme-text-secondary cursor-help" />
|
|
157
|
+
</Tooltip>
|
|
158
|
+
<button
|
|
159
|
+
onClick={onToggle}
|
|
160
|
+
className={clsx(
|
|
161
|
+
'w-7 h-4 rounded-full transition-colors relative shrink-0',
|
|
162
|
+
enabled ? 'bg-skyhook-500' : 'bg-theme-elevated'
|
|
163
|
+
)}
|
|
164
|
+
>
|
|
165
|
+
<div className={clsx(
|
|
166
|
+
'absolute top-0.5 w-3 h-3 rounded-full bg-white transition-transform',
|
|
167
|
+
enabled ? 'translate-x-3.5' : 'translate-x-0.5'
|
|
168
|
+
)} />
|
|
169
|
+
</button>
|
|
170
|
+
</div>
|
|
171
|
+
)
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export const TrafficFilterSidebar = memo(function TrafficFilterSidebar({
|
|
175
|
+
hideSystem,
|
|
176
|
+
setHideSystem,
|
|
177
|
+
hideExternal,
|
|
178
|
+
setHideExternal,
|
|
179
|
+
minConnections,
|
|
180
|
+
setMinConnections,
|
|
181
|
+
showNamespaceGroups,
|
|
182
|
+
setShowNamespaceGroups,
|
|
183
|
+
collapseInternet,
|
|
184
|
+
setCollapseInternet,
|
|
185
|
+
addonMode,
|
|
186
|
+
setAddonMode,
|
|
187
|
+
aggregateExternal,
|
|
188
|
+
setAggregateExternal,
|
|
189
|
+
detectServices,
|
|
190
|
+
setDetectServices,
|
|
191
|
+
timeRange,
|
|
192
|
+
setTimeRange,
|
|
193
|
+
isHubble,
|
|
194
|
+
l7Protocol,
|
|
195
|
+
setL7Protocol,
|
|
196
|
+
l7Methods,
|
|
197
|
+
onToggleL7Method,
|
|
198
|
+
l7StatusRanges,
|
|
199
|
+
onToggleL7StatusRange,
|
|
200
|
+
l7Verdicts,
|
|
201
|
+
onToggleL7Verdict,
|
|
202
|
+
dnsPattern,
|
|
203
|
+
setDnsPattern,
|
|
204
|
+
namespaces,
|
|
205
|
+
hiddenNamespaces,
|
|
206
|
+
onToggleNamespace,
|
|
207
|
+
}: TrafficFilterSidebarProps) {
|
|
208
|
+
const [namespacesExpanded, setNamespacesExpanded] = useState(false)
|
|
209
|
+
|
|
210
|
+
// Sort namespaces by node count (descending)
|
|
211
|
+
const sortedNamespaces = [...namespaces].sort((a, b) => b.nodeCount - a.nodeCount)
|
|
212
|
+
const visibleNamespaces = namespacesExpanded ? sortedNamespaces : sortedNamespaces.slice(0, 8)
|
|
213
|
+
const hasMore = sortedNamespaces.length > 8
|
|
214
|
+
|
|
215
|
+
return (
|
|
216
|
+
<div className="w-72 flex flex-col shrink-0 bg-theme-surface/90 backdrop-blur border-r border-theme-border overflow-hidden">
|
|
217
|
+
{/* Header */}
|
|
218
|
+
<div className="flex items-center px-3 py-2 border-b border-theme-border">
|
|
219
|
+
<span className="text-sm font-medium text-theme-text-secondary">Traffic Filters</span>
|
|
220
|
+
</div>
|
|
221
|
+
|
|
222
|
+
{/* Scrollable content */}
|
|
223
|
+
<div className="flex-1 overflow-y-auto">
|
|
224
|
+
{/* Time Range & Threshold */}
|
|
225
|
+
<div className="px-3 py-2 border-b border-theme-border space-y-1.5">
|
|
226
|
+
<div className="flex items-center gap-2">
|
|
227
|
+
<Clock className="w-3.5 h-3.5 text-theme-text-tertiary" />
|
|
228
|
+
<select
|
|
229
|
+
value={timeRange}
|
|
230
|
+
onChange={(e) => setTimeRange(e.target.value)}
|
|
231
|
+
title="Show traffic from the selected time window"
|
|
232
|
+
className="flex-1 bg-theme-elevated text-theme-text-primary text-xs rounded px-2 py-1.5 border border-theme-border focus:outline-none focus:ring-1 focus:ring-blue-500"
|
|
233
|
+
>
|
|
234
|
+
{TIME_RANGES.map(({ value, label }) => (
|
|
235
|
+
<option key={value} value={value}>{label}</option>
|
|
236
|
+
))}
|
|
237
|
+
</select>
|
|
238
|
+
</div>
|
|
239
|
+
<div className="flex items-center gap-2">
|
|
240
|
+
<Filter className="w-3.5 h-3.5 text-theme-text-tertiary" />
|
|
241
|
+
<select
|
|
242
|
+
value={minConnections}
|
|
243
|
+
onChange={(e) => setMinConnections(Number(e.target.value))}
|
|
244
|
+
title="Hide low-traffic flows to reduce noise"
|
|
245
|
+
className="flex-1 bg-theme-elevated text-theme-text-primary text-xs rounded px-2 py-1.5 border border-theme-border focus:outline-none focus:ring-1 focus:ring-blue-500"
|
|
246
|
+
>
|
|
247
|
+
{CONNECTION_THRESHOLDS.map(({ value, label }) => (
|
|
248
|
+
<option key={value} value={value}>{label}</option>
|
|
249
|
+
))}
|
|
250
|
+
</select>
|
|
251
|
+
</div>
|
|
252
|
+
</div>
|
|
253
|
+
|
|
254
|
+
{/* Filtering */}
|
|
255
|
+
<div className="px-3 py-2 border-b border-theme-border">
|
|
256
|
+
<div className="flex items-center gap-2 mb-1.5">
|
|
257
|
+
<Filter className="w-3.5 h-3.5 text-theme-text-tertiary" />
|
|
258
|
+
<span className="text-[10px] font-medium text-theme-text-tertiary uppercase tracking-wider">Filtering</span>
|
|
259
|
+
</div>
|
|
260
|
+
<div className="space-y-0.5">
|
|
261
|
+
<ToggleOption
|
|
262
|
+
label="Hide System"
|
|
263
|
+
description="Filter out infrastructure traffic (kube-system, monitoring, etc.)"
|
|
264
|
+
enabled={hideSystem}
|
|
265
|
+
onToggle={() => setHideSystem(!hideSystem)}
|
|
266
|
+
icon={Cpu}
|
|
267
|
+
/>
|
|
268
|
+
<ToggleOption
|
|
269
|
+
label="Hide External"
|
|
270
|
+
description="Hide traffic to/from external services"
|
|
271
|
+
enabled={hideExternal}
|
|
272
|
+
onToggle={() => setHideExternal(!hideExternal)}
|
|
273
|
+
icon={Globe}
|
|
274
|
+
/>
|
|
275
|
+
</div>
|
|
276
|
+
|
|
277
|
+
{/* Cluster Addons 3-way toggle */}
|
|
278
|
+
<div className="mt-2 pt-2 border-t border-theme-border/50">
|
|
279
|
+
<div className="flex items-center gap-2 mb-1.5">
|
|
280
|
+
<Puzzle className="w-3.5 h-3.5 text-theme-text-tertiary" />
|
|
281
|
+
<span className="text-xs text-theme-text-primary">Cluster Addons</span>
|
|
282
|
+
<Tooltip content="Monitoring, logging, cert-manager, etc. Excludes ingress controllers and service mesh.">
|
|
283
|
+
<Info className="w-3 h-3 text-theme-text-tertiary hover:text-theme-text-secondary cursor-help" />
|
|
284
|
+
</Tooltip>
|
|
285
|
+
</div>
|
|
286
|
+
<div className="flex rounded-md overflow-hidden border border-theme-border">
|
|
287
|
+
{(['show', 'group', 'hide'] as const).map((mode) => (
|
|
288
|
+
<button
|
|
289
|
+
key={mode}
|
|
290
|
+
onClick={() => setAddonMode(mode)}
|
|
291
|
+
className={clsx(
|
|
292
|
+
'flex-1 px-2 py-1.5 text-[10px] font-medium transition-colors capitalize',
|
|
293
|
+
addonMode === mode
|
|
294
|
+
? 'bg-skyhook-500 text-white'
|
|
295
|
+
: 'bg-theme-elevated text-theme-text-secondary hover:bg-theme-hover'
|
|
296
|
+
)}
|
|
297
|
+
>
|
|
298
|
+
{mode}
|
|
299
|
+
</button>
|
|
300
|
+
))}
|
|
301
|
+
</div>
|
|
302
|
+
</div>
|
|
303
|
+
</div>
|
|
304
|
+
|
|
305
|
+
{/* Display */}
|
|
306
|
+
<div className="px-3 py-2 border-b border-theme-border">
|
|
307
|
+
<div className="flex items-center gap-2 mb-1.5">
|
|
308
|
+
<Eye className="w-3.5 h-3.5 text-theme-text-tertiary" />
|
|
309
|
+
<span className="text-[10px] font-medium text-theme-text-tertiary uppercase tracking-wider">Display</span>
|
|
310
|
+
</div>
|
|
311
|
+
<div className="space-y-0.5">
|
|
312
|
+
<ToggleOption
|
|
313
|
+
label="Namespace Colors"
|
|
314
|
+
description="Color nodes by their namespace"
|
|
315
|
+
enabled={showNamespaceGroups}
|
|
316
|
+
onToggle={() => setShowNamespaceGroups(!showNamespaceGroups)}
|
|
317
|
+
icon={Layers}
|
|
318
|
+
/>
|
|
319
|
+
<ToggleOption
|
|
320
|
+
label="Collapse Internet"
|
|
321
|
+
description="Group inbound external IPs into single 'Internet' node"
|
|
322
|
+
enabled={collapseInternet}
|
|
323
|
+
onToggle={() => setCollapseInternet(!collapseInternet)}
|
|
324
|
+
icon={Globe}
|
|
325
|
+
/>
|
|
326
|
+
</div>
|
|
327
|
+
</div>
|
|
328
|
+
|
|
329
|
+
{/* Service Detection */}
|
|
330
|
+
<div className="px-3 py-2 border-b border-theme-border">
|
|
331
|
+
<div className="flex items-center gap-2 mb-1.5">
|
|
332
|
+
<Network className="w-3.5 h-3.5 text-theme-text-tertiary" />
|
|
333
|
+
<span className="text-[10px] font-medium text-theme-text-tertiary uppercase tracking-wider">Detection</span>
|
|
334
|
+
</div>
|
|
335
|
+
<div className="space-y-0.5">
|
|
336
|
+
<ToggleOption
|
|
337
|
+
label="Aggregate External"
|
|
338
|
+
description="Group traffic to same external service (e.g., multiple MongoDB hosts)"
|
|
339
|
+
enabled={aggregateExternal}
|
|
340
|
+
onToggle={() => setAggregateExternal(!aggregateExternal)}
|
|
341
|
+
icon={Layers}
|
|
342
|
+
/>
|
|
343
|
+
<ToggleOption
|
|
344
|
+
label="Identify by Port"
|
|
345
|
+
description="Label well-known ports (27017→MongoDB, 6379→Redis). Heuristic-based."
|
|
346
|
+
enabled={detectServices}
|
|
347
|
+
onToggle={() => setDetectServices(!detectServices)}
|
|
348
|
+
icon={Cpu}
|
|
349
|
+
/>
|
|
350
|
+
</div>
|
|
351
|
+
</div>
|
|
352
|
+
|
|
353
|
+
{/* L7 Filters (Hubble only) */}
|
|
354
|
+
{isHubble && (
|
|
355
|
+
<div className="space-y-2 px-3 py-2 border-t border-theme-border">
|
|
356
|
+
<div className="flex items-center gap-1.5">
|
|
357
|
+
<Filter className="w-3 h-3 text-theme-text-tertiary" />
|
|
358
|
+
<span className="text-[10px] font-medium text-theme-text-tertiary uppercase tracking-wider">L7 Filters</span>
|
|
359
|
+
</div>
|
|
360
|
+
|
|
361
|
+
{/* Protocol selector */}
|
|
362
|
+
<div>
|
|
363
|
+
<div className="text-[10px] text-theme-text-tertiary mb-1">Protocol</div>
|
|
364
|
+
<div className="flex rounded-md overflow-hidden border border-theme-border">
|
|
365
|
+
{['all', 'HTTP', 'DNS', 'TCP'].map(proto => (
|
|
366
|
+
<button
|
|
367
|
+
key={proto}
|
|
368
|
+
onClick={() => setL7Protocol(proto)}
|
|
369
|
+
className={clsx(
|
|
370
|
+
'flex-1 px-2 py-1 text-[10px] font-medium transition-colors capitalize',
|
|
371
|
+
l7Protocol === proto
|
|
372
|
+
? 'bg-skyhook-500 text-white'
|
|
373
|
+
: 'bg-theme-elevated text-theme-text-secondary hover:bg-theme-hover'
|
|
374
|
+
)}
|
|
375
|
+
>
|
|
376
|
+
{proto === 'all' ? 'All' : proto}
|
|
377
|
+
</button>
|
|
378
|
+
))}
|
|
379
|
+
</div>
|
|
380
|
+
</div>
|
|
381
|
+
|
|
382
|
+
{/* HTTP sub-filters (visible when protocol is All or HTTP) */}
|
|
383
|
+
{(l7Protocol === 'all' || l7Protocol === 'HTTP') && (
|
|
384
|
+
<>
|
|
385
|
+
<div>
|
|
386
|
+
<div className="text-[10px] text-theme-text-tertiary mb-1">HTTP Method</div>
|
|
387
|
+
<div className="flex flex-wrap gap-1">
|
|
388
|
+
{['GET', 'POST', 'PUT', 'DELETE', 'PATCH'].map(method => (
|
|
389
|
+
<button
|
|
390
|
+
key={method}
|
|
391
|
+
onClick={() => onToggleL7Method(method)}
|
|
392
|
+
className={clsx(
|
|
393
|
+
'px-1.5 py-0.5 rounded text-[10px] font-medium transition-colors',
|
|
394
|
+
l7Methods.has(method)
|
|
395
|
+
? SEVERITY_BADGE.info
|
|
396
|
+
: 'bg-theme-elevated text-theme-text-tertiary hover:text-theme-text-secondary'
|
|
397
|
+
)}
|
|
398
|
+
>
|
|
399
|
+
{method}
|
|
400
|
+
</button>
|
|
401
|
+
))}
|
|
402
|
+
</div>
|
|
403
|
+
</div>
|
|
404
|
+
|
|
405
|
+
<div>
|
|
406
|
+
<div className="text-[10px] text-theme-text-tertiary mb-1">Status Code</div>
|
|
407
|
+
<div className="flex flex-wrap gap-1">
|
|
408
|
+
{([
|
|
409
|
+
{ label: '2xx', active: SEVERITY_BADGE.success },
|
|
410
|
+
{ label: '3xx', active: SEVERITY_BADGE.neutral },
|
|
411
|
+
{ label: '4xx', active: SEVERITY_BADGE.warning },
|
|
412
|
+
{ label: '5xx', active: SEVERITY_BADGE.error },
|
|
413
|
+
] as const).map(({ label, active }) => (
|
|
414
|
+
<button
|
|
415
|
+
key={label}
|
|
416
|
+
onClick={() => onToggleL7StatusRange(label)}
|
|
417
|
+
className={clsx(
|
|
418
|
+
'px-1.5 py-0.5 rounded text-[10px] font-medium transition-colors',
|
|
419
|
+
l7StatusRanges.has(label)
|
|
420
|
+
? active
|
|
421
|
+
: 'bg-theme-elevated text-theme-text-tertiary hover:text-theme-text-secondary'
|
|
422
|
+
)}
|
|
423
|
+
>
|
|
424
|
+
{label}
|
|
425
|
+
</button>
|
|
426
|
+
))}
|
|
427
|
+
</div>
|
|
428
|
+
</div>
|
|
429
|
+
</>
|
|
430
|
+
)}
|
|
431
|
+
|
|
432
|
+
{/* DNS sub-filter (visible when protocol is All or DNS) */}
|
|
433
|
+
{(l7Protocol === 'all' || l7Protocol === 'DNS') && (
|
|
434
|
+
<div>
|
|
435
|
+
<div className="text-[10px] text-theme-text-tertiary mb-1">DNS Query</div>
|
|
436
|
+
<input
|
|
437
|
+
type="text"
|
|
438
|
+
value={dnsPattern}
|
|
439
|
+
onChange={(e) => setDnsPattern(e.target.value)}
|
|
440
|
+
placeholder="e.g. example.com"
|
|
441
|
+
className="w-full px-2 py-1 text-[11px] rounded bg-theme-elevated border border-theme-border text-theme-text-primary placeholder:text-theme-text-tertiary focus:outline-none focus:ring-1 focus:ring-blue-500/50"
|
|
442
|
+
/>
|
|
443
|
+
</div>
|
|
444
|
+
)}
|
|
445
|
+
|
|
446
|
+
{/* Verdict (always visible — applies to all protocols) */}
|
|
447
|
+
<div>
|
|
448
|
+
<div className="text-[10px] text-theme-text-tertiary mb-1">Verdict</div>
|
|
449
|
+
<div className="flex flex-wrap gap-1">
|
|
450
|
+
{([
|
|
451
|
+
{ label: 'forwarded', active: SEVERITY_BADGE.success },
|
|
452
|
+
{ label: 'dropped', active: SEVERITY_BADGE.error },
|
|
453
|
+
{ label: 'error', active: SEVERITY_BADGE.warning },
|
|
454
|
+
] as const).map(({ label, active }) => (
|
|
455
|
+
<button
|
|
456
|
+
key={label}
|
|
457
|
+
onClick={() => onToggleL7Verdict(label)}
|
|
458
|
+
className={clsx(
|
|
459
|
+
'px-1.5 py-0.5 rounded text-[10px] font-medium capitalize transition-colors',
|
|
460
|
+
l7Verdicts.has(label)
|
|
461
|
+
? active
|
|
462
|
+
: 'bg-theme-elevated text-theme-text-tertiary hover:text-theme-text-secondary'
|
|
463
|
+
)}
|
|
464
|
+
>
|
|
465
|
+
{label}
|
|
466
|
+
</button>
|
|
467
|
+
))}
|
|
468
|
+
</div>
|
|
469
|
+
</div>
|
|
470
|
+
</div>
|
|
471
|
+
)}
|
|
472
|
+
|
|
473
|
+
{/* Namespaces */}
|
|
474
|
+
{sortedNamespaces.length > 0 && (
|
|
475
|
+
<div className="px-3 py-2">
|
|
476
|
+
<div className="flex items-center justify-between mb-1.5">
|
|
477
|
+
<div className="flex items-center gap-2">
|
|
478
|
+
<Layers className="w-3.5 h-3.5 text-theme-text-tertiary" />
|
|
479
|
+
<span className="text-[10px] font-medium text-theme-text-tertiary uppercase tracking-wider">Namespaces</span>
|
|
480
|
+
</div>
|
|
481
|
+
<div className="flex items-center gap-2 text-[10px]">
|
|
482
|
+
<button
|
|
483
|
+
onClick={() => {
|
|
484
|
+
hiddenNamespaces.forEach(ns => onToggleNamespace(ns))
|
|
485
|
+
}}
|
|
486
|
+
disabled={hiddenNamespaces.size === 0}
|
|
487
|
+
className={clsx(
|
|
488
|
+
hiddenNamespaces.size > 0
|
|
489
|
+
? 'text-blue-400 hover:text-blue-300'
|
|
490
|
+
: 'text-theme-text-tertiary/50 cursor-default'
|
|
491
|
+
)}
|
|
492
|
+
>
|
|
493
|
+
All
|
|
494
|
+
</button>
|
|
495
|
+
<span className="text-theme-text-tertiary/30">|</span>
|
|
496
|
+
<button
|
|
497
|
+
onClick={() => {
|
|
498
|
+
sortedNamespaces.forEach(({ name }) => {
|
|
499
|
+
if (!hiddenNamespaces.has(name)) {
|
|
500
|
+
onToggleNamespace(name)
|
|
501
|
+
}
|
|
502
|
+
})
|
|
503
|
+
}}
|
|
504
|
+
disabled={hiddenNamespaces.size === sortedNamespaces.length}
|
|
505
|
+
className={clsx(
|
|
506
|
+
hiddenNamespaces.size < sortedNamespaces.length
|
|
507
|
+
? 'text-blue-400 hover:text-blue-300'
|
|
508
|
+
: 'text-theme-text-tertiary/50 cursor-default'
|
|
509
|
+
)}
|
|
510
|
+
>
|
|
511
|
+
None
|
|
512
|
+
</button>
|
|
513
|
+
</div>
|
|
514
|
+
</div>
|
|
515
|
+
<div className="space-y-0.5">
|
|
516
|
+
{visibleNamespaces.map(({ name, nodeCount }) => {
|
|
517
|
+
const isHidden = hiddenNamespaces.has(name)
|
|
518
|
+
return (
|
|
519
|
+
<button
|
|
520
|
+
key={name}
|
|
521
|
+
onClick={() => onToggleNamespace(name)}
|
|
522
|
+
className={clsx(
|
|
523
|
+
'w-full flex items-center gap-2 px-2 py-1 rounded text-left transition-all',
|
|
524
|
+
isHidden
|
|
525
|
+
? 'opacity-50 hover:opacity-70'
|
|
526
|
+
: 'hover:ring-1 hover:ring-white/20'
|
|
527
|
+
)}
|
|
528
|
+
style={{
|
|
529
|
+
backgroundColor: isHidden ? 'transparent' : getNamespaceColor(name),
|
|
530
|
+
}}
|
|
531
|
+
>
|
|
532
|
+
{isHidden && (
|
|
533
|
+
<div
|
|
534
|
+
className="w-2.5 h-2.5 rounded-sm shrink-0"
|
|
535
|
+
style={{ backgroundColor: getNamespaceColor(name) }}
|
|
536
|
+
/>
|
|
537
|
+
)}
|
|
538
|
+
<span className={clsx(
|
|
539
|
+
'text-[11px] font-medium truncate flex-1',
|
|
540
|
+
isHidden ? 'text-theme-text-tertiary line-through' : 'text-white'
|
|
541
|
+
)}>
|
|
542
|
+
{name}
|
|
543
|
+
</span>
|
|
544
|
+
<span className={clsx(
|
|
545
|
+
'text-[10px] tabular-nums',
|
|
546
|
+
isHidden ? 'text-theme-text-tertiary' : 'text-white/70'
|
|
547
|
+
)}>
|
|
548
|
+
{nodeCount}
|
|
549
|
+
</span>
|
|
550
|
+
</button>
|
|
551
|
+
)
|
|
552
|
+
})}
|
|
553
|
+
</div>
|
|
554
|
+
{hasMore && (
|
|
555
|
+
<button
|
|
556
|
+
onClick={() => setNamespacesExpanded(!namespacesExpanded)}
|
|
557
|
+
className="w-full flex items-center justify-center gap-1 mt-2 py-1 text-[10px] text-theme-text-tertiary hover:text-theme-text-secondary"
|
|
558
|
+
>
|
|
559
|
+
<ChevronDown className={clsx(
|
|
560
|
+
'w-3 h-3 transition-transform',
|
|
561
|
+
namespacesExpanded && 'rotate-180'
|
|
562
|
+
)} />
|
|
563
|
+
{namespacesExpanded ? 'Show less' : `+${sortedNamespaces.length - 8} more`}
|
|
564
|
+
</button>
|
|
565
|
+
)}
|
|
566
|
+
</div>
|
|
567
|
+
)}
|
|
568
|
+
</div>
|
|
569
|
+
</div>
|
|
570
|
+
)
|
|
571
|
+
})
|