@skyhook-io/k8s-ui 1.0.0
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/package.json +63 -0
- package/src/components/dock/BottomDock.tsx +204 -0
- package/src/components/dock/DockContext.tsx +212 -0
- package/src/components/dock/TerminalTab.tsx +333 -0
- package/src/components/dock/index.ts +3 -0
- package/src/components/gitops/GitOpsActions.tsx +227 -0
- package/src/components/gitops/GitOpsStatusBadge.tsx +221 -0
- package/src/components/gitops/ManagedResourcesList.tsx +210 -0
- package/src/components/gitops/SyncCountdown.tsx +146 -0
- package/src/components/gitops/index.ts +4 -0
- package/src/components/logs/JsonLogLine.tsx +90 -0
- package/src/components/logs/LogCore.tsx +553 -0
- package/src/components/logs/LogToolbarSelects.tsx +74 -0
- package/src/components/logs/LogsViewer.tsx +146 -0
- package/src/components/logs/WorkloadLogsViewer.tsx +263 -0
- package/src/components/logs/index.ts +13 -0
- package/src/components/logs/useLogBuffer.ts +138 -0
- package/src/components/logs/useLogSearch.ts +151 -0
- package/src/components/logs/useLogStream.ts +61 -0
- package/src/components/resources/ResourcesView.tsx +5494 -0
- package/src/components/resources/index.ts +17 -0
- package/src/components/resources/renderers/AlertRenderer.tsx +166 -0
- package/src/components/resources/renderers/ArgoApplicationRenderer.tsx +233 -0
- package/src/components/resources/renderers/CNPGBackupRenderer.tsx +89 -0
- package/src/components/resources/renderers/CNPGClusterRenderer.tsx +194 -0
- package/src/components/resources/renderers/CNPGPoolerRenderer.tsx +79 -0
- package/src/components/resources/renderers/CNPGScheduledBackupRenderer.tsx +68 -0
- package/src/components/resources/renderers/CertificateRenderer.tsx +209 -0
- package/src/components/resources/renderers/CertificateRequestRenderer.tsx +123 -0
- package/src/components/resources/renderers/ChallengeRenderer.tsx +111 -0
- package/src/components/resources/renderers/ClusterComplianceReportRenderer.tsx +164 -0
- package/src/components/resources/renderers/ClusterExternalSecretRenderer.tsx +140 -0
- package/src/components/resources/renderers/ClusterIssuerRenderer.tsx +153 -0
- package/src/components/resources/renderers/ConfigAuditReportRenderer.tsx +221 -0
- package/src/components/resources/renderers/ConfigMapRenderer.tsx +124 -0
- package/src/components/resources/renderers/CronJobRenderer.tsx +86 -0
- package/src/components/resources/renderers/EventRenderer.tsx +175 -0
- package/src/components/resources/renderers/ExposedSecretReportRenderer.tsx +119 -0
- package/src/components/resources/renderers/ExternalSecretRenderer.tsx +172 -0
- package/src/components/resources/renderers/FluxHelmReleaseRenderer.tsx +242 -0
- package/src/components/resources/renderers/GRPCRouteRenderer.tsx +263 -0
- package/src/components/resources/renderers/GatewayClassRenderer.tsx +68 -0
- package/src/components/resources/renderers/GatewayRenderer.tsx +218 -0
- package/src/components/resources/renderers/GenericRenderer.tsx +476 -0
- package/src/components/resources/renderers/GitRepositoryRenderer.tsx +154 -0
- package/src/components/resources/renderers/HPARenderer.tsx +164 -0
- package/src/components/resources/renderers/HTTPRouteRenderer.tsx +303 -0
- package/src/components/resources/renderers/HelmRepositoryRenderer.tsx +167 -0
- package/src/components/resources/renderers/IngressClassRenderer.tsx +49 -0
- package/src/components/resources/renderers/IngressRenderer.tsx +104 -0
- package/src/components/resources/renderers/IstioAuthorizationPolicyRenderer.tsx +175 -0
- package/src/components/resources/renderers/IstioDestinationRuleRenderer.tsx +176 -0
- package/src/components/resources/renderers/IstioGatewayRenderer.tsx +125 -0
- package/src/components/resources/renderers/IstioPeerAuthenticationRenderer.tsx +84 -0
- package/src/components/resources/renderers/IstioServiceEntryRenderer.tsx +112 -0
- package/src/components/resources/renderers/IstioVirtualServiceRenderer.tsx +288 -0
- package/src/components/resources/renderers/JobRenderer.tsx +103 -0
- package/src/components/resources/renderers/KarpenterEC2NodeClassRenderer.tsx +157 -0
- package/src/components/resources/renderers/KarpenterNodeClaimRenderer.tsx +188 -0
- package/src/components/resources/renderers/KarpenterNodePoolRenderer.tsx +148 -0
- package/src/components/resources/renderers/KedaScaledJobRenderer.tsx +90 -0
- package/src/components/resources/renderers/KedaScaledObjectRenderer.tsx +136 -0
- package/src/components/resources/renderers/KedaTriggerAuthRenderer.tsx +151 -0
- package/src/components/resources/renderers/KnativeConfigurationRenderer.tsx +70 -0
- package/src/components/resources/renderers/KnativeEventingRenderer.tsx +309 -0
- package/src/components/resources/renderers/KnativeFlowRenderer.tsx +142 -0
- package/src/components/resources/renderers/KnativeNetworkingRenderer.tsx +232 -0
- package/src/components/resources/renderers/KnativeRevisionRenderer.tsx +97 -0
- package/src/components/resources/renderers/KnativeRouteRenderer.tsx +74 -0
- package/src/components/resources/renderers/KnativeServiceRenderer.tsx +138 -0
- package/src/components/resources/renderers/KnativeSourceRenderer.tsx +220 -0
- package/src/components/resources/renderers/KustomizationRenderer.tsx +151 -0
- package/src/components/resources/renderers/KyvernoPolicyReportRenderer.tsx +352 -0
- package/src/components/resources/renderers/LeaseRenderer.tsx +93 -0
- package/src/components/resources/renderers/NetworkPolicyRenderer.tsx +208 -0
- package/src/components/resources/renderers/NodeRenderer.tsx +282 -0
- package/src/components/resources/renderers/OCIRepositoryRenderer.tsx +153 -0
- package/src/components/resources/renderers/OrderRenderer.tsx +108 -0
- package/src/components/resources/renderers/PVCRenderer.tsx +98 -0
- package/src/components/resources/renderers/PersistentVolumeRenderer.tsx +138 -0
- package/src/components/resources/renderers/PodDisruptionBudgetRenderer.tsx +126 -0
- package/src/components/resources/renderers/PodMonitorRenderer.tsx +63 -0
- package/src/components/resources/renderers/PodRenderer.tsx +677 -0
- package/src/components/resources/renderers/PriorityClassRenderer.tsx +50 -0
- package/src/components/resources/renderers/PrometheusRuleRenderer.tsx +62 -0
- package/src/components/resources/renderers/ReplicaSetRenderer.tsx +71 -0
- package/src/components/resources/renderers/RoleBindingRenderer.tsx +97 -0
- package/src/components/resources/renderers/RoleRenderer.tsx +192 -0
- package/src/components/resources/renderers/RolloutRenderer.tsx +227 -0
- package/src/components/resources/renderers/RuntimeClassRenderer.tsx +75 -0
- package/src/components/resources/renderers/SbomReportRenderer.tsx +98 -0
- package/src/components/resources/renderers/SealedSecretRenderer.tsx +121 -0
- package/src/components/resources/renderers/SecretRenderer.tsx +321 -0
- package/src/components/resources/renderers/SecretStoreRenderer.tsx +111 -0
- package/src/components/resources/renderers/ServiceAccountRenderer.tsx +81 -0
- package/src/components/resources/renderers/ServiceMonitorRenderer.tsx +65 -0
- package/src/components/resources/renderers/ServiceRenderer.tsx +98 -0
- package/src/components/resources/renderers/SimpleRouteRenderer.tsx +195 -0
- package/src/components/resources/renderers/StorageClassRenderer.tsx +82 -0
- package/src/components/resources/renderers/TraefikIngressRouteRenderer.tsx +257 -0
- package/src/components/resources/renderers/VPARenderer.tsx +193 -0
- package/src/components/resources/renderers/VeleroBSLRenderer.tsx +73 -0
- package/src/components/resources/renderers/VeleroBackupRenderer.tsx +193 -0
- package/src/components/resources/renderers/VeleroRestoreRenderer.tsx +161 -0
- package/src/components/resources/renderers/VeleroScheduleRenderer.tsx +134 -0
- package/src/components/resources/renderers/VeleroVSLRenderer.tsx +36 -0
- package/src/components/resources/renderers/VulnerabilityReportRenderer.tsx +347 -0
- package/src/components/resources/renderers/WebhookConfigRenderer.tsx +156 -0
- package/src/components/resources/renderers/WorkflowRenderer.tsx +202 -0
- package/src/components/resources/renderers/WorkflowTemplateRenderer.tsx +91 -0
- package/src/components/resources/renderers/WorkloadRenderer.tsx +311 -0
- package/src/components/resources/renderers/argo-cells.tsx +118 -0
- package/src/components/resources/renderers/certmanager-cells.tsx +164 -0
- package/src/components/resources/renderers/cnpg-cells.tsx +161 -0
- package/src/components/resources/renderers/eso-cells.tsx +115 -0
- package/src/components/resources/renderers/flux-cells.tsx +253 -0
- package/src/components/resources/renderers/index.ts +105 -0
- package/src/components/resources/renderers/istio-cells.tsx +210 -0
- package/src/components/resources/renderers/karpenter-cells.tsx +107 -0
- package/src/components/resources/renderers/keda-cells.tsx +94 -0
- package/src/components/resources/renderers/knative-cells.tsx +396 -0
- package/src/components/resources/renderers/kyverno-cells.tsx +85 -0
- package/src/components/resources/renderers/prometheus-cells.tsx +74 -0
- package/src/components/resources/renderers/traefik-cells.tsx +120 -0
- package/src/components/resources/renderers/trivy-cells.tsx +153 -0
- package/src/components/resources/renderers/trivy-shared.tsx +67 -0
- package/src/components/resources/renderers/velero-cells.tsx +164 -0
- package/src/components/resources/resource-utils-argo.ts +155 -0
- package/src/components/resources/resource-utils-certmanager.ts +198 -0
- package/src/components/resources/resource-utils-cnpg.ts +344 -0
- package/src/components/resources/resource-utils-eso.ts +385 -0
- package/src/components/resources/resource-utils-flux.ts +186 -0
- package/src/components/resources/resource-utils-istio.ts +372 -0
- package/src/components/resources/resource-utils-karpenter.ts +150 -0
- package/src/components/resources/resource-utils-keda.ts +203 -0
- package/src/components/resources/resource-utils-knative.ts +218 -0
- package/src/components/resources/resource-utils-kyverno.ts +224 -0
- package/src/components/resources/resource-utils-prometheus.ts +159 -0
- package/src/components/resources/resource-utils-traefik.ts +180 -0
- package/src/components/resources/resource-utils-trivy.ts +146 -0
- package/src/components/resources/resource-utils-velero.ts +293 -0
- package/src/components/resources/resource-utils.ts +1558 -0
- package/src/components/shared/EditableYamlView.tsx +302 -0
- package/src/components/shared/ResourceActionsBar.tsx +924 -0
- package/src/components/shared/ResourceRendererDispatch.tsx +557 -0
- package/src/components/shared/index.ts +3 -0
- package/src/components/timeline/index.ts +1 -0
- package/src/components/timeline/shared.tsx +614 -0
- package/src/components/topology/GroupNode.tsx +198 -0
- package/src/components/topology/K8sResourceNode.tsx +440 -0
- package/src/components/topology/TopologyControls.tsx +62 -0
- package/src/components/topology/TopologyFilterSidebar.tsx +377 -0
- package/src/components/topology/TopologyGraph.tsx +748 -0
- package/src/components/topology/TopologySearch.tsx +304 -0
- package/src/components/topology/index.ts +7 -0
- package/src/components/topology/layout.ts +734 -0
- package/src/components/topology/layout.worker.ts +272 -0
- package/src/components/topology/topology.css +129 -0
- package/src/components/ui/CodeViewer.tsx +451 -0
- package/src/components/ui/ConfirmDialog.tsx +188 -0
- package/src/components/ui/ForceDeleteConfirmDialog.tsx +57 -0
- package/src/components/ui/HealthRing.tsx +72 -0
- package/src/components/ui/MetricsChart.tsx +259 -0
- package/src/components/ui/ResourceBar.tsx +68 -0
- package/src/components/ui/Toast.tsx +225 -0
- package/src/components/ui/Tooltip.tsx +165 -0
- package/src/components/ui/YamlEditor.tsx +280 -0
- package/src/components/ui/drawer-components.tsx +792 -0
- package/src/components/ui/index.ts +10 -0
- package/src/components/workload/ResourceDetailDrawer.tsx +158 -0
- package/src/components/workload/WorkloadView.tsx +1109 -0
- package/src/components/workload/index.ts +2 -0
- package/src/hooks/index.ts +9 -0
- package/src/hooks/useAnimatedUnmount.ts +30 -0
- package/src/hooks/useKeyboardShortcuts.tsx +336 -0
- package/src/hooks/useRefreshAnimation.ts +58 -0
- package/src/index.ts +35 -0
- package/src/types/core.ts +894 -0
- package/src/types/gitops.ts +458 -0
- package/src/types/index.ts +2 -0
- package/src/utils/animation.ts +40 -0
- package/src/utils/api-resources.ts +185 -0
- package/src/utils/badge-colors.ts +399 -0
- package/src/utils/context-name.ts +72 -0
- package/src/utils/download.ts +19 -0
- package/src/utils/format.ts +233 -0
- package/src/utils/index.ts +10 -0
- package/src/utils/log-format.ts +300 -0
- package/src/utils/navigation.ts +119 -0
- package/src/utils/resource-hierarchy.ts +630 -0
- package/src/utils/resource-icons.ts +206 -0
- package/src/vite-env.d.ts +1 -0
- package/tsconfig.json +24 -0
|
@@ -0,0 +1,1109 @@
|
|
|
1
|
+
import { useState, useMemo, useEffect, useRef, useCallback, type ReactNode } from 'react'
|
|
2
|
+
import { flushSync } from 'react-dom'
|
|
3
|
+
import { useRefreshAnimation } from '../../hooks/useRefreshAnimation'
|
|
4
|
+
import { useRegisterShortcuts } from '../../hooks/useKeyboardShortcuts'
|
|
5
|
+
import { clsx } from 'clsx'
|
|
6
|
+
import {
|
|
7
|
+
ArrowLeft,
|
|
8
|
+
RefreshCw,
|
|
9
|
+
Activity,
|
|
10
|
+
Terminal,
|
|
11
|
+
Layers,
|
|
12
|
+
FileText,
|
|
13
|
+
Copy,
|
|
14
|
+
Check,
|
|
15
|
+
Minimize2,
|
|
16
|
+
Maximize2,
|
|
17
|
+
X,
|
|
18
|
+
BarChart3,
|
|
19
|
+
} from 'lucide-react'
|
|
20
|
+
import type { TimelineEvent, ResourceRef, Relationships, SelectedResource } from '../../types'
|
|
21
|
+
import type { NavigateToResource } from '../../utils/navigation'
|
|
22
|
+
import { refToSelectedResource, pluralToKind } from '../../utils/navigation'
|
|
23
|
+
import { isChangeEvent, isHistoricalEvent } from '../../types'
|
|
24
|
+
import { getKindBadgeColor, getHealthBadgeColor } from '../../utils/badge-colors'
|
|
25
|
+
import { buildResourceHierarchy, getAllEventsFromHierarchy, isProblematicEvent, type ResourceLane } from '../../utils/resource-hierarchy'
|
|
26
|
+
import {
|
|
27
|
+
ZOOM_LEVELS,
|
|
28
|
+
type ZoomLevel,
|
|
29
|
+
formatAxisTime,
|
|
30
|
+
EventMarker,
|
|
31
|
+
EventDotLegend,
|
|
32
|
+
HealthSpanLegend,
|
|
33
|
+
HealthSpan,
|
|
34
|
+
ZoomControls,
|
|
35
|
+
buildHealthSpans,
|
|
36
|
+
timeToX,
|
|
37
|
+
calculateTimeRange,
|
|
38
|
+
} from '../timeline/shared'
|
|
39
|
+
import { ResourceActionsBar } from '../shared/ResourceActionsBar'
|
|
40
|
+
import { EditableYamlView, SaveSuccessAnimation } from '../shared/EditableYamlView'
|
|
41
|
+
import { ResourceRendererDispatch, getResourceStatus } from '../shared/ResourceRendererDispatch'
|
|
42
|
+
import { getKindColor, formatKindName } from '../ui/drawer-components'
|
|
43
|
+
|
|
44
|
+
type TabType = 'overview' | 'timeline' | 'logs' | 'metrics' | 'yaml'
|
|
45
|
+
|
|
46
|
+
// ============================================================================
|
|
47
|
+
// MAIN WORKLOAD VIEW — presentation only, data injected via props
|
|
48
|
+
// ============================================================================
|
|
49
|
+
|
|
50
|
+
interface WorkloadViewProps {
|
|
51
|
+
kind: string
|
|
52
|
+
namespace: string
|
|
53
|
+
name: string
|
|
54
|
+
onBack: () => void
|
|
55
|
+
onNavigateToResource?: NavigateToResource
|
|
56
|
+
onCollapseToDrawer?: () => void
|
|
57
|
+
/** false = collapsed drawer mode, true (default) = full expanded mode */
|
|
58
|
+
expanded?: boolean
|
|
59
|
+
/** Close the drawer (collapsed mode) */
|
|
60
|
+
onClose?: () => void
|
|
61
|
+
/** Expand from drawer to full view */
|
|
62
|
+
onExpand?: () => void
|
|
63
|
+
/** Initial view tab — 'yaml' opens YAML directly */
|
|
64
|
+
initialTab?: 'detail' | 'yaml'
|
|
65
|
+
/** API group for CRD resources */
|
|
66
|
+
group?: string
|
|
67
|
+
|
|
68
|
+
// ── Data (injected by wrapper) ──────────────────────────────────────────
|
|
69
|
+
/** The resource data object */
|
|
70
|
+
resource?: any
|
|
71
|
+
/** Resource relationships (pods, owner, config, etc.) */
|
|
72
|
+
relationships?: Relationships
|
|
73
|
+
/** TLS certificate info for secrets */
|
|
74
|
+
certificateInfo?: any
|
|
75
|
+
/** Whether the resource is loading */
|
|
76
|
+
isLoading?: boolean
|
|
77
|
+
/** Function to refetch the resource data */
|
|
78
|
+
refetch?: () => void
|
|
79
|
+
|
|
80
|
+
// ── Timeline data ────────────────────────────────────────────────────────
|
|
81
|
+
/** All timeline events for this resource's namespace */
|
|
82
|
+
allEvents?: TimelineEvent[]
|
|
83
|
+
/** Whether timeline events are loading */
|
|
84
|
+
eventsLoading?: boolean
|
|
85
|
+
/** Topology data for hierarchy building */
|
|
86
|
+
topology?: any
|
|
87
|
+
|
|
88
|
+
// ── Capabilities ─────────────────────────────────────────────────────────
|
|
89
|
+
/** Whether secrets can be updated */
|
|
90
|
+
canUpdateSecrets?: boolean
|
|
91
|
+
|
|
92
|
+
// ── Mutations ────────────────────────────────────────────────────────────
|
|
93
|
+
/** Update a resource from YAML */
|
|
94
|
+
onUpdateResource?: (params: { kind: string; namespace: string; name: string; yaml: string }) => Promise<void>
|
|
95
|
+
/** Whether the resource is being updated */
|
|
96
|
+
isUpdatingResource?: boolean
|
|
97
|
+
|
|
98
|
+
// ── Tab state (optional URL sync) ────────────────────────────────────────
|
|
99
|
+
/** Controlled active tab. If not provided, managed internally. */
|
|
100
|
+
activeTab?: TabType
|
|
101
|
+
/** Called when tab changes (for URL sync etc.) */
|
|
102
|
+
onTabChange?: (tab: TabType) => void
|
|
103
|
+
|
|
104
|
+
// ── Render props for platform-specific content ───────────────────────────
|
|
105
|
+
/** Render the logs tab content */
|
|
106
|
+
renderLogsTab?: (props: {
|
|
107
|
+
kind: string
|
|
108
|
+
apiKind: string
|
|
109
|
+
namespace: string
|
|
110
|
+
name: string
|
|
111
|
+
resource: any
|
|
112
|
+
pods: ResourceRef[]
|
|
113
|
+
selectedPod: string | null
|
|
114
|
+
onSelectPod: (name: string | null) => void
|
|
115
|
+
initialContainer: string | null
|
|
116
|
+
onConsumeInitialContainer: () => void
|
|
117
|
+
}) => ReactNode
|
|
118
|
+
/** Render the metrics tab content */
|
|
119
|
+
renderMetricsTab?: (props: { kind: string; namespace: string; name: string }) => ReactNode
|
|
120
|
+
/** Whether metrics are available for this resource kind */
|
|
121
|
+
isMetricsAvailable?: (kind: string, resource: any) => boolean
|
|
122
|
+
|
|
123
|
+
// ── ResourceActionsBar props (passed through) ────────────────────────────
|
|
124
|
+
/** All props for the actions bar (forwarded as-is) */
|
|
125
|
+
actionsBarProps?: Record<string, any>
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export function WorkloadView({
|
|
129
|
+
kind: kindProp,
|
|
130
|
+
namespace,
|
|
131
|
+
name,
|
|
132
|
+
onBack,
|
|
133
|
+
onNavigateToResource,
|
|
134
|
+
onCollapseToDrawer,
|
|
135
|
+
expanded = true,
|
|
136
|
+
onClose,
|
|
137
|
+
onExpand,
|
|
138
|
+
initialTab,
|
|
139
|
+
group,
|
|
140
|
+
// Data
|
|
141
|
+
resource,
|
|
142
|
+
relationships,
|
|
143
|
+
certificateInfo,
|
|
144
|
+
isLoading: resourceLoading = false,
|
|
145
|
+
refetch: refetchProp,
|
|
146
|
+
// Timeline
|
|
147
|
+
allEvents,
|
|
148
|
+
eventsLoading = false,
|
|
149
|
+
topology,
|
|
150
|
+
// Capabilities
|
|
151
|
+
canUpdateSecrets,
|
|
152
|
+
// Mutations
|
|
153
|
+
onUpdateResource,
|
|
154
|
+
isUpdatingResource,
|
|
155
|
+
// Tab state
|
|
156
|
+
activeTab: controlledTab,
|
|
157
|
+
onTabChange,
|
|
158
|
+
// Render props
|
|
159
|
+
renderLogsTab,
|
|
160
|
+
renderMetricsTab,
|
|
161
|
+
isMetricsAvailable,
|
|
162
|
+
// Actions bar
|
|
163
|
+
actionsBarProps,
|
|
164
|
+
}: WorkloadViewProps) {
|
|
165
|
+
// Normalize kind: URL has plural lowercase, internal logic uses singular PascalCase
|
|
166
|
+
const kind = pluralToKind(kindProp)
|
|
167
|
+
const apiKind = kindProp
|
|
168
|
+
|
|
169
|
+
// Tab state — controlled or uncontrolled
|
|
170
|
+
const [internalTab, setInternalTab] = useState<TabType>('overview')
|
|
171
|
+
const activeTab = controlledTab ?? internalTab
|
|
172
|
+
const handleSetTab = useCallback((tab: TabType) => {
|
|
173
|
+
setInternalTab(tab)
|
|
174
|
+
onTabChange?.(tab)
|
|
175
|
+
}, [onTabChange])
|
|
176
|
+
|
|
177
|
+
// Collapsed mode state (YAML toggle for drawer mode)
|
|
178
|
+
const [showYaml, setShowYaml] = useState(initialTab === 'yaml')
|
|
179
|
+
useEffect(() => {
|
|
180
|
+
setShowYaml(initialTab === 'yaml')
|
|
181
|
+
}, [kindProp, namespace, name, initialTab])
|
|
182
|
+
|
|
183
|
+
const switchView = useCallback((yaml: boolean) => {
|
|
184
|
+
const update = () => flushSync(() => setShowYaml(yaml))
|
|
185
|
+
if (document.startViewTransition) {
|
|
186
|
+
document.startViewTransition(update)
|
|
187
|
+
} else {
|
|
188
|
+
setShowYaml(yaml)
|
|
189
|
+
}
|
|
190
|
+
}, [])
|
|
191
|
+
|
|
192
|
+
const [selectedEventId, setSelectedEventId] = useState<string | null>(null)
|
|
193
|
+
const [zoom, setZoom] = useState<ZoomLevel>(1)
|
|
194
|
+
const [selectedPod, setSelectedPod] = useState<string | null>(null)
|
|
195
|
+
const [initialContainer, setInitialContainer] = useState<string | null>(null)
|
|
196
|
+
const [copied, setCopied] = useState<string | null>(null)
|
|
197
|
+
const [saveSuccess, setSaveSuccess] = useState(false)
|
|
198
|
+
|
|
199
|
+
// Refresh animation
|
|
200
|
+
const [refetch, isRefreshAnimating, refreshPhase] = useRefreshAnimation(refetchProp ?? (() => {}))
|
|
201
|
+
|
|
202
|
+
// Build resource hierarchy
|
|
203
|
+
const resourceLanes = useMemo(() => {
|
|
204
|
+
if (!allEvents) return []
|
|
205
|
+
return buildResourceHierarchy({
|
|
206
|
+
events: allEvents,
|
|
207
|
+
topology,
|
|
208
|
+
rootResource: { kind, namespace, name },
|
|
209
|
+
groupByApp: true,
|
|
210
|
+
})
|
|
211
|
+
}, [allEvents, topology, kind, namespace, name])
|
|
212
|
+
|
|
213
|
+
// Flatten events from hierarchy
|
|
214
|
+
const resourceEvents = useMemo(() => {
|
|
215
|
+
return getAllEventsFromHierarchy(resourceLanes)
|
|
216
|
+
}, [resourceLanes])
|
|
217
|
+
|
|
218
|
+
// Get pods from relationships and hierarchy
|
|
219
|
+
const childPods = useMemo(() => {
|
|
220
|
+
if (resourceLanes.length === 0) return []
|
|
221
|
+
const rootLane = resourceLanes[0]
|
|
222
|
+
const pods: { name: string; namespace: string; events: TimelineEvent[] }[] = []
|
|
223
|
+
const collectPods = (lane: ResourceLane) => {
|
|
224
|
+
if (lane.kind === 'Pod') {
|
|
225
|
+
pods.push({ name: lane.name, namespace: lane.namespace, events: lane.events })
|
|
226
|
+
}
|
|
227
|
+
lane.children?.forEach(collectPods)
|
|
228
|
+
}
|
|
229
|
+
rootLane.children?.forEach(collectPods)
|
|
230
|
+
if (rootLane.kind === 'Pod') {
|
|
231
|
+
pods.push({ name: rootLane.name, namespace: rootLane.namespace, events: rootLane.events })
|
|
232
|
+
}
|
|
233
|
+
return pods
|
|
234
|
+
}, [resourceLanes])
|
|
235
|
+
|
|
236
|
+
const pods = relationships?.pods || []
|
|
237
|
+
const allPods: ResourceRef[] = useMemo(() => {
|
|
238
|
+
const combined = [
|
|
239
|
+
...pods,
|
|
240
|
+
...childPods.map(p => ({ kind: 'Pod' as const, namespace: p.namespace, name: p.name })),
|
|
241
|
+
]
|
|
242
|
+
const seen = new Set<string>()
|
|
243
|
+
return combined.filter(p => {
|
|
244
|
+
const key = `${p.namespace}/${p.name}`
|
|
245
|
+
if (seen.has(key)) return false
|
|
246
|
+
seen.add(key)
|
|
247
|
+
return true
|
|
248
|
+
})
|
|
249
|
+
}, [pods, childPods])
|
|
250
|
+
|
|
251
|
+
// Metadata
|
|
252
|
+
const metadata = useMemo(() => extractMetadata(kind, resource), [kind, resource])
|
|
253
|
+
|
|
254
|
+
// Copy to clipboard
|
|
255
|
+
const copyToClipboard = useCallback((text: string, key: string) => {
|
|
256
|
+
navigator.clipboard.writeText(text)
|
|
257
|
+
setCopied(key)
|
|
258
|
+
setTimeout(() => setCopied(null), 2000)
|
|
259
|
+
}, [])
|
|
260
|
+
|
|
261
|
+
const handleSaveSecretValue = useCallback(async (yaml: string) => {
|
|
262
|
+
if (!onUpdateResource) return
|
|
263
|
+
try {
|
|
264
|
+
await onUpdateResource({
|
|
265
|
+
kind: apiKind,
|
|
266
|
+
namespace,
|
|
267
|
+
name,
|
|
268
|
+
yaml,
|
|
269
|
+
})
|
|
270
|
+
setTimeout(() => refetch(), 1000)
|
|
271
|
+
} catch {
|
|
272
|
+
// Error handled by mutation (toast)
|
|
273
|
+
}
|
|
274
|
+
}, [onUpdateResource, apiKind, namespace, name, refetch])
|
|
275
|
+
|
|
276
|
+
const handleSaved = useCallback(() => {
|
|
277
|
+
setSaveSuccess(true)
|
|
278
|
+
setTimeout(() => {
|
|
279
|
+
refetch()
|
|
280
|
+
setTimeout(() => setSaveSuccess(false), 2000)
|
|
281
|
+
}, 1000)
|
|
282
|
+
}, [refetch])
|
|
283
|
+
|
|
284
|
+
// Handle "open logs" from container-level buttons (e.g., PodRenderer) — switch to Logs tab with right pod+container
|
|
285
|
+
const handleOpenLogs = useCallback((podName: string, containerName: string) => {
|
|
286
|
+
setSelectedPod(podName)
|
|
287
|
+
setInitialContainer(containerName)
|
|
288
|
+
handleSetTab('logs')
|
|
289
|
+
}, [handleSetTab])
|
|
290
|
+
|
|
291
|
+
// Selected resource object for shared components
|
|
292
|
+
const selectedResource: SelectedResource = useMemo(() => ({
|
|
293
|
+
kind: apiKind,
|
|
294
|
+
namespace,
|
|
295
|
+
name,
|
|
296
|
+
group,
|
|
297
|
+
}), [apiKind, namespace, name, group])
|
|
298
|
+
|
|
299
|
+
// Keyboard shortcuts — different behavior for expanded vs collapsed mode
|
|
300
|
+
useRegisterShortcuts(useMemo(() => [
|
|
301
|
+
{
|
|
302
|
+
id: 'workload-escape',
|
|
303
|
+
keys: 'Escape',
|
|
304
|
+
description: expanded ? 'Go back' : 'Close drawer',
|
|
305
|
+
category: expanded ? 'Navigation' as const : 'Drawer' as const,
|
|
306
|
+
scope: expanded ? 'global' as const : 'drawer' as const,
|
|
307
|
+
handler: expanded ? onBack : () => onClose?.(),
|
|
308
|
+
enabled: true,
|
|
309
|
+
},
|
|
310
|
+
{
|
|
311
|
+
id: 'drawer-yaml',
|
|
312
|
+
keys: 'y',
|
|
313
|
+
description: 'Switch to YAML view',
|
|
314
|
+
category: 'Drawer' as const,
|
|
315
|
+
scope: 'drawer' as const,
|
|
316
|
+
handler: () => switchView(true),
|
|
317
|
+
enabled: !expanded,
|
|
318
|
+
},
|
|
319
|
+
{
|
|
320
|
+
id: 'drawer-detail',
|
|
321
|
+
keys: 'e',
|
|
322
|
+
description: 'Switch to detail view',
|
|
323
|
+
category: 'Drawer' as const,
|
|
324
|
+
scope: 'drawer' as const,
|
|
325
|
+
handler: () => switchView(false),
|
|
326
|
+
enabled: !expanded,
|
|
327
|
+
},
|
|
328
|
+
], [expanded, onBack, onClose, switchView]))
|
|
329
|
+
|
|
330
|
+
const status = getResourceStatus(apiKind, resource)
|
|
331
|
+
|
|
332
|
+
const showMetricsTab = isMetricsAvailable ? isMetricsAvailable(kind, resource) : false
|
|
333
|
+
|
|
334
|
+
// ── Collapsed (drawer) mode ──────────────────────────────────────────────
|
|
335
|
+
if (!expanded) {
|
|
336
|
+
return (
|
|
337
|
+
<div className="flex flex-col h-full w-full">
|
|
338
|
+
{/* Drawer header */}
|
|
339
|
+
<div className="border-b border-theme-border shrink-0">
|
|
340
|
+
{/* Top row: badges and controls */}
|
|
341
|
+
<div className="flex items-center justify-between px-4 pt-3 pb-2">
|
|
342
|
+
<div className="flex items-center gap-2 flex-wrap">
|
|
343
|
+
<span className={clsx('px-2 py-0.5 text-xs font-medium rounded border', getKindColor(apiKind))}>
|
|
344
|
+
{formatKindName(apiKind)}
|
|
345
|
+
</span>
|
|
346
|
+
{status && (
|
|
347
|
+
<span className={clsx('px-2 py-0.5 text-xs font-medium rounded', status.color)}>
|
|
348
|
+
{status.text}
|
|
349
|
+
</span>
|
|
350
|
+
)}
|
|
351
|
+
</div>
|
|
352
|
+
<div className="flex items-center gap-1">
|
|
353
|
+
{onExpand && (
|
|
354
|
+
<button
|
|
355
|
+
onClick={onExpand}
|
|
356
|
+
className="p-1.5 text-theme-text-secondary hover:text-theme-text-primary hover:bg-theme-elevated rounded"
|
|
357
|
+
title="Open full view"
|
|
358
|
+
>
|
|
359
|
+
<Maximize2 className="w-4 h-4" />
|
|
360
|
+
</button>
|
|
361
|
+
)}
|
|
362
|
+
<button
|
|
363
|
+
onClick={() => refetch()}
|
|
364
|
+
disabled={isRefreshAnimating}
|
|
365
|
+
className={clsx(
|
|
366
|
+
'p-1.5 hover:bg-theme-elevated rounded disabled:opacity-50 transition-colors duration-500',
|
|
367
|
+
refreshPhase === 'success' ? 'text-emerald-400' : 'text-theme-text-secondary hover:text-theme-text-primary'
|
|
368
|
+
)}
|
|
369
|
+
title="Refresh"
|
|
370
|
+
>
|
|
371
|
+
{refreshPhase === 'success'
|
|
372
|
+
? <Check className="w-4 h-4 stroke-[2.5]" />
|
|
373
|
+
: <RefreshCw className={clsx('w-4 h-4', refreshPhase === 'spinning' && 'animate-spin')} />
|
|
374
|
+
}
|
|
375
|
+
</button>
|
|
376
|
+
{onClose && (
|
|
377
|
+
<button onClick={onClose} className="p-1.5 text-theme-text-secondary hover:text-theme-text-primary hover:bg-theme-elevated rounded" title="Close (Esc)">
|
|
378
|
+
<X className="w-4 h-4" />
|
|
379
|
+
</button>
|
|
380
|
+
)}
|
|
381
|
+
</div>
|
|
382
|
+
</div>
|
|
383
|
+
|
|
384
|
+
{/* Name and namespace */}
|
|
385
|
+
<div className="px-4 pb-3">
|
|
386
|
+
<div className="flex items-center gap-2">
|
|
387
|
+
<h2 className="text-lg font-semibold text-theme-text-primary truncate">{name}</h2>
|
|
388
|
+
<button
|
|
389
|
+
onClick={() => copyToClipboard(name, 'name')}
|
|
390
|
+
className="p-1 text-theme-text-secondary hover:text-theme-text-primary hover:bg-theme-elevated rounded shrink-0"
|
|
391
|
+
title="Copy name"
|
|
392
|
+
>
|
|
393
|
+
{copied === 'name' ? <Check className="w-3.5 h-3.5 text-green-400" /> : <Copy className="w-3.5 h-3.5" />}
|
|
394
|
+
</button>
|
|
395
|
+
</div>
|
|
396
|
+
<p className="text-sm text-theme-text-tertiary">{namespace}</p>
|
|
397
|
+
</div>
|
|
398
|
+
|
|
399
|
+
{/* Actions bar */}
|
|
400
|
+
<ResourceActionsBar resource={selectedResource} data={resource} onClose={onClose} showYaml={showYaml} onToggleYaml={() => switchView(!showYaml)} {...actionsBarProps} />
|
|
401
|
+
</div>
|
|
402
|
+
|
|
403
|
+
{/* Success animation overlay */}
|
|
404
|
+
{saveSuccess && <SaveSuccessAnimation />}
|
|
405
|
+
|
|
406
|
+
{/* Content — viewTransitionName scopes View Transitions API cross-fade to this element */}
|
|
407
|
+
<div className="flex-1 overflow-y-auto" style={{ viewTransitionName: 'drawer-content' }}>
|
|
408
|
+
{resourceLoading ? (
|
|
409
|
+
<div className="flex items-center justify-center h-32 text-theme-text-tertiary">Loading...</div>
|
|
410
|
+
) : !resource ? (
|
|
411
|
+
<div className="flex items-center justify-center h-32 text-theme-text-tertiary">Resource not found</div>
|
|
412
|
+
) : showYaml ? (
|
|
413
|
+
<EditableYamlView
|
|
414
|
+
resource={selectedResource}
|
|
415
|
+
data={resource}
|
|
416
|
+
onCopy={(text) => copyToClipboard(text, 'yaml')}
|
|
417
|
+
copied={copied === 'yaml'}
|
|
418
|
+
onSaved={handleSaved}
|
|
419
|
+
/>
|
|
420
|
+
) : (
|
|
421
|
+
<ResourceRendererDispatch
|
|
422
|
+
resource={selectedResource}
|
|
423
|
+
data={resource}
|
|
424
|
+
relationships={relationships}
|
|
425
|
+
certificateInfo={certificateInfo}
|
|
426
|
+
onCopy={copyToClipboard}
|
|
427
|
+
copied={copied}
|
|
428
|
+
onNavigate={onNavigateToResource ? (ref) => onNavigateToResource(refToSelectedResource(ref)) : undefined}
|
|
429
|
+
onSaveSecretValue={canUpdateSecrets ? handleSaveSecretValue : undefined}
|
|
430
|
+
isSavingSecret={isUpdatingResource}
|
|
431
|
+
/>
|
|
432
|
+
)}
|
|
433
|
+
</div>
|
|
434
|
+
</div>
|
|
435
|
+
)
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
// ── Expanded (full) mode ─────────────────────────────────────────────────
|
|
439
|
+
return (
|
|
440
|
+
<div className="flex flex-col h-full w-full bg-theme-base">
|
|
441
|
+
{/* Header */}
|
|
442
|
+
<div className="shrink-0 border-b border-theme-border bg-theme-surface">
|
|
443
|
+
<div className="px-6 py-3 flex items-start gap-4">
|
|
444
|
+
{/* Back button */}
|
|
445
|
+
<button
|
|
446
|
+
onClick={onBack}
|
|
447
|
+
className="p-1.5 mt-0.5 text-theme-text-secondary hover:text-theme-text-primary hover:bg-theme-elevated rounded-lg transition-colors"
|
|
448
|
+
title="Go back (Esc)"
|
|
449
|
+
>
|
|
450
|
+
<ArrowLeft className="w-5 h-5" />
|
|
451
|
+
</button>
|
|
452
|
+
|
|
453
|
+
{/* Resource identity */}
|
|
454
|
+
<div className="flex-1 min-w-0">
|
|
455
|
+
<div className="flex items-center gap-3 mb-1">
|
|
456
|
+
<h1 className="text-lg font-semibold text-theme-text-primary truncate">{name}</h1>
|
|
457
|
+
<button
|
|
458
|
+
onClick={() => copyToClipboard(name, 'name')}
|
|
459
|
+
className="p-1 text-theme-text-secondary hover:text-theme-text-primary hover:bg-theme-elevated rounded shrink-0"
|
|
460
|
+
title="Copy name"
|
|
461
|
+
>
|
|
462
|
+
{copied === 'name' ? <Check className="w-3.5 h-3.5 text-green-400" /> : <Copy className="w-3.5 h-3.5" />}
|
|
463
|
+
</button>
|
|
464
|
+
</div>
|
|
465
|
+
<div className="flex items-center gap-3 text-sm text-theme-text-secondary">
|
|
466
|
+
<span className={clsx('px-2 py-0.5 text-xs font-medium rounded border', getKindColor(apiKind))}>
|
|
467
|
+
{formatKindName(apiKind)}
|
|
468
|
+
</span>
|
|
469
|
+
{status && (
|
|
470
|
+
<span className={clsx('px-2 py-0.5 text-xs font-medium rounded', status.color)}>
|
|
471
|
+
{status.text}
|
|
472
|
+
</span>
|
|
473
|
+
)}
|
|
474
|
+
{namespace && namespace !== '_' && (
|
|
475
|
+
<span>Namespace: <span className="text-theme-text-primary">{namespace}</span></span>
|
|
476
|
+
)}
|
|
477
|
+
{metadata.find(m => m.label === 'Image') && (
|
|
478
|
+
<span className="truncate max-w-md font-mono text-xs">{metadata.find(m => m.label === 'Image')?.value}</span>
|
|
479
|
+
)}
|
|
480
|
+
{relationships?.owner && (
|
|
481
|
+
<span>Owner: <button onClick={() => onNavigateToResource?.(refToSelectedResource(relationships.owner!))} className="text-blue-500 hover:underline">{relationships.owner.name}</button></span>
|
|
482
|
+
)}
|
|
483
|
+
</div>
|
|
484
|
+
</div>
|
|
485
|
+
|
|
486
|
+
{/* Refresh */}
|
|
487
|
+
<button
|
|
488
|
+
onClick={() => refetch()}
|
|
489
|
+
disabled={isRefreshAnimating}
|
|
490
|
+
className={clsx(
|
|
491
|
+
'p-1.5 mt-0.5 hover:bg-theme-elevated rounded disabled:opacity-50 transition-colors duration-500',
|
|
492
|
+
refreshPhase === 'success' ? 'text-emerald-400' : 'text-theme-text-secondary hover:text-theme-text-primary'
|
|
493
|
+
)}
|
|
494
|
+
title="Refresh"
|
|
495
|
+
>
|
|
496
|
+
{refreshPhase === 'success'
|
|
497
|
+
? <Check className="w-5 h-5 stroke-[2.5]" />
|
|
498
|
+
: <RefreshCw className={clsx('w-5 h-5', refreshPhase === 'spinning' && 'animate-spin')} />
|
|
499
|
+
}
|
|
500
|
+
</button>
|
|
501
|
+
|
|
502
|
+
{/* Collapse back to drawer */}
|
|
503
|
+
{onCollapseToDrawer && (
|
|
504
|
+
<button
|
|
505
|
+
onClick={onCollapseToDrawer}
|
|
506
|
+
className="p-1.5 mt-0.5 text-theme-text-secondary hover:text-theme-text-primary hover:bg-theme-elevated rounded-lg transition-colors"
|
|
507
|
+
title="Collapse to drawer"
|
|
508
|
+
>
|
|
509
|
+
<Minimize2 className="w-5 h-5" />
|
|
510
|
+
</button>
|
|
511
|
+
)}
|
|
512
|
+
</div>
|
|
513
|
+
|
|
514
|
+
{/* Tabs (left) + Actions (right) */}
|
|
515
|
+
<div className="px-6 flex items-center border-t border-theme-border">
|
|
516
|
+
<div className="flex gap-1">
|
|
517
|
+
<TabButton active={activeTab === 'overview'} onClick={() => handleSetTab('overview')}>
|
|
518
|
+
<Layers className="w-4 h-4" />
|
|
519
|
+
Overview
|
|
520
|
+
</TabButton>
|
|
521
|
+
<TabButton active={activeTab === 'timeline'} onClick={() => handleSetTab('timeline')}>
|
|
522
|
+
<Activity className="w-4 h-4" />
|
|
523
|
+
Timeline
|
|
524
|
+
{resourceEvents.length > 0 && (
|
|
525
|
+
<span className="ml-1 px-1.5 py-0.5 text-xs bg-theme-elevated rounded">{resourceEvents.length}</span>
|
|
526
|
+
)}
|
|
527
|
+
</TabButton>
|
|
528
|
+
{allPods.length > 0 && renderLogsTab && (
|
|
529
|
+
<TabButton active={activeTab === 'logs'} onClick={() => handleSetTab('logs')}>
|
|
530
|
+
<Terminal className="w-4 h-4" />
|
|
531
|
+
Logs
|
|
532
|
+
</TabButton>
|
|
533
|
+
)}
|
|
534
|
+
{showMetricsTab && renderMetricsTab && (
|
|
535
|
+
<TabButton active={activeTab === 'metrics'} onClick={() => handleSetTab('metrics')}>
|
|
536
|
+
<BarChart3 className="w-4 h-4" />
|
|
537
|
+
Metrics
|
|
538
|
+
</TabButton>
|
|
539
|
+
)}
|
|
540
|
+
<TabButton active={activeTab === 'yaml'} onClick={() => handleSetTab('yaml')}>
|
|
541
|
+
<FileText className="w-4 h-4" />
|
|
542
|
+
YAML
|
|
543
|
+
</TabButton>
|
|
544
|
+
</div>
|
|
545
|
+
<div className="ml-auto">
|
|
546
|
+
<ResourceActionsBar resource={selectedResource} data={resource} hideLogs {...actionsBarProps} />
|
|
547
|
+
</div>
|
|
548
|
+
</div>
|
|
549
|
+
</div>
|
|
550
|
+
|
|
551
|
+
{/* Success animation overlay */}
|
|
552
|
+
{saveSuccess && <SaveSuccessAnimation />}
|
|
553
|
+
|
|
554
|
+
{/* Tab Content */}
|
|
555
|
+
<div className="flex-1 overflow-hidden relative">
|
|
556
|
+
{activeTab === 'overview' && (
|
|
557
|
+
<InfoTab
|
|
558
|
+
resource={resource}
|
|
559
|
+
selectedResource={selectedResource}
|
|
560
|
+
relationships={relationships}
|
|
561
|
+
isLoading={resourceLoading}
|
|
562
|
+
onNavigate={onNavigateToResource}
|
|
563
|
+
onCopy={copyToClipboard}
|
|
564
|
+
copied={copied}
|
|
565
|
+
onSaveSecretValue={canUpdateSecrets ? handleSaveSecretValue : undefined}
|
|
566
|
+
isSavingSecret={isUpdatingResource}
|
|
567
|
+
onOpenLogs={handleOpenLogs}
|
|
568
|
+
onSwitchToTimeline={() => handleSetTab('timeline')}
|
|
569
|
+
/>
|
|
570
|
+
)}
|
|
571
|
+
{activeTab === 'timeline' && (
|
|
572
|
+
<EventsTab
|
|
573
|
+
events={resourceEvents}
|
|
574
|
+
resourceLanes={resourceLanes}
|
|
575
|
+
isLoading={eventsLoading}
|
|
576
|
+
zoom={zoom}
|
|
577
|
+
onZoomChange={setZoom}
|
|
578
|
+
resourceKind={kind}
|
|
579
|
+
resourceName={name}
|
|
580
|
+
selectedEventId={selectedEventId}
|
|
581
|
+
onSelectEvent={setSelectedEventId}
|
|
582
|
+
/>
|
|
583
|
+
)}
|
|
584
|
+
{activeTab === 'logs' && renderLogsTab && (
|
|
585
|
+
renderLogsTab({
|
|
586
|
+
kind,
|
|
587
|
+
apiKind,
|
|
588
|
+
namespace,
|
|
589
|
+
name,
|
|
590
|
+
resource,
|
|
591
|
+
pods: allPods,
|
|
592
|
+
selectedPod,
|
|
593
|
+
onSelectPod: setSelectedPod,
|
|
594
|
+
initialContainer,
|
|
595
|
+
onConsumeInitialContainer: () => setInitialContainer(null),
|
|
596
|
+
})
|
|
597
|
+
)}
|
|
598
|
+
{activeTab === 'metrics' && renderMetricsTab && (
|
|
599
|
+
<div className="h-full overflow-auto p-4">
|
|
600
|
+
{renderMetricsTab({ kind: resource?.kind || kind, namespace, name })}
|
|
601
|
+
</div>
|
|
602
|
+
)}
|
|
603
|
+
{activeTab === 'yaml' && (
|
|
604
|
+
<div className="h-full overflow-auto">
|
|
605
|
+
{resourceLoading ? (
|
|
606
|
+
<div className="flex items-center justify-center h-32 text-theme-text-tertiary">Loading...</div>
|
|
607
|
+
) : !resource ? (
|
|
608
|
+
<div className="flex items-center justify-center h-32 text-theme-text-tertiary">Resource not found</div>
|
|
609
|
+
) : (
|
|
610
|
+
<EditableYamlView
|
|
611
|
+
resource={selectedResource}
|
|
612
|
+
data={resource}
|
|
613
|
+
onCopy={(text) => copyToClipboard(text, 'yaml')}
|
|
614
|
+
copied={copied === 'yaml'}
|
|
615
|
+
onSaved={handleSaved}
|
|
616
|
+
/>
|
|
617
|
+
)}
|
|
618
|
+
</div>
|
|
619
|
+
)}
|
|
620
|
+
</div>
|
|
621
|
+
</div>
|
|
622
|
+
)
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
// ============================================================================
|
|
626
|
+
// HELPER FUNCTIONS
|
|
627
|
+
// ============================================================================
|
|
628
|
+
|
|
629
|
+
function extractMetadata(kind: string, resource: any): { label: string; value: string }[] {
|
|
630
|
+
if (!resource) return []
|
|
631
|
+
const items: { label: string; value: string }[] = []
|
|
632
|
+
const spec = resource.spec || {}
|
|
633
|
+
const status = resource.status || {}
|
|
634
|
+
|
|
635
|
+
switch (kind) {
|
|
636
|
+
case 'Deployment':
|
|
637
|
+
case 'StatefulSet':
|
|
638
|
+
case 'Rollout': {
|
|
639
|
+
const containers = spec.template?.spec?.containers || []
|
|
640
|
+
if (containers[0]?.image) items.push({ label: 'Image', value: containers[0].image })
|
|
641
|
+
break
|
|
642
|
+
}
|
|
643
|
+
case 'DaemonSet': {
|
|
644
|
+
const dsContainers = spec.template?.spec?.containers || []
|
|
645
|
+
if (dsContainers[0]?.image) items.push({ label: 'Image', value: dsContainers[0].image })
|
|
646
|
+
break
|
|
647
|
+
}
|
|
648
|
+
case 'Pod':
|
|
649
|
+
if (status.phase) items.push({ label: 'Phase', value: status.phase })
|
|
650
|
+
if (status.podIP) items.push({ label: 'Pod IP', value: status.podIP })
|
|
651
|
+
break
|
|
652
|
+
case 'CronJob':
|
|
653
|
+
if (spec.schedule) items.push({ label: 'Schedule', value: spec.schedule })
|
|
654
|
+
break
|
|
655
|
+
case 'Job':
|
|
656
|
+
if (status.succeeded !== undefined) items.push({ label: 'Succeeded', value: String(status.succeeded) })
|
|
657
|
+
break
|
|
658
|
+
}
|
|
659
|
+
return items
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
// ============================================================================
|
|
663
|
+
// SUB-COMPONENTS
|
|
664
|
+
// ============================================================================
|
|
665
|
+
|
|
666
|
+
function TabButton({ active, onClick, children }: { active: boolean; onClick: () => void; children: React.ReactNode }) {
|
|
667
|
+
return (
|
|
668
|
+
<button
|
|
669
|
+
onClick={onClick}
|
|
670
|
+
className={clsx(
|
|
671
|
+
'flex items-center gap-1.5 px-3 py-2 text-sm font-medium border-b-2 transition-colors',
|
|
672
|
+
active
|
|
673
|
+
? 'text-theme-text-primary border-blue-500'
|
|
674
|
+
: 'text-theme-text-secondary border-transparent hover:text-theme-text-primary hover:border-theme-border-light'
|
|
675
|
+
)}
|
|
676
|
+
>
|
|
677
|
+
{children}
|
|
678
|
+
</button>
|
|
679
|
+
)
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
// ============================================================================
|
|
683
|
+
// EVENTS TAB (Swimlane timeline)
|
|
684
|
+
// ============================================================================
|
|
685
|
+
|
|
686
|
+
function EventsTab({
|
|
687
|
+
events,
|
|
688
|
+
resourceLanes,
|
|
689
|
+
isLoading,
|
|
690
|
+
zoom,
|
|
691
|
+
onZoomChange,
|
|
692
|
+
resourceKind,
|
|
693
|
+
resourceName,
|
|
694
|
+
selectedEventId,
|
|
695
|
+
onSelectEvent,
|
|
696
|
+
}: {
|
|
697
|
+
events: TimelineEvent[]
|
|
698
|
+
resourceLanes: ResourceLane[]
|
|
699
|
+
isLoading: boolean
|
|
700
|
+
zoom: ZoomLevel
|
|
701
|
+
onZoomChange: (zoom: ZoomLevel) => void
|
|
702
|
+
resourceKind: string
|
|
703
|
+
resourceName: string
|
|
704
|
+
selectedEventId: string | null
|
|
705
|
+
onSelectEvent: (id: string | null) => void
|
|
706
|
+
}) {
|
|
707
|
+
const rowRefs = useRef<Map<number, HTMLTableRowElement>>(new Map())
|
|
708
|
+
const tableContainerRef = useRef<HTMLDivElement>(null)
|
|
709
|
+
const [hoveredEventId, setHoveredEventId] = useState<string | null>(null)
|
|
710
|
+
const [visibleRowRange, setVisibleRowRange] = useState<{ first: number; last: number } | null>(null)
|
|
711
|
+
|
|
712
|
+
// Scroll to selected event
|
|
713
|
+
useEffect(() => {
|
|
714
|
+
if (selectedEventId) {
|
|
715
|
+
const eventIndex = events.findIndex(e => e.id === selectedEventId)
|
|
716
|
+
if (eventIndex >= 0) {
|
|
717
|
+
const row = rowRefs.current.get(eventIndex)
|
|
718
|
+
if (row) row.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
}, [selectedEventId, events])
|
|
722
|
+
|
|
723
|
+
// Track visible rows via IntersectionObserver
|
|
724
|
+
useEffect(() => {
|
|
725
|
+
if (!tableContainerRef.current || events.length === 0) return
|
|
726
|
+
const visibleIndices = new Set<number>()
|
|
727
|
+
const observer = new IntersectionObserver(
|
|
728
|
+
(entries) => {
|
|
729
|
+
for (const entry of entries) {
|
|
730
|
+
const idx = parseInt(entry.target.getAttribute('data-row-index') || '-1', 10)
|
|
731
|
+
if (idx >= 0) {
|
|
732
|
+
if (entry.isIntersecting) visibleIndices.add(idx)
|
|
733
|
+
else visibleIndices.delete(idx)
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
if (visibleIndices.size > 0) {
|
|
737
|
+
const indices = Array.from(visibleIndices)
|
|
738
|
+
setVisibleRowRange({ first: Math.min(...indices), last: Math.max(...indices) })
|
|
739
|
+
} else {
|
|
740
|
+
setVisibleRowRange(null)
|
|
741
|
+
}
|
|
742
|
+
},
|
|
743
|
+
{ root: tableContainerRef.current, threshold: 0.1 }
|
|
744
|
+
)
|
|
745
|
+
const timeoutId = setTimeout(() => {
|
|
746
|
+
rowRefs.current.forEach((row) => observer.observe(row))
|
|
747
|
+
}, 100)
|
|
748
|
+
return () => { clearTimeout(timeoutId); observer.disconnect() }
|
|
749
|
+
}, [events])
|
|
750
|
+
|
|
751
|
+
// Visible time range from visible rows
|
|
752
|
+
const visibleTimeRangeFromRows = useMemo(() => {
|
|
753
|
+
if (!visibleRowRange || events.length === 0) return null
|
|
754
|
+
const visibleEvents = events.slice(visibleRowRange.first, visibleRowRange.last + 1)
|
|
755
|
+
if (visibleEvents.length === 0) return null
|
|
756
|
+
const timestamps = visibleEvents.map(e => new Date(e.timestamp).getTime())
|
|
757
|
+
const start = Math.min(...timestamps)
|
|
758
|
+
const end = Math.max(...timestamps)
|
|
759
|
+
const timeSpan = end - start
|
|
760
|
+
const padding = Math.max(timeSpan * 0.1, 60000)
|
|
761
|
+
return { start: start - padding, end: end + padding }
|
|
762
|
+
}, [events, visibleRowRange])
|
|
763
|
+
|
|
764
|
+
const now = Date.now()
|
|
765
|
+
const { start: startTime, windowMs } = calculateTimeRange(zoom, now)
|
|
766
|
+
const zoomIndex = ZOOM_LEVELS.indexOf(zoom)
|
|
767
|
+
const canZoomIn = zoomIndex > 0
|
|
768
|
+
const canZoomOut = zoomIndex < ZOOM_LEVELS.length - 1
|
|
769
|
+
const handleZoomIn = () => { if (canZoomIn) onZoomChange(ZOOM_LEVELS[zoomIndex - 1]) }
|
|
770
|
+
const handleZoomOut = () => { if (canZoomOut) onZoomChange(ZOOM_LEVELS[zoomIndex + 1]) }
|
|
771
|
+
const localTimeToX = (ts: number) => timeToX(ts, startTime, windowMs)
|
|
772
|
+
|
|
773
|
+
// Build swimlanes
|
|
774
|
+
const swimlanes = useMemo(() => {
|
|
775
|
+
type SwimLane = {
|
|
776
|
+
id: string; label: string
|
|
777
|
+
spans: { start: number; end: number; health: string }[]
|
|
778
|
+
events: TimelineEvent[]
|
|
779
|
+
createdAt?: number; createdBeforeWindow: boolean
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
if (resourceLanes.length === 0) {
|
|
783
|
+
const mainResourceEvents = events.filter(e => e.kind === resourceKind && e.name === resourceName)
|
|
784
|
+
const healthResult = buildHealthSpans(mainResourceEvents.filter(e => isChangeEvent(e)), startTime, now, mainResourceEvents)
|
|
785
|
+
return [{ id: 'main', label: `${resourceKind}: ${resourceName}`, spans: healthResult.spans, events: mainResourceEvents, createdAt: healthResult.createdAt, createdBeforeWindow: healthResult.createdBeforeWindow }]
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
const rootLane = resourceLanes[0]
|
|
789
|
+
const lanes: SwimLane[] = []
|
|
790
|
+
|
|
791
|
+
const rootHealthResult = buildHealthSpans(rootLane.events.filter(e => isChangeEvent(e)), startTime, now, rootLane.events)
|
|
792
|
+
lanes.push({
|
|
793
|
+
id: rootLane.id,
|
|
794
|
+
label: `${rootLane.kind}: ${rootLane.name.length > 40 ? rootLane.name.slice(0, 20) + '...' + rootLane.name.slice(-17) : rootLane.name}`,
|
|
795
|
+
spans: rootHealthResult.spans, events: rootLane.events,
|
|
796
|
+
createdAt: rootHealthResult.createdAt, createdBeforeWindow: rootHealthResult.createdBeforeWindow,
|
|
797
|
+
})
|
|
798
|
+
|
|
799
|
+
const flattenChildren = (lane: ResourceLane): ResourceLane[] => {
|
|
800
|
+
const children = lane.children || []
|
|
801
|
+
return children.flatMap(child => [child, ...flattenChildren(child)])
|
|
802
|
+
}
|
|
803
|
+
const allChildren = flattenChildren(rootLane)
|
|
804
|
+
|
|
805
|
+
const kindPriority: Record<string, number> = {
|
|
806
|
+
Service: 1, Deployment: 2, Rollout: 2, StatefulSet: 2, DaemonSet: 2,
|
|
807
|
+
ReplicaSet: 3, ConfigMap: 4, Secret: 4, Gateway: 5, HTTPRoute: 4,
|
|
808
|
+
GRPCRoute: 4, TCPRoute: 4, TLSRoute: 4, Ingress: 5, Pod: 6,
|
|
809
|
+
}
|
|
810
|
+
allChildren.sort((a, b) => {
|
|
811
|
+
const aPriority = kindPriority[a.kind] || 10
|
|
812
|
+
const bPriority = kindPriority[b.kind] || 10
|
|
813
|
+
if (aPriority !== bPriority) return aPriority - bPriority
|
|
814
|
+
return b.events.length - a.events.length
|
|
815
|
+
})
|
|
816
|
+
|
|
817
|
+
for (const child of allChildren.slice(0, 6)) {
|
|
818
|
+
const childHealthResult = buildHealthSpans(child.events.filter(e => isChangeEvent(e)), startTime, now, child.events)
|
|
819
|
+
lanes.push({
|
|
820
|
+
id: child.id,
|
|
821
|
+
label: `${child.kind}: ${child.name.length > 40 ? child.name.slice(0, 20) + '...' + child.name.slice(-17) : child.name}`,
|
|
822
|
+
spans: childHealthResult.spans, events: child.events,
|
|
823
|
+
createdAt: childHealthResult.createdAt, createdBeforeWindow: childHealthResult.createdBeforeWindow,
|
|
824
|
+
})
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
return lanes
|
|
828
|
+
}, [resourceLanes, events, resourceKind, resourceName, startTime, now])
|
|
829
|
+
|
|
830
|
+
// Time axis ticks
|
|
831
|
+
const tickCount = 8
|
|
832
|
+
const ticks = Array.from({ length: tickCount + 1 }, (_, i) => {
|
|
833
|
+
const t = startTime + (windowMs * i) / tickCount
|
|
834
|
+
return { time: t, label: formatAxisTime(new Date(t)) }
|
|
835
|
+
})
|
|
836
|
+
|
|
837
|
+
const formatTimeRangeDisplay = () => {
|
|
838
|
+
const start = new Date(startTime)
|
|
839
|
+
const end = new Date(now)
|
|
840
|
+
return `${start.toLocaleString([], { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })} → ${end.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}`
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
const nowX = localTimeToX(now)
|
|
844
|
+
|
|
845
|
+
if (isLoading) {
|
|
846
|
+
return (
|
|
847
|
+
<div className="flex items-center justify-center h-full text-theme-text-tertiary">
|
|
848
|
+
<RefreshCw className="w-5 h-5 animate-spin mr-2" />
|
|
849
|
+
Loading events...
|
|
850
|
+
</div>
|
|
851
|
+
)
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
return (
|
|
855
|
+
<div className="h-full flex flex-col overflow-hidden">
|
|
856
|
+
{/* Timeline toolbar */}
|
|
857
|
+
<div className="shrink-0 px-4 py-2 border-b border-theme-border bg-theme-surface/50 flex items-center justify-between">
|
|
858
|
+
<span className="text-sm font-medium text-theme-text-secondary">Events ({events.length})</span>
|
|
859
|
+
<div className="flex items-center gap-3">
|
|
860
|
+
<ZoomControls zoom={zoom} onZoomIn={handleZoomIn} onZoomOut={handleZoomOut} canZoomIn={canZoomIn} canZoomOut={canZoomOut} />
|
|
861
|
+
<span className="text-xs text-theme-text-tertiary">{formatTimeRangeDisplay()}</span>
|
|
862
|
+
</div>
|
|
863
|
+
</div>
|
|
864
|
+
|
|
865
|
+
{/* Legend */}
|
|
866
|
+
<div className="shrink-0 px-4 py-1.5 border-b border-theme-border bg-theme-surface/30 flex items-center justify-between">
|
|
867
|
+
<HealthSpanLegend />
|
|
868
|
+
<EventDotLegend />
|
|
869
|
+
</div>
|
|
870
|
+
|
|
871
|
+
{/* Swimlane Timeline */}
|
|
872
|
+
<div className="shrink-0 border-b border-theme-border bg-theme-base relative">
|
|
873
|
+
{/* Scrollable swimlane area — max 4 lanes visible before scrolling */}
|
|
874
|
+
<div className="max-h-[140px] overflow-y-auto relative">
|
|
875
|
+
{nowX >= 0 && nowX <= 100 && (
|
|
876
|
+
<div className="absolute top-0 bottom-0 w-0.5 bg-purple-500/50 z-20 pointer-events-none" style={{ left: `calc(280px + (100% - 280px) * ${nowX / 100})` }}>
|
|
877
|
+
<span className="absolute -top-4 left-1/2 -translate-x-1/2 text-xs text-purple-500 font-medium whitespace-nowrap">now</span>
|
|
878
|
+
</div>
|
|
879
|
+
)}
|
|
880
|
+
|
|
881
|
+
{swimlanes.map((lane) => (
|
|
882
|
+
<div key={lane.id} className="flex border-b border-theme-border/50 last:border-b-0">
|
|
883
|
+
<div className="w-[280px] shrink-0 px-3 py-1 bg-theme-surface/50 border-r border-theme-border text-xs font-medium text-theme-text-secondary truncate flex items-center">
|
|
884
|
+
{lane.label}
|
|
885
|
+
</div>
|
|
886
|
+
<div className="flex-1 relative h-7 bg-theme-base">
|
|
887
|
+
{visibleTimeRangeFromRows && (
|
|
888
|
+
<div className="absolute top-0 bottom-0 bg-blue-500/10 border-x border-blue-500/30 pointer-events-none" style={{
|
|
889
|
+
left: `${Math.max(0, localTimeToX(visibleTimeRangeFromRows.start))}%`,
|
|
890
|
+
width: `${Math.max(2, Math.min(100, localTimeToX(visibleTimeRangeFromRows.end)) - Math.max(0, localTimeToX(visibleTimeRangeFromRows.start)))}%`,
|
|
891
|
+
}} />
|
|
892
|
+
)}
|
|
893
|
+
{lane.spans.map((span, i) => {
|
|
894
|
+
const left = Math.max(0, localTimeToX(span.start))
|
|
895
|
+
const right = Math.min(100, localTimeToX(span.end))
|
|
896
|
+
const width = right - left
|
|
897
|
+
const showCreatedBefore = i === 0 && lane.createdBeforeWindow && lane.createdAt
|
|
898
|
+
return (
|
|
899
|
+
<HealthSpan
|
|
900
|
+
key={i}
|
|
901
|
+
health={span.health}
|
|
902
|
+
left={left}
|
|
903
|
+
width={width}
|
|
904
|
+
title={`${span.health} (${new Date(span.start).toLocaleTimeString()} - ${new Date(span.end).toLocaleTimeString()})`}
|
|
905
|
+
createdBefore={showCreatedBefore ? new Date(lane.createdAt!) : undefined}
|
|
906
|
+
/>
|
|
907
|
+
)
|
|
908
|
+
})}
|
|
909
|
+
{lane.events.map((evt, i) => {
|
|
910
|
+
const x = localTimeToX(new Date(evt.timestamp).getTime())
|
|
911
|
+
if (x < 0 || x > 100) return null
|
|
912
|
+
return (
|
|
913
|
+
<EventMarker
|
|
914
|
+
key={`${evt.id}-${i}`}
|
|
915
|
+
event={evt}
|
|
916
|
+
x={x}
|
|
917
|
+
selected={selectedEventId === evt.id}
|
|
918
|
+
onClick={() => onSelectEvent(selectedEventId === evt.id ? null : evt.id)}
|
|
919
|
+
small
|
|
920
|
+
/>
|
|
921
|
+
)
|
|
922
|
+
})}
|
|
923
|
+
</div>
|
|
924
|
+
</div>
|
|
925
|
+
))}
|
|
926
|
+
</div>
|
|
927
|
+
|
|
928
|
+
{/* Time axis */}
|
|
929
|
+
<div className="flex">
|
|
930
|
+
<div className="w-[280px] shrink-0 bg-theme-surface/50 border-r border-theme-border" />
|
|
931
|
+
<div className="flex-1 relative h-5 bg-theme-elevated/30">
|
|
932
|
+
{ticks.map((tick, i) => {
|
|
933
|
+
const x = localTimeToX(tick.time)
|
|
934
|
+
return (
|
|
935
|
+
<div key={i} className="absolute top-0 flex flex-col items-center" style={{ left: `${x}%`, transform: 'translateX(-50%)' }}>
|
|
936
|
+
<div className="h-1.5 w-px bg-theme-border" />
|
|
937
|
+
<span className="text-[10px] text-theme-text-tertiary">{tick.label}</span>
|
|
938
|
+
</div>
|
|
939
|
+
)
|
|
940
|
+
})}
|
|
941
|
+
</div>
|
|
942
|
+
</div>
|
|
943
|
+
</div>
|
|
944
|
+
|
|
945
|
+
{/* Events table */}
|
|
946
|
+
<div ref={tableContainerRef} className="flex-1 overflow-auto">
|
|
947
|
+
<table className="w-full text-sm">
|
|
948
|
+
<thead className="sticky top-0 bg-theme-surface border-b border-theme-border z-10">
|
|
949
|
+
<tr className="text-left text-xs text-theme-text-tertiary">
|
|
950
|
+
<th className="px-4 py-2 font-medium w-32">Event Type</th>
|
|
951
|
+
<th className="px-4 py-2 font-medium">Summary</th>
|
|
952
|
+
<th className="px-4 py-2 font-medium w-40">Time</th>
|
|
953
|
+
<th className="px-4 py-2 font-medium w-32">Resource</th>
|
|
954
|
+
<th className="px-4 py-2 font-medium w-24">Status</th>
|
|
955
|
+
</tr>
|
|
956
|
+
</thead>
|
|
957
|
+
<tbody className="table-divide-subtle">
|
|
958
|
+
{events.length === 0 ? (
|
|
959
|
+
<tr>
|
|
960
|
+
<td colSpan={5} className="px-4 py-8 text-center text-theme-text-tertiary">
|
|
961
|
+
No events in this time range
|
|
962
|
+
</td>
|
|
963
|
+
</tr>
|
|
964
|
+
) : (
|
|
965
|
+
events.map((evt, evtIdx) => {
|
|
966
|
+
const isSelected = selectedEventId === evt.id
|
|
967
|
+
const isHovered = hoveredEventId === evt.id
|
|
968
|
+
const isWarning = isProblematicEvent(evt)
|
|
969
|
+
return (
|
|
970
|
+
<tr
|
|
971
|
+
key={`${evt.id}-${evtIdx}`}
|
|
972
|
+
ref={(el) => { if (el) rowRefs.current.set(evtIdx, el); else rowRefs.current.delete(evtIdx) }}
|
|
973
|
+
data-row-index={evtIdx}
|
|
974
|
+
onClick={() => onSelectEvent(isSelected ? null : evt.id)}
|
|
975
|
+
onMouseEnter={() => setHoveredEventId(evt.id)}
|
|
976
|
+
onMouseLeave={() => setHoveredEventId(null)}
|
|
977
|
+
className={clsx(
|
|
978
|
+
'cursor-pointer transition-colors',
|
|
979
|
+
isSelected ? 'bg-blue-500/10' : isHovered ? 'bg-blue-500/5' : 'hover:bg-theme-surface/50'
|
|
980
|
+
)}
|
|
981
|
+
>
|
|
982
|
+
<td className="px-4 py-3">
|
|
983
|
+
<div className="flex items-center gap-2">
|
|
984
|
+
<EventDot event={evt} />
|
|
985
|
+
<span className={clsx('font-medium', isWarning ? 'text-amber-500' : 'text-theme-text-primary')}>
|
|
986
|
+
{isHistoricalEvent(evt) && evt.reason ? evt.reason : isChangeEvent(evt) ? evt.eventType : evt.reason}
|
|
987
|
+
</span>
|
|
988
|
+
</div>
|
|
989
|
+
</td>
|
|
990
|
+
<td className="px-4 py-3 text-theme-text-secondary">
|
|
991
|
+
{evt.message || evt.diff?.summary || '-'}
|
|
992
|
+
</td>
|
|
993
|
+
<td className="px-4 py-3 text-theme-text-tertiary">
|
|
994
|
+
{new Date(evt.timestamp).toLocaleString([], { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit', second: '2-digit' })}
|
|
995
|
+
</td>
|
|
996
|
+
<td className="px-4 py-3">
|
|
997
|
+
<span className={clsx('text-xs px-1.5 py-0.5 rounded', getKindBadgeColor(evt.kind))}>
|
|
998
|
+
{evt.kind}
|
|
999
|
+
</span>
|
|
1000
|
+
</td>
|
|
1001
|
+
<td className="px-4 py-3">
|
|
1002
|
+
{isWarning ? (
|
|
1003
|
+
<span className="text-xs px-2 py-0.5 rounded bg-amber-500/20 text-amber-500">Active</span>
|
|
1004
|
+
) : evt.healthState ? (
|
|
1005
|
+
<span className={clsx('text-xs px-2 py-0.5 rounded', getHealthBadgeColor(evt.healthState))}>{evt.healthState}</span>
|
|
1006
|
+
) : null}
|
|
1007
|
+
</td>
|
|
1008
|
+
</tr>
|
|
1009
|
+
)
|
|
1010
|
+
})
|
|
1011
|
+
)}
|
|
1012
|
+
</tbody>
|
|
1013
|
+
</table>
|
|
1014
|
+
</div>
|
|
1015
|
+
</div>
|
|
1016
|
+
)
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
function EventDot({ event }: { event: TimelineEvent }) {
|
|
1020
|
+
const isWarning = isProblematicEvent(event)
|
|
1021
|
+
const isDelete = event.eventType === 'delete'
|
|
1022
|
+
const isAdd = event.eventType === 'add'
|
|
1023
|
+
return (
|
|
1024
|
+
<div className={clsx(
|
|
1025
|
+
'w-3 h-3 rounded-full shrink-0',
|
|
1026
|
+
isWarning ? 'bg-amber-500' : isDelete ? 'bg-red-500' : isAdd ? 'bg-green-500' : 'bg-blue-500'
|
|
1027
|
+
)} />
|
|
1028
|
+
)
|
|
1029
|
+
}
|
|
1030
|
+
|
|
1031
|
+
// ============================================================================
|
|
1032
|
+
// INFO TAB — uses ResourceRendererDispatch for kind-specific rendering
|
|
1033
|
+
// ============================================================================
|
|
1034
|
+
|
|
1035
|
+
function InfoTab({
|
|
1036
|
+
resource,
|
|
1037
|
+
selectedResource,
|
|
1038
|
+
relationships,
|
|
1039
|
+
isLoading,
|
|
1040
|
+
onNavigate,
|
|
1041
|
+
onCopy,
|
|
1042
|
+
copied,
|
|
1043
|
+
onSaveSecretValue,
|
|
1044
|
+
isSavingSecret,
|
|
1045
|
+
onOpenLogs,
|
|
1046
|
+
onSwitchToTimeline,
|
|
1047
|
+
}: {
|
|
1048
|
+
resource: any
|
|
1049
|
+
selectedResource: SelectedResource
|
|
1050
|
+
relationships?: Relationships
|
|
1051
|
+
isLoading: boolean
|
|
1052
|
+
onNavigate?: NavigateToResource
|
|
1053
|
+
onCopy: (text: string, key: string) => void
|
|
1054
|
+
copied: string | null
|
|
1055
|
+
onSaveSecretValue?: (yaml: string) => Promise<void>
|
|
1056
|
+
isSavingSecret?: boolean
|
|
1057
|
+
onOpenLogs?: (podName: string, containerName: string) => void
|
|
1058
|
+
onSwitchToTimeline?: () => void
|
|
1059
|
+
}) {
|
|
1060
|
+
if (isLoading) {
|
|
1061
|
+
return (
|
|
1062
|
+
<div className="flex items-center justify-center h-full text-theme-text-tertiary">
|
|
1063
|
+
<RefreshCw className="w-5 h-5 animate-spin mr-2" />
|
|
1064
|
+
Loading...
|
|
1065
|
+
</div>
|
|
1066
|
+
)
|
|
1067
|
+
}
|
|
1068
|
+
|
|
1069
|
+
if (!resource) {
|
|
1070
|
+
return (
|
|
1071
|
+
<div className="flex items-center justify-center h-full text-theme-text-tertiary">
|
|
1072
|
+
Resource not found
|
|
1073
|
+
</div>
|
|
1074
|
+
)
|
|
1075
|
+
}
|
|
1076
|
+
|
|
1077
|
+
return (
|
|
1078
|
+
<div className="h-full overflow-auto">
|
|
1079
|
+
<ResourceRendererDispatch
|
|
1080
|
+
resource={selectedResource}
|
|
1081
|
+
data={resource}
|
|
1082
|
+
relationships={relationships}
|
|
1083
|
+
onCopy={onCopy}
|
|
1084
|
+
copied={copied}
|
|
1085
|
+
onNavigate={onNavigate ? (ref) => onNavigate(refToSelectedResource(ref)) : undefined}
|
|
1086
|
+
onSaveSecretValue={onSaveSecretValue}
|
|
1087
|
+
isSavingSecret={isSavingSecret}
|
|
1088
|
+
showCommonSections={true}
|
|
1089
|
+
showMetrics={false}
|
|
1090
|
+
onOpenLogs={onOpenLogs}
|
|
1091
|
+
eventsHint={onSwitchToTimeline && (
|
|
1092
|
+
<button
|
|
1093
|
+
onClick={onSwitchToTimeline}
|
|
1094
|
+
className="text-xs text-theme-text-tertiary hover:text-theme-text-secondary transition-colors"
|
|
1095
|
+
>
|
|
1096
|
+
These are events for this resource only. Switch to the <span className="underline">Timeline</span> tab to see events across all related resources.
|
|
1097
|
+
</button>
|
|
1098
|
+
)}
|
|
1099
|
+
renderSidebar={(sidebarSections) => (
|
|
1100
|
+
<div className="lg:w-[35%] lg:shrink-0 lg:border-l border-theme-border">
|
|
1101
|
+
<div className="p-4 space-y-4">
|
|
1102
|
+
{sidebarSections}
|
|
1103
|
+
</div>
|
|
1104
|
+
</div>
|
|
1105
|
+
)}
|
|
1106
|
+
/>
|
|
1107
|
+
</div>
|
|
1108
|
+
)
|
|
1109
|
+
}
|