@skyhook-io/k8s-ui 1.8.9 → 1.8.11
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 +5 -4
- package/src/assets/gitops/argocd.png +0 -0
- package/src/assets/gitops/flux.svg +7 -0
- package/src/components/applications/AppChips.tsx +10 -0
- package/src/components/applications/ApplicationDetail.test.tsx +569 -0
- package/src/components/applications/ApplicationDetail.tsx +2517 -450
- package/src/components/applications/ApplicationsList.tsx +4 -1
- package/src/components/applications/ApplicationsView.tsx +101 -18
- package/src/components/applications/applications-view.test.tsx +47 -0
- package/src/components/applications/index.ts +1 -1
- package/src/components/audit/AuditAlerts.tsx +14 -10
- package/src/components/checks/ChecksView.tsx +81 -51
- package/src/components/checks/severity.ts +39 -45
- package/src/components/cluster-switcher/ClusterSwitcher.tsx +2 -2
- package/src/components/compare/CompareResourcePicker.tsx +2 -2
- package/src/components/gitops/GitOpsGraphFilterRail.tsx +2 -1
- package/src/components/gitops/GitOpsTableView.tsx +14 -59
- package/src/components/gitops/ManagedResourcesList.tsx +8 -11
- package/src/components/gitops/SyncCountdown.tsx +26 -17
- package/src/components/gitops/SyncOptionsDialog.tsx +2 -2
- package/src/components/gitops/insights/GitOpsInsightViews.tsx +277 -104
- package/src/components/gitops/insights/insights-helpers.test.ts +86 -1
- package/src/components/gitops/insights/insights-helpers.ts +69 -1
- package/src/components/gitops/tree/GitOpsTreeGraph.tsx +5 -2
- package/src/components/issues/IssuesView.tsx +219 -143
- package/src/components/issues/ResourceIssuesSection.test.tsx +40 -0
- package/src/components/issues/ResourceIssuesSection.tsx +28 -128
- package/src/components/issues/index.ts +3 -2
- package/src/components/issues/issue-timing.ts +53 -0
- package/src/components/issues/issues.test.ts +146 -4
- package/src/components/issues/severity.ts +29 -32
- package/src/components/logs/LogCore.tsx +14 -4
- package/src/components/logs/WorkloadLogsViewer.tsx +31 -6
- package/src/components/logs/useLogStream.ts +8 -1
- package/src/components/namespace-switcher/NamespacePicker.tsx +86 -107
- package/src/components/resources/ResourcesSidebar.test.tsx +107 -0
- package/src/components/resources/ResourcesSidebar.tsx +70 -52
- package/src/components/resources/ResourcesView.selected-kind.test.ts +143 -0
- package/src/components/resources/ResourcesView.tsx +243 -53
- package/src/components/resources/renderers/ClusterComplianceReportRenderer.tsx +2 -2
- package/src/components/resources/renderers/CronWorkflowRenderer.tsx +99 -0
- package/src/components/resources/renderers/JobRenderer.tsx +3 -1
- package/src/components/resources/renderers/MetricsUnavailableNotice.tsx +47 -0
- package/src/components/resources/renderers/NodeRenderer.test.tsx +103 -0
- package/src/components/resources/renderers/NodeRenderer.tsx +20 -12
- package/src/components/resources/renderers/PVCRenderer.test.tsx +23 -0
- package/src/components/resources/renderers/PVCRenderer.tsx +2 -2
- package/src/components/resources/renderers/PodRenderer.test.tsx +101 -0
- package/src/components/resources/renderers/PodRenderer.tsx +81 -70
- package/src/components/resources/renderers/PrometheusRuleRenderer.tsx +2 -2
- package/src/components/resources/renderers/SbomReportRenderer.tsx +2 -2
- package/src/components/resources/renderers/SecretRenderer.tsx +2 -0
- package/src/components/resources/renderers/ServiceRenderer.tsx +58 -40
- package/src/components/resources/renderers/VulnerabilityReportRenderer.tsx +2 -2
- package/src/components/resources/renderers/WorkflowRenderer.test.tsx +43 -0
- package/src/components/resources/renderers/WorkflowRenderer.tsx +60 -78
- package/src/components/resources/renderers/index.ts +1 -0
- package/src/components/resources/resource-utils-capi.ts +1 -1
- package/src/components/resources/resource-utils.ts +31 -0
- package/src/components/shared/DetailShell.tsx +2 -2
- package/src/components/shared/ResourceActionsBar.tsx +140 -108
- package/src/components/shared/ResourceRendererDispatch.tsx +33 -4
- package/src/components/timeline/TimelineList.tsx +371 -354
- package/src/components/timeline/TimelineStrip.test.tsx +194 -0
- package/src/components/timeline/TimelineStrip.tsx +836 -0
- package/src/components/timeline/TimelineSwimlanes.test.tsx +1028 -0
- package/src/components/timeline/TimelineSwimlanes.tsx +2498 -593
- package/src/components/timeline/TimelineToolbar.test.tsx +320 -0
- package/src/components/timeline/TimelineToolbar.tsx +759 -0
- package/src/components/timeline/TimelineToolbarState.test.tsx +64 -0
- package/src/components/timeline/index.ts +23 -0
- package/src/components/timeline/scrubber-math.test.ts +235 -0
- package/src/components/timeline/scrubber-math.ts +249 -0
- package/src/components/timeline/shared.test.tsx +219 -0
- package/src/components/timeline/shared.tsx +232 -447
- package/src/components/timeline/timeline-filters.test.ts +196 -0
- package/src/components/timeline/timeline-filters.ts +180 -0
- package/src/components/timeline/timeline-lane-sort.test.ts +97 -0
- package/src/components/timeline/timeline-lane-sort.ts +77 -0
- package/src/components/timeline/timeline-live.test.ts +131 -0
- package/src/components/timeline/timeline-live.ts +77 -0
- package/src/components/topology/GroupNode.tsx +9 -2
- package/src/components/topology/K8sResourceNode.test.ts +11 -0
- package/src/components/topology/K8sResourceNode.tsx +387 -243
- package/src/components/topology/TopologyFilterSidebar.tsx +9 -0
- package/src/components/topology/TopologyGraph.tsx +89 -16
- package/src/components/topology/TopologySearch.tsx +2 -2
- package/src/components/topology/layout.ts +10 -2
- package/src/components/topology/topology.css +0 -92
- package/src/components/ui/Badge.tsx +5 -0
- package/src/components/ui/BoardSkeleton.tsx +47 -0
- package/src/components/ui/CardSection.tsx +117 -0
- package/src/components/ui/CodeViewer.tsx +2 -2
- package/src/components/ui/Collapse.tsx +59 -0
- package/src/components/ui/ConfirmDialog.tsx +7 -3
- package/src/components/ui/DialogPortal.tsx +31 -9
- package/src/components/ui/Input.tsx +24 -0
- package/src/components/ui/MultiSelectPicker.test.tsx +101 -0
- package/src/components/ui/MultiSelectPicker.tsx +167 -0
- package/src/components/ui/PaneLoader.tsx +10 -2
- package/src/components/ui/ResourceBar.tsx +68 -25
- package/src/components/ui/SearchBox.tsx +18 -4
- package/src/components/ui/SearchPillInput.tsx +2 -2
- package/src/components/ui/Toast.tsx +1 -1
- package/src/components/ui/drawer-components.tsx +39 -26
- package/src/components/ui/index.ts +6 -0
- package/src/components/ui/severity-tone.ts +63 -0
- package/src/components/workload/ResourceDetailDrawer.tsx +6 -2
- package/src/components/workload/WorkloadView.tsx +2682 -472
- package/src/components/workload/index.ts +1 -1
- package/src/theme/variables.css +15 -1
- package/src/types/core.ts +57 -4
- package/src/utils/api-resources.test.ts +54 -0
- package/src/utils/api-resources.ts +68 -1
- package/src/utils/application-topology.test.ts +198 -0
- package/src/utils/application-topology.ts +261 -0
- package/src/utils/applications.test.ts +167 -1
- package/src/utils/applications.ts +1157 -393
- package/src/utils/index.ts +2 -0
- package/src/utils/navigation.test.ts +32 -1
- package/src/utils/navigation.ts +70 -0
- package/src/utils/resource-hierarchy.test.ts +990 -1
- package/src/utils/resource-hierarchy.ts +905 -267
- package/src/utils/resource-icons.ts +2 -0
- package/src/utils/topology-neighborhood.test.ts +202 -1
- package/src/utils/topology-neighborhood.ts +194 -13
- package/src/utils/workflow-execution.test.ts +556 -0
- package/src/utils/workflow-execution.ts +814 -0
- package/src/utils/workload-colors.ts +2 -3
- package/tsconfig.json +2 -2
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { useState, useMemo, useEffect, useRef, useCallback, type ReactNode } from 'react'
|
|
1
|
+
import { useState, useMemo, useEffect, useRef, useCallback, type ComponentType, type ReactNode } from 'react'
|
|
2
2
|
import { flushSync } from 'react-dom'
|
|
3
3
|
import { useRefreshAnimation } from '../../hooks/useRefreshAnimation'
|
|
4
4
|
import { startViewTransitionSafe } from '../../utils/view-transition'
|
|
@@ -11,18 +11,26 @@ import {
|
|
|
11
11
|
ArrowRight,
|
|
12
12
|
RefreshCw,
|
|
13
13
|
Activity,
|
|
14
|
+
AlertTriangle,
|
|
14
15
|
Terminal,
|
|
15
16
|
Layers,
|
|
16
17
|
FileText,
|
|
17
18
|
Copy,
|
|
18
19
|
Check,
|
|
20
|
+
CheckCircle2,
|
|
21
|
+
Clock3,
|
|
22
|
+
Boxes,
|
|
23
|
+
ExternalLink,
|
|
24
|
+
GitBranch,
|
|
19
25
|
Minimize2,
|
|
20
26
|
Maximize2,
|
|
27
|
+
Scale,
|
|
28
|
+
Server,
|
|
21
29
|
X,
|
|
22
30
|
BarChart3,
|
|
23
31
|
Network,
|
|
24
32
|
} from 'lucide-react'
|
|
25
|
-
import type { TimelineEvent, ResourceRef, Relationships, SelectedResource, ResolvedEnvFrom, Topology, TopologyNode, HPADiagnosis } from '../../types'
|
|
33
|
+
import type { TimelineEvent, ResourceRef, Relationships, SelectedResource, ResolvedEnvFrom, Topology, TopologyNode, HPADiagnosis, WorkloadPodInfo } from '../../types'
|
|
26
34
|
import type { GitOpsStatus } from '../../types/gitops'
|
|
27
35
|
import type { NavigateToResource } from '../../utils/navigation'
|
|
28
36
|
import { refToSelectedResource, pluralToKind, kindToPlural, apiVersionToGroup } from '../../utils/navigation'
|
|
@@ -30,34 +38,50 @@ import { neighborhoodFor, seedNodeIds } from '../../utils/topology-neighborhood'
|
|
|
30
38
|
import { TopologyGraph } from '../topology/TopologyGraph'
|
|
31
39
|
import { gitOpsOwnerFromRelationships, type GitOpsOwnerRef } from '../../utils/gitops-owner'
|
|
32
40
|
import { gitOpsRouteForResource } from '../../utils/gitops-route'
|
|
33
|
-
import { isChangeEvent, isHistoricalEvent } from '../../types'
|
|
34
|
-
import { getKindBadgeColor, getHealthBadgeColor } from '../../utils/badge-colors'
|
|
35
41
|
import { buildResourceHierarchy, getAllEventsFromHierarchy, isProblematicEvent, type ResourceLane } from '../../utils/resource-hierarchy'
|
|
36
|
-
import {
|
|
37
|
-
|
|
38
|
-
type ZoomLevel,
|
|
39
|
-
formatAxisTime,
|
|
40
|
-
EventMarker,
|
|
41
|
-
EventDotLegend,
|
|
42
|
-
HealthSpanLegend,
|
|
43
|
-
HealthSpan,
|
|
44
|
-
ZoomControls,
|
|
45
|
-
buildHealthSpans,
|
|
46
|
-
timeToX,
|
|
47
|
-
calculateTimeRange,
|
|
48
|
-
} from '../timeline/shared'
|
|
42
|
+
import { TimelineSwimlanes, type TimeWindow } from '../timeline/TimelineSwimlanes'
|
|
43
|
+
import { TimelineList } from '../timeline/TimelineList'
|
|
49
44
|
import { ResourceActionsBar } from '../shared/ResourceActionsBar'
|
|
50
45
|
import { EditableYamlView, SaveSuccessAnimation } from '../shared/EditableYamlView'
|
|
51
|
-
import { ResourceRendererDispatch, getResourceStatus, type RendererOverrides } from '../shared/ResourceRendererDispatch'
|
|
46
|
+
import { ResourceRendererDispatch, getResourceStatus, diagnoseHealthHint, type DiagnoseHealthHint, type RendererOverrides } from '../shared/ResourceRendererDispatch'
|
|
52
47
|
import type { ScalerDiagnosis } from '../resources/renderers/WorkloadRenderer'
|
|
53
48
|
import { DetailShell, type DetailShellTab } from '../shared/DetailShell'
|
|
54
49
|
import { HelmManagedByChip, ManagedByChip, type HelmOwnerRef } from '../shared/ManagedByChip'
|
|
55
|
-
import { getKindColorOutline, displayKindName, OperationalIssuesShownContext } from '../ui/drawer-components'
|
|
50
|
+
import { getKindColorOutline, displayKindName, OperationalIssuesShownContext, ResourceRefBadge } from '../ui/drawer-components'
|
|
51
|
+
import { Badge, type BadgeSeverity } from '../ui/Badge'
|
|
52
|
+
import { Tooltip } from '../ui/Tooltip'
|
|
56
53
|
import { midTruncate } from '../../utils/format'
|
|
54
|
+
import { hpaStateLabel } from '../resources/resource-utils-hpa'
|
|
55
|
+
import {
|
|
56
|
+
cronToHuman,
|
|
57
|
+
formatAge,
|
|
58
|
+
formatDuration,
|
|
59
|
+
getGatewayAddresses,
|
|
60
|
+
getGatewayListeners,
|
|
61
|
+
getIngressAddress,
|
|
62
|
+
getIngressHosts,
|
|
63
|
+
getServiceExternalIP,
|
|
64
|
+
} from '../resources/resource-utils'
|
|
65
|
+
import { ServicePortCards, type ServicePortRenderProps } from '../resources/renderers/ServiceRenderer'
|
|
57
66
|
|
|
58
67
|
export type WorkloadTabType = 'overview' | 'topology' | 'timeline' | 'logs' | 'metrics' | 'yaml'
|
|
59
68
|
type TabType = WorkloadTabType
|
|
60
69
|
|
|
70
|
+
export interface ResourceOwnershipContext {
|
|
71
|
+
application?: {
|
|
72
|
+
key: string
|
|
73
|
+
name: string
|
|
74
|
+
}
|
|
75
|
+
workload: ResourceRef
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export interface ServingResourceDetail {
|
|
79
|
+
ref: ResourceRef
|
|
80
|
+
resource?: any
|
|
81
|
+
loading?: boolean
|
|
82
|
+
error?: Error | null
|
|
83
|
+
}
|
|
84
|
+
|
|
61
85
|
// ============================================================================
|
|
62
86
|
// MAIN WORKLOAD VIEW — presentation only, data injected via props
|
|
63
87
|
// ============================================================================
|
|
@@ -105,6 +129,10 @@ interface WorkloadViewProps {
|
|
|
105
129
|
scopeControls?: ReactNode
|
|
106
130
|
/** Hide WorkloadView's own breadcrumb/identity header when a host page owns that chrome. */
|
|
107
131
|
compactHeader?: boolean
|
|
132
|
+
/** High-confidence containment context for resources whose controller ownership is reliable, currently Pods. */
|
|
133
|
+
ownershipContext?: ResourceOwnershipContext
|
|
134
|
+
/** Open the application wrapper for ownership breadcrumbs. */
|
|
135
|
+
onOpenApplication?: (appKey: string) => void
|
|
108
136
|
|
|
109
137
|
// ── Data (injected by wrapper) ──────────────────────────────────────────
|
|
110
138
|
/** The resource data object */
|
|
@@ -117,6 +145,17 @@ interface WorkloadViewProps {
|
|
|
117
145
|
hpaDiagnosis?: HPADiagnosis
|
|
118
146
|
/** Compact diagnosis for autoscalers controlling this workload */
|
|
119
147
|
scalerDiagnostics?: ScalerDiagnosis[]
|
|
148
|
+
/** Pod readiness data from the host's workload-pods endpoint. Optional so
|
|
149
|
+
* shared consumers can still render from relationships when they do not wire
|
|
150
|
+
* Radar-specific data hooks. */
|
|
151
|
+
workloadPods?: WorkloadPodInfo[]
|
|
152
|
+
workloadPodsLoading?: boolean
|
|
153
|
+
workloadPodsError?: Error | null
|
|
154
|
+
/** Full objects for service/route refs related to this workload. Optional;
|
|
155
|
+
* overview falls back to relationship refs when hosts do not fetch them. */
|
|
156
|
+
servingResources?: ServingResourceDetail[]
|
|
157
|
+
renderServicePortAction?: (props: ServicePortRenderProps) => ReactNode
|
|
158
|
+
renderServicePortPanel?: (props: ServicePortRenderProps) => ReactNode
|
|
120
159
|
/** Whether the resource is loading */
|
|
121
160
|
isLoading?: boolean
|
|
122
161
|
/** Fetch error for the resource (preserves status + message so the
|
|
@@ -128,6 +167,8 @@ interface WorkloadViewProps {
|
|
|
128
167
|
// ── Timeline data ────────────────────────────────────────────────────────
|
|
129
168
|
/** All timeline events for this resource's namespace */
|
|
130
169
|
allEvents?: TimelineEvent[]
|
|
170
|
+
/** Persisted lifecycle events reconstructed for resources related to this workload. */
|
|
171
|
+
relatedTimelineEvents?: TimelineEvent[]
|
|
131
172
|
/** Whether timeline events are loading */
|
|
132
173
|
eventsLoading?: boolean
|
|
133
174
|
/** Topology data for hierarchy building + the Topology tab's neighborhood. */
|
|
@@ -156,7 +197,7 @@ interface WorkloadViewProps {
|
|
|
156
197
|
/** Controlled active tab. If not provided, managed internally. */
|
|
157
198
|
activeTab?: TabType
|
|
158
199
|
/** Called when tab changes (for URL sync etc.) */
|
|
159
|
-
onTabChange?: (tab: TabType) => void
|
|
200
|
+
onTabChange?: (tab: TabType, opts?: { replace?: boolean }) => void
|
|
160
201
|
|
|
161
202
|
// ── GitOps navigation ─────────────────────────────────────────────────────
|
|
162
203
|
/**
|
|
@@ -206,6 +247,14 @@ interface WorkloadViewProps {
|
|
|
206
247
|
initialContainer: string | null
|
|
207
248
|
onConsumeInitialContainer: () => void
|
|
208
249
|
}) => ReactNode
|
|
250
|
+
/** Render a full replacement for the expanded Overview tab. */
|
|
251
|
+
renderExpandedOverview?: (props: {
|
|
252
|
+
kind: string
|
|
253
|
+
apiKind: string
|
|
254
|
+
namespace: string
|
|
255
|
+
name: string
|
|
256
|
+
resource: any
|
|
257
|
+
}) => ReactNode
|
|
209
258
|
/** Render the metrics tab content */
|
|
210
259
|
renderMetricsTab?: (props: { kind: string; namespace: string; name: string }) => ReactNode
|
|
211
260
|
/** Render a read-only YAML view for a related object from the workload's
|
|
@@ -218,6 +267,8 @@ interface WorkloadViewProps {
|
|
|
218
267
|
isMetricsAvailable?: (kind: string, resource: any) => boolean
|
|
219
268
|
/** Render extra content at the bottom of the overview tab (e.g. audit findings) */
|
|
220
269
|
renderOverviewExtra?: (props: { kind: string; namespace: string; name: string }) => ReactNode
|
|
270
|
+
/** Render lightweight overview intro content before the default renderer. */
|
|
271
|
+
renderOverviewIntro?: (props: { kind: string; namespace: string; name: string }) => ReactNode
|
|
221
272
|
/** Render content at the TOP of the overview tab, above the renderer (e.g. live
|
|
222
273
|
* Operational Issues). Optional + additive — consumers that don't pass it are
|
|
223
274
|
* unaffected. Only rendered when `hasOperationalIssues` is true: the lead
|
|
@@ -266,17 +317,26 @@ export function WorkloadView({
|
|
|
266
317
|
hideBackButton,
|
|
267
318
|
scopeControls,
|
|
268
319
|
compactHeader,
|
|
320
|
+
ownershipContext,
|
|
321
|
+
onOpenApplication,
|
|
269
322
|
// Data
|
|
270
323
|
resource,
|
|
271
324
|
relationships,
|
|
272
325
|
certificateInfo,
|
|
273
326
|
hpaDiagnosis,
|
|
274
327
|
scalerDiagnostics,
|
|
328
|
+
workloadPods,
|
|
329
|
+
workloadPodsLoading = false,
|
|
330
|
+
workloadPodsError = null,
|
|
331
|
+
servingResources,
|
|
332
|
+
renderServicePortAction,
|
|
333
|
+
renderServicePortPanel,
|
|
275
334
|
isLoading: resourceLoading = false,
|
|
276
335
|
resourceError,
|
|
277
336
|
refetch: refetchProp,
|
|
278
337
|
// Timeline
|
|
279
338
|
allEvents,
|
|
339
|
+
relatedTimelineEvents = [],
|
|
280
340
|
eventsLoading = false,
|
|
281
341
|
topology,
|
|
282
342
|
resourceFocusedK8sEvents,
|
|
@@ -296,6 +356,7 @@ export function WorkloadView({
|
|
|
296
356
|
onTabChange,
|
|
297
357
|
// Render props
|
|
298
358
|
renderLogsTab,
|
|
359
|
+
renderExpandedOverview,
|
|
299
360
|
renderRelatedYaml,
|
|
300
361
|
renderMetricsTab,
|
|
301
362
|
isMetricsAvailable,
|
|
@@ -303,6 +364,7 @@ export function WorkloadView({
|
|
|
303
364
|
onDuplicate,
|
|
304
365
|
onDownload,
|
|
305
366
|
renderOverviewExtra,
|
|
367
|
+
renderOverviewIntro,
|
|
306
368
|
renderOverviewLead,
|
|
307
369
|
hasOperationalIssues,
|
|
308
370
|
// Actions bar
|
|
@@ -349,7 +411,6 @@ export function WorkloadView({
|
|
|
349
411
|
}, [])
|
|
350
412
|
|
|
351
413
|
const [selectedEventId, setSelectedEventId] = useState<string | null>(null)
|
|
352
|
-
const [zoom, setZoom] = useState<ZoomLevel>(1)
|
|
353
414
|
const [selectedPod, setSelectedPod] = useState<string | null>(null)
|
|
354
415
|
const [initialContainer, setInitialContainer] = useState<string | null>(null)
|
|
355
416
|
const [copied, setCopied] = useState<string | null>(null)
|
|
@@ -386,7 +447,6 @@ export function WorkloadView({
|
|
|
386
447
|
// A deep-linked ?tab=topology that turns out unavailable falls back to
|
|
387
448
|
// overview instead of rendering an empty body under a hidden tab.
|
|
388
449
|
const topologyTabHidden = !!topology && (!neighborhood || neighborhood.nodes.length === 0)
|
|
389
|
-
const effectiveTab: TabType = activeTab === 'topology' && topologyTabHidden ? 'overview' : activeTab
|
|
390
450
|
|
|
391
451
|
// YAML tab object rail — the same neighborhood, as a manifest list: the
|
|
392
452
|
// workload first, then routing → config → policy/scaling → ownership.
|
|
@@ -433,8 +493,10 @@ export function WorkloadView({
|
|
|
433
493
|
|
|
434
494
|
// Flatten events from hierarchy
|
|
435
495
|
const resourceEvents = useMemo(() => {
|
|
436
|
-
|
|
437
|
-
|
|
496
|
+
const events = [...getAllEventsFromHierarchy(resourceLanes), ...relatedTimelineEvents]
|
|
497
|
+
const unique = new Map(events.map((event) => [event.id, event]))
|
|
498
|
+
return Array.from(unique.values()).sort((a, b) => Date.parse(a.timestamp) - Date.parse(b.timestamp) || a.id.localeCompare(b.id))
|
|
499
|
+
}, [resourceLanes, relatedTimelineEvents])
|
|
438
500
|
const overviewEvents = resourceEvents.length > 0 ? resourceEvents : (resourceFocusedK8sEvents ?? [])
|
|
439
501
|
const overviewEventsLoading = resourceEvents.length > 0 ? eventsLoading : resourceFocusedEventsLoading
|
|
440
502
|
const overviewEventsError = resourceEvents.length > 0 ? undefined : resourceFocusedK8sError
|
|
@@ -462,6 +524,7 @@ export function WorkloadView({
|
|
|
462
524
|
const combined = [
|
|
463
525
|
...pods,
|
|
464
526
|
...childPods.map(p => ({ kind: 'Pod' as const, namespace: p.namespace, name: p.name })),
|
|
527
|
+
...(workloadPods ?? []).map(p => ({ kind: 'Pod' as const, namespace, name: p.name })),
|
|
465
528
|
]
|
|
466
529
|
const seen = new Set<string>()
|
|
467
530
|
return combined.filter(p => {
|
|
@@ -470,7 +533,7 @@ export function WorkloadView({
|
|
|
470
533
|
seen.add(key)
|
|
471
534
|
return true
|
|
472
535
|
})
|
|
473
|
-
}, [pods, childPods])
|
|
536
|
+
}, [pods, childPods, workloadPods, namespace])
|
|
474
537
|
|
|
475
538
|
// Metadata
|
|
476
539
|
const metadata = useMemo(() => extractMetadata(kind, resource), [kind, resource])
|
|
@@ -562,8 +625,30 @@ export function WorkloadView({
|
|
|
562
625
|
], [active, expanded, onBack, onClose, switchView]))
|
|
563
626
|
|
|
564
627
|
const status = getResourceStatus(apiKind, resource)
|
|
628
|
+
const showOwnershipHeading = kind === 'Pod' && Boolean(ownershipContext)
|
|
629
|
+
const headerImage = metadata.find(m => m.label === 'Image')?.value
|
|
630
|
+
|
|
631
|
+
// The AI/Diagnose action lives in the header chrome (next to expand/refresh/close),
|
|
632
|
+
// set apart from the imperative ops in the action bar — it's an invitation to
|
|
633
|
+
// understand the resource, not another verb to run on it. Adaptive by health:
|
|
634
|
+
// prominent "Diagnose" on a problem, a quiet icon when fine. Rendered via the host
|
|
635
|
+
// slot (DiagnoseCustomization); standalone Radar injects it, Hub overrides it.
|
|
636
|
+
const renderDiagnose = actionsBarProps?.renderDiagnose as
|
|
637
|
+
| ((ctx: { kind: string; namespace: string; name: string; health?: DiagnoseHealthHint }) => ReactNode)
|
|
638
|
+
| undefined
|
|
639
|
+
const diagnoseAction = renderDiagnose?.({
|
|
640
|
+
kind: apiKind,
|
|
641
|
+
namespace,
|
|
642
|
+
name,
|
|
643
|
+
health: diagnoseHealthHint(apiKind, resource),
|
|
644
|
+
})
|
|
565
645
|
|
|
566
646
|
const showMetricsTab = isMetricsAvailable ? isMetricsAvailable(kind, resource) : false
|
|
647
|
+
const logsTabVisible = Boolean(renderLogsTab) && (allPods.length > 0 || LOGS_TAB_WITHOUT_PODS_KINDS.has(kindToPlural(kind).toLowerCase()))
|
|
648
|
+
const metricsTabVisible = Boolean(showMetricsTab && renderMetricsTab)
|
|
649
|
+
const podEvidenceLoading = resourceLoading || workloadPodsLoading || eventsLoading
|
|
650
|
+
const logsFallbackReady = !renderLogsTab || (!logsTabVisible && !podEvidenceLoading)
|
|
651
|
+
const requestedTab: TabType = activeTab
|
|
567
652
|
const tabs: DetailShellTab<TabType>[] = [
|
|
568
653
|
{ id: 'overview', label: 'Overview', icon: <Layers className="w-4 h-4" /> },
|
|
569
654
|
{ id: 'topology', label: 'Topology', icon: <Network className="w-4 h-4" />, hidden: topologyTabHidden },
|
|
@@ -573,10 +658,25 @@ export function WorkloadView({
|
|
|
573
658
|
icon: <Activity className="w-4 h-4" />,
|
|
574
659
|
badge: resourceEvents.length > 0 ? <span className="ml-1 badge-sm bg-theme-elevated">{resourceEvents.length}</span> : undefined,
|
|
575
660
|
},
|
|
576
|
-
{ id: 'logs', label: 'Logs', icon: <Terminal className="w-4 h-4" />, hidden: !
|
|
577
|
-
{ id: 'metrics', label: 'Metrics', icon: <BarChart3 className="w-4 h-4" />, hidden: !
|
|
661
|
+
{ id: 'logs', label: 'Logs', icon: <Terminal className="w-4 h-4" />, hidden: !logsTabVisible },
|
|
662
|
+
{ id: 'metrics', label: 'Metrics', icon: <BarChart3 className="w-4 h-4" />, hidden: !metricsTabVisible },
|
|
578
663
|
{ id: 'yaml', label: 'YAML', icon: <FileText className="w-4 h-4" /> },
|
|
579
664
|
]
|
|
665
|
+
const requestedTabAvailable = tabs.some((tab) => tab.id === requestedTab && !tab.hidden)
|
|
666
|
+
const effectiveTab: TabType = requestedTabAvailable ? requestedTab : 'overview'
|
|
667
|
+
const shouldCommitFallback =
|
|
668
|
+
requestedTab !== 'overview' &&
|
|
669
|
+
!requestedTabAvailable &&
|
|
670
|
+
(
|
|
671
|
+
(requestedTab === 'topology' && topologyTabHidden) ||
|
|
672
|
+
(requestedTab === 'metrics' && (!renderMetricsTab || (!!resource && !resourceLoading && !showMetricsTab))) ||
|
|
673
|
+
(requestedTab === 'logs' && logsFallbackReady)
|
|
674
|
+
)
|
|
675
|
+
useEffect(() => {
|
|
676
|
+
if (shouldCommitFallback && activeTab !== 'overview') onTabChange?.('overview', { replace: true })
|
|
677
|
+
}, [activeTab, onTabChange, shouldCommitFallback])
|
|
678
|
+
const expandedOverview = expanded ? renderExpandedOverview?.({ kind, apiKind, namespace, name, resource }) : null
|
|
679
|
+
const overviewIntro = renderOverviewIntro?.({ kind, namespace, name })
|
|
580
680
|
|
|
581
681
|
// ── Collapsed (drawer) mode ──────────────────────────────────────────────
|
|
582
682
|
if (!expanded) {
|
|
@@ -596,39 +696,46 @@ export function WorkloadView({
|
|
|
596
696
|
</span>
|
|
597
697
|
)}
|
|
598
698
|
</div>
|
|
599
|
-
<div className="flex items-center gap-1">
|
|
699
|
+
<div className="flex items-center gap-1.5">
|
|
700
|
+
{diagnoseAction}
|
|
600
701
|
{onExpand && (
|
|
702
|
+
<Tooltip content="Open full view" delay={150} position="bottom">
|
|
703
|
+
<button
|
|
704
|
+
onClick={() => onExpand({ yaml: showYaml })}
|
|
705
|
+
// Pre-mount the fullscreen view on hover/press so the click starts
|
|
706
|
+
// the morph instantly (its heavy mount is already paid for).
|
|
707
|
+
onPointerEnter={onExpandIntent}
|
|
708
|
+
onPointerDown={onExpandIntent}
|
|
709
|
+
onPointerLeave={onCancelExpandIntent}
|
|
710
|
+
className="p-1.5 text-theme-text-secondary hover:text-theme-text-primary hover:bg-theme-elevated rounded"
|
|
711
|
+
aria-label="Open full view"
|
|
712
|
+
>
|
|
713
|
+
<Maximize2 className="w-4 h-4" />
|
|
714
|
+
</button>
|
|
715
|
+
</Tooltip>
|
|
716
|
+
)}
|
|
717
|
+
<Tooltip content="Refresh" delay={150} position="bottom">
|
|
601
718
|
<button
|
|
602
|
-
onClick={() =>
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
title="Open full view"
|
|
719
|
+
onClick={() => refetch()}
|
|
720
|
+
disabled={isRefreshAnimating}
|
|
721
|
+
className={clsx(
|
|
722
|
+
'p-1.5 hover:bg-theme-elevated rounded disabled:opacity-50 transition-colors duration-500',
|
|
723
|
+
refreshPhase === 'success' ? 'text-emerald-400' : 'text-theme-text-secondary hover:text-theme-text-primary'
|
|
724
|
+
)}
|
|
725
|
+
aria-label="Refresh"
|
|
610
726
|
>
|
|
611
|
-
|
|
727
|
+
{refreshPhase === 'success'
|
|
728
|
+
? <Check className="w-4 h-4 stroke-[2.5]" />
|
|
729
|
+
: <RefreshCw className={clsx('w-4 h-4', refreshPhase === 'spinning' && 'animate-spin')} />
|
|
730
|
+
}
|
|
612
731
|
</button>
|
|
613
|
-
|
|
614
|
-
<button
|
|
615
|
-
onClick={() => refetch()}
|
|
616
|
-
disabled={isRefreshAnimating}
|
|
617
|
-
className={clsx(
|
|
618
|
-
'p-1.5 hover:bg-theme-elevated rounded disabled:opacity-50 transition-colors duration-500',
|
|
619
|
-
refreshPhase === 'success' ? 'text-emerald-400' : 'text-theme-text-secondary hover:text-theme-text-primary'
|
|
620
|
-
)}
|
|
621
|
-
title="Refresh"
|
|
622
|
-
>
|
|
623
|
-
{refreshPhase === 'success'
|
|
624
|
-
? <Check className="w-4 h-4 stroke-[2.5]" />
|
|
625
|
-
: <RefreshCw className={clsx('w-4 h-4', refreshPhase === 'spinning' && 'animate-spin')} />
|
|
626
|
-
}
|
|
627
|
-
</button>
|
|
732
|
+
</Tooltip>
|
|
628
733
|
{onClose && (
|
|
629
|
-
<
|
|
630
|
-
<
|
|
631
|
-
|
|
734
|
+
<Tooltip content="Close (Esc)" delay={150} position="bottom">
|
|
735
|
+
<button onClick={onClose} className="p-1.5 text-theme-text-secondary hover:text-theme-text-primary hover:bg-theme-elevated rounded" aria-label="Close">
|
|
736
|
+
<X className="w-4 h-4" />
|
|
737
|
+
</button>
|
|
738
|
+
</Tooltip>
|
|
632
739
|
)}
|
|
633
740
|
</div>
|
|
634
741
|
</div>
|
|
@@ -637,13 +744,15 @@ export function WorkloadView({
|
|
|
637
744
|
<div className="px-4 pb-3">
|
|
638
745
|
<div className="flex items-center gap-2">
|
|
639
746
|
<h2 className="text-lg font-semibold text-theme-text-primary truncate">{name}</h2>
|
|
640
|
-
<
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
747
|
+
<Tooltip content="Copy name" delay={150}>
|
|
748
|
+
<button
|
|
749
|
+
onClick={() => copyToClipboard(name, 'name')}
|
|
750
|
+
className="p-1 text-theme-text-secondary hover:text-theme-text-primary hover:bg-theme-elevated rounded shrink-0"
|
|
751
|
+
aria-label="Copy name"
|
|
752
|
+
>
|
|
753
|
+
{copied === 'name' ? <Check className="w-3.5 h-3.5 text-green-400" /> : <Copy className="w-3.5 h-3.5" />}
|
|
754
|
+
</button>
|
|
755
|
+
</Tooltip>
|
|
647
756
|
</div>
|
|
648
757
|
<p className="text-sm text-theme-text-tertiary">{namespace}</p>
|
|
649
758
|
{(gitopsOwner || helmOwner || (gitOpsResourcePath && onNavigateGitOpsPath)) && (
|
|
@@ -711,12 +820,8 @@ export function WorkloadView({
|
|
|
711
820
|
updates={resourceFocusedUpdates}
|
|
712
821
|
eventsError={resourceFocusedK8sError}
|
|
713
822
|
updatesError={resourceFocusedUpdatesError}
|
|
823
|
+
mainFooter={renderOverviewExtra && renderOverviewExtra({ kind, namespace, name })}
|
|
714
824
|
/>
|
|
715
|
-
{renderOverviewExtra && (
|
|
716
|
-
<div className="px-4 pb-4">
|
|
717
|
-
{renderOverviewExtra({ kind, namespace, name })}
|
|
718
|
-
</div>
|
|
719
|
-
)}
|
|
720
825
|
</OperationalIssuesShownContext.Provider>
|
|
721
826
|
)}
|
|
722
827
|
</div>
|
|
@@ -731,27 +836,42 @@ export function WorkloadView({
|
|
|
731
836
|
breadcrumb={breadcrumb}
|
|
732
837
|
nav={
|
|
733
838
|
breadcrumb || hideBackButton ? undefined : (
|
|
734
|
-
<
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
839
|
+
<Tooltip content="Go back (Esc)" delay={150} position="bottom">
|
|
840
|
+
<button
|
|
841
|
+
onClick={onBack}
|
|
842
|
+
className="p-1.5 mt-0.5 text-theme-text-secondary hover:text-theme-text-primary hover:bg-theme-elevated rounded-lg transition-colors"
|
|
843
|
+
aria-label="Go back"
|
|
844
|
+
>
|
|
845
|
+
<ArrowLeft className="w-5 h-5" />
|
|
846
|
+
</button>
|
|
847
|
+
</Tooltip>
|
|
741
848
|
)
|
|
742
849
|
}
|
|
743
850
|
identity={
|
|
744
851
|
<>
|
|
745
|
-
|
|
746
|
-
<
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
{
|
|
753
|
-
|
|
754
|
-
|
|
852
|
+
{showOwnershipHeading && ownershipContext ? (
|
|
853
|
+
<OwnershipHeading
|
|
854
|
+
podName={name}
|
|
855
|
+
context={ownershipContext}
|
|
856
|
+
copied={copied === 'name'}
|
|
857
|
+
onCopy={() => copyToClipboard(name, 'name')}
|
|
858
|
+
onNavigateToResource={onNavigateToResource}
|
|
859
|
+
onOpenApplication={onOpenApplication}
|
|
860
|
+
/>
|
|
861
|
+
) : (
|
|
862
|
+
<div className="flex items-center gap-3 mb-1">
|
|
863
|
+
<h1 className="text-lg font-semibold text-theme-text-primary truncate">{name}</h1>
|
|
864
|
+
<Tooltip content="Copy name" delay={150}>
|
|
865
|
+
<button
|
|
866
|
+
onClick={() => copyToClipboard(name, 'name')}
|
|
867
|
+
className="p-1 text-theme-text-secondary hover:text-theme-text-primary hover:bg-theme-elevated rounded shrink-0"
|
|
868
|
+
aria-label="Copy name"
|
|
869
|
+
>
|
|
870
|
+
{copied === 'name' ? <Check className="w-3.5 h-3.5 text-green-400" /> : <Copy className="w-3.5 h-3.5" />}
|
|
871
|
+
</button>
|
|
872
|
+
</Tooltip>
|
|
873
|
+
</div>
|
|
874
|
+
)}
|
|
755
875
|
<div className="flex items-center gap-3 text-sm text-theme-text-secondary">
|
|
756
876
|
<span className={clsx('badge', getKindColorOutline(apiKind))}>
|
|
757
877
|
{displayKindName(apiKind, resource?.kind)}
|
|
@@ -764,8 +884,12 @@ export function WorkloadView({
|
|
|
764
884
|
{namespace && namespace !== '_' && (
|
|
765
885
|
<span>Namespace: <span className="text-theme-text-primary">{namespace}</span></span>
|
|
766
886
|
)}
|
|
767
|
-
{
|
|
768
|
-
<
|
|
887
|
+
{headerImage && (
|
|
888
|
+
<Tooltip content={headerImage} delay={300} wrapperClassName="min-w-0 max-w-md">
|
|
889
|
+
<span className="truncate font-mono text-xs">
|
|
890
|
+
{midTruncate(headerImage, 72)}
|
|
891
|
+
</span>
|
|
892
|
+
</Tooltip>
|
|
769
893
|
)}
|
|
770
894
|
{gitopsOwner && (
|
|
771
895
|
<ManagedByChip owner={gitopsOwner} status={gitOpsOwnerStatus} verified={gitOpsOwnerVerified} pending={gitOpsOwnerPending} source={gitOpsOwnerSource} onOpen={onOpenGitOpsResource} variant="block" />
|
|
@@ -776,7 +900,7 @@ export function WorkloadView({
|
|
|
776
900
|
{gitOpsResourcePath && onNavigateGitOpsPath && (
|
|
777
901
|
<OpenInGitOpsChip onClick={() => onNavigateGitOpsPath(gitOpsResourcePath)} />
|
|
778
902
|
)}
|
|
779
|
-
{relationships?.owner && (
|
|
903
|
+
{relationships?.owner && !showOwnershipHeading && (
|
|
780
904
|
<span>Owner: <button onClick={() => onNavigateToResource?.(refToSelectedResource(relationships.owner!))} className="text-blue-500 hover:underline">{relationships.owner.name}</button></span>
|
|
781
905
|
)}
|
|
782
906
|
</div>
|
|
@@ -784,28 +908,33 @@ export function WorkloadView({
|
|
|
784
908
|
}
|
|
785
909
|
headerActions={
|
|
786
910
|
<>
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
disabled={isRefreshAnimating}
|
|
790
|
-
className={clsx(
|
|
791
|
-
'p-1.5 mt-0.5 hover:bg-theme-elevated rounded disabled:opacity-50 transition-colors duration-500',
|
|
792
|
-
refreshPhase === 'success' ? 'text-emerald-400' : 'text-theme-text-secondary hover:text-theme-text-primary'
|
|
793
|
-
)}
|
|
794
|
-
title="Refresh"
|
|
795
|
-
>
|
|
796
|
-
{refreshPhase === 'success'
|
|
797
|
-
? <Check className="w-5 h-5 stroke-[2.5]" />
|
|
798
|
-
: <RefreshCw className={clsx('w-5 h-5', refreshPhase === 'spinning' && 'animate-spin')} />
|
|
799
|
-
}
|
|
800
|
-
</button>
|
|
801
|
-
{onCollapseToDrawer && (
|
|
911
|
+
{diagnoseAction}
|
|
912
|
+
<Tooltip content="Refresh" delay={150} position="bottom">
|
|
802
913
|
<button
|
|
803
|
-
onClick={
|
|
804
|
-
|
|
805
|
-
|
|
914
|
+
onClick={() => refetch()}
|
|
915
|
+
disabled={isRefreshAnimating}
|
|
916
|
+
className={clsx(
|
|
917
|
+
'p-1.5 mt-0.5 hover:bg-theme-elevated rounded disabled:opacity-50 transition-colors duration-500',
|
|
918
|
+
refreshPhase === 'success' ? 'text-emerald-400' : 'text-theme-text-secondary hover:text-theme-text-primary'
|
|
919
|
+
)}
|
|
920
|
+
aria-label="Refresh"
|
|
806
921
|
>
|
|
807
|
-
|
|
922
|
+
{refreshPhase === 'success'
|
|
923
|
+
? <Check className="w-5 h-5 stroke-[2.5]" />
|
|
924
|
+
: <RefreshCw className={clsx('w-5 h-5', refreshPhase === 'spinning' && 'animate-spin')} />
|
|
925
|
+
}
|
|
808
926
|
</button>
|
|
927
|
+
</Tooltip>
|
|
928
|
+
{onCollapseToDrawer && (
|
|
929
|
+
<Tooltip content="Collapse to drawer" delay={150} position="bottom">
|
|
930
|
+
<button
|
|
931
|
+
onClick={onCollapseToDrawer}
|
|
932
|
+
className="p-1.5 mt-0.5 text-theme-text-secondary hover:text-theme-text-primary hover:bg-theme-elevated rounded-lg transition-colors"
|
|
933
|
+
aria-label="Collapse to drawer"
|
|
934
|
+
>
|
|
935
|
+
<Minimize2 className="w-5 h-5" />
|
|
936
|
+
</button>
|
|
937
|
+
</Tooltip>
|
|
809
938
|
)}
|
|
810
939
|
</>
|
|
811
940
|
}
|
|
@@ -817,13 +946,29 @@ export function WorkloadView({
|
|
|
817
946
|
overlay={saveSuccess ? <SaveSuccessAnimation /> : null}
|
|
818
947
|
compactHeader={compactHeader}
|
|
819
948
|
>
|
|
820
|
-
{effectiveTab === 'overview' && (
|
|
949
|
+
{effectiveTab === 'overview' && expandedOverview ? (
|
|
950
|
+
<div className="h-full min-h-0">
|
|
951
|
+
{hasOperationalIssues && renderOverviewLead && (
|
|
952
|
+
<div className="px-4 pt-4">
|
|
953
|
+
{renderOverviewLead({ kind, namespace, name })}
|
|
954
|
+
</div>
|
|
955
|
+
)}
|
|
956
|
+
{expandedOverview}
|
|
957
|
+
</div>
|
|
958
|
+
) : effectiveTab === 'overview' && (
|
|
821
959
|
<InfoTab
|
|
822
960
|
resource={resource}
|
|
823
961
|
selectedResource={selectedResource}
|
|
824
962
|
relationships={relationships}
|
|
963
|
+
certificateInfo={certificateInfo}
|
|
825
964
|
hpaDiagnosis={hpaDiagnosis}
|
|
826
965
|
scalerDiagnostics={scalerDiagnostics}
|
|
966
|
+
workloadPods={workloadPods}
|
|
967
|
+
workloadPodsLoading={workloadPodsLoading}
|
|
968
|
+
workloadPodsError={workloadPodsError}
|
|
969
|
+
servingResources={servingResources}
|
|
970
|
+
renderServicePortAction={renderServicePortAction}
|
|
971
|
+
renderServicePortPanel={renderServicePortPanel}
|
|
827
972
|
isLoading={resourceLoading}
|
|
828
973
|
error={resourceError}
|
|
829
974
|
onNavigate={onNavigateToResource}
|
|
@@ -833,6 +978,8 @@ export function WorkloadView({
|
|
|
833
978
|
isSavingSecret={isUpdatingResource}
|
|
834
979
|
onOpenLogs={handleOpenLogs}
|
|
835
980
|
onSwitchToTimeline={() => handleSetTab('timeline')}
|
|
981
|
+
onSwitchToLogs={logsTabVisible ? () => handleSetTab('logs') : undefined}
|
|
982
|
+
onSwitchToTopology={!topologyTabHidden ? () => handleSetTab('topology') : undefined}
|
|
836
983
|
rendererOverrides={rendererOverrides}
|
|
837
984
|
resolvedEnvFrom={resolvedEnvFrom}
|
|
838
985
|
events={overviewEvents}
|
|
@@ -841,6 +988,7 @@ export function WorkloadView({
|
|
|
841
988
|
eventsError={overviewEventsError}
|
|
842
989
|
updatesError={resourceFocusedUpdatesError}
|
|
843
990
|
extraContent={renderOverviewExtra && renderOverviewExtra({ kind, namespace, name })}
|
|
991
|
+
introContent={overviewIntro}
|
|
844
992
|
leadContent={hasOperationalIssues && renderOverviewLead ? renderOverviewLead({ kind, namespace, name }) : undefined}
|
|
845
993
|
/>
|
|
846
994
|
)}
|
|
@@ -864,14 +1012,11 @@ export function WorkloadView({
|
|
|
864
1012
|
{effectiveTab === 'timeline' && (
|
|
865
1013
|
<EventsTab
|
|
866
1014
|
events={resourceEvents}
|
|
867
|
-
resourceLanes={resourceLanes}
|
|
868
1015
|
isLoading={eventsLoading}
|
|
869
|
-
zoom={zoom}
|
|
870
|
-
onZoomChange={setZoom}
|
|
871
|
-
resourceKind={kind}
|
|
872
|
-
resourceName={name}
|
|
873
1016
|
selectedEventId={selectedEventId}
|
|
874
1017
|
onSelectEvent={setSelectedEventId}
|
|
1018
|
+
topology={topology}
|
|
1019
|
+
onResourceClick={onNavigateToResource}
|
|
875
1020
|
/>
|
|
876
1021
|
)}
|
|
877
1022
|
{effectiveTab === 'logs' && renderLogsTab && (
|
|
@@ -988,15 +1133,90 @@ function extractMetadata(kind: string, resource: any): { label: string; value: s
|
|
|
988
1133
|
|
|
989
1134
|
function OpenInGitOpsChip({ onClick }: { onClick: () => void }) {
|
|
990
1135
|
return (
|
|
991
|
-
<
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1136
|
+
<Tooltip content="Open this resource in the GitOps tab (tree + insights + ops)" delay={150}>
|
|
1137
|
+
<button
|
|
1138
|
+
type="button"
|
|
1139
|
+
onClick={onClick}
|
|
1140
|
+
className="inline-flex items-center gap-1 rounded border border-skyhook-500/40 bg-skyhook-500/10 px-1.5 py-0.5 text-[11px] font-medium text-skyhook-500 hover:bg-skyhook-500/20 transition-colors"
|
|
1141
|
+
>
|
|
1142
|
+
Open in GitOps
|
|
1143
|
+
<ArrowRight className="h-3 w-3 shrink-0" />
|
|
1144
|
+
</button>
|
|
1145
|
+
</Tooltip>
|
|
1146
|
+
)
|
|
1147
|
+
}
|
|
1148
|
+
|
|
1149
|
+
function OwnershipHeading({
|
|
1150
|
+
podName,
|
|
1151
|
+
context,
|
|
1152
|
+
copied,
|
|
1153
|
+
onCopy,
|
|
1154
|
+
onNavigateToResource,
|
|
1155
|
+
onOpenApplication,
|
|
1156
|
+
}: {
|
|
1157
|
+
podName: string
|
|
1158
|
+
context: ResourceOwnershipContext
|
|
1159
|
+
copied: boolean
|
|
1160
|
+
onCopy: () => void
|
|
1161
|
+
onNavigateToResource?: NavigateToResource
|
|
1162
|
+
onOpenApplication?: (appKey: string) => void
|
|
1163
|
+
}) {
|
|
1164
|
+
const app = context.application
|
|
1165
|
+
const workload = context.workload
|
|
1166
|
+
const workloadLabel = `${displayKindName(kindToPlural(workload.kind), workload.kind)}/${workload.name}`
|
|
1167
|
+
return (
|
|
1168
|
+
<div className="mb-1 flex min-w-0 items-center gap-2">
|
|
1169
|
+
<div className="flex min-w-0 items-center gap-1.5 text-lg font-semibold text-theme-text-primary">
|
|
1170
|
+
{app ? (
|
|
1171
|
+
onOpenApplication ? (
|
|
1172
|
+
<Tooltip content={`Open application ${app.name}`} delay={150} wrapperClassName="min-w-0">
|
|
1173
|
+
<button
|
|
1174
|
+
type="button"
|
|
1175
|
+
onClick={() => onOpenApplication(app.key)}
|
|
1176
|
+
className="min-w-0 truncate rounded px-0.5 text-left hover:text-accent-text focus:outline-none focus:ring-2 focus:ring-accent"
|
|
1177
|
+
>
|
|
1178
|
+
{midTruncate(app.name, 32)}
|
|
1179
|
+
</button>
|
|
1180
|
+
</Tooltip>
|
|
1181
|
+
) : (
|
|
1182
|
+
<Tooltip content={app.name} delay={300} wrapperClassName="min-w-0">
|
|
1183
|
+
<span className="truncate">{midTruncate(app.name, 32)}</span>
|
|
1184
|
+
</Tooltip>
|
|
1185
|
+
)
|
|
1186
|
+
) : null}
|
|
1187
|
+
{app && <span className="shrink-0 text-theme-text-tertiary">/</span>}
|
|
1188
|
+
{onNavigateToResource ? (
|
|
1189
|
+
<Tooltip content={`Open workload ${workloadLabel}`} delay={150} wrapperClassName="min-w-0">
|
|
1190
|
+
<button
|
|
1191
|
+
type="button"
|
|
1192
|
+
onClick={() => onNavigateToResource(refToSelectedResource(workload))}
|
|
1193
|
+
className="min-w-0 truncate rounded px-0.5 text-left hover:text-accent-text focus:outline-none focus:ring-2 focus:ring-accent"
|
|
1194
|
+
>
|
|
1195
|
+
{midTruncate(workload.name, 32)}
|
|
1196
|
+
</button>
|
|
1197
|
+
</Tooltip>
|
|
1198
|
+
) : (
|
|
1199
|
+
<Tooltip content={workloadLabel} delay={300} wrapperClassName="min-w-0">
|
|
1200
|
+
<span className="truncate">{midTruncate(workload.name, 32)}</span>
|
|
1201
|
+
</Tooltip>
|
|
1202
|
+
)}
|
|
1203
|
+
<span className="shrink-0 text-theme-text-tertiary">/</span>
|
|
1204
|
+
<Tooltip content={podName} delay={300} wrapperClassName="min-w-0">
|
|
1205
|
+
<h1 className="truncate text-lg font-semibold text-theme-text-primary">
|
|
1206
|
+
{podName}
|
|
1207
|
+
</h1>
|
|
1208
|
+
</Tooltip>
|
|
1209
|
+
</div>
|
|
1210
|
+
<Tooltip content="Copy pod name" delay={150}>
|
|
1211
|
+
<button
|
|
1212
|
+
onClick={onCopy}
|
|
1213
|
+
className="shrink-0 rounded p-1 text-theme-text-secondary hover:bg-theme-elevated hover:text-theme-text-primary"
|
|
1214
|
+
aria-label="Copy pod name"
|
|
1215
|
+
>
|
|
1216
|
+
{copied ? <Check className="h-3.5 w-3.5 text-green-400" /> : <Copy className="h-3.5 w-3.5" />}
|
|
1217
|
+
</button>
|
|
1218
|
+
</Tooltip>
|
|
1219
|
+
</div>
|
|
1000
1220
|
)
|
|
1001
1221
|
}
|
|
1002
1222
|
|
|
@@ -1006,162 +1226,98 @@ function OpenInGitOpsChip({ onClick }: { onClick: () => void }) {
|
|
|
1006
1226
|
|
|
1007
1227
|
function EventsTab({
|
|
1008
1228
|
events,
|
|
1009
|
-
resourceLanes,
|
|
1010
1229
|
isLoading,
|
|
1011
|
-
zoom,
|
|
1012
|
-
onZoomChange,
|
|
1013
|
-
resourceKind,
|
|
1014
|
-
resourceName,
|
|
1015
1230
|
selectedEventId,
|
|
1016
1231
|
onSelectEvent,
|
|
1232
|
+
topology,
|
|
1233
|
+
onResourceClick,
|
|
1017
1234
|
}: {
|
|
1018
1235
|
events: TimelineEvent[]
|
|
1019
|
-
resourceLanes: ResourceLane[]
|
|
1020
1236
|
isLoading: boolean
|
|
1021
|
-
zoom: ZoomLevel
|
|
1022
|
-
onZoomChange: (zoom: ZoomLevel) => void
|
|
1023
|
-
resourceKind: string
|
|
1024
|
-
resourceName: string
|
|
1025
1237
|
selectedEventId: string | null
|
|
1026
1238
|
onSelectEvent: (id: string | null) => void
|
|
1239
|
+
topology?: Topology
|
|
1240
|
+
onResourceClick?: NavigateToResource
|
|
1027
1241
|
}) {
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
// Scroll to selected event
|
|
1242
|
+
// A ticking clock so the fitted window's right edge and the swimlane's Now line
|
|
1243
|
+
// track the present, not the mount time — the tab's events refresh (SSE + poll),
|
|
1244
|
+
// so a frozen "now" drifts left of the advancing edge and newer events land "in
|
|
1245
|
+
// the future" to its right.
|
|
1246
|
+
const [now, setNow] = useState(() => Date.now())
|
|
1034
1247
|
useEffect(() => {
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
const row = rowRefs.current.get(eventIndex)
|
|
1039
|
-
if (row) row.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
|
1040
|
-
}
|
|
1041
|
-
}
|
|
1042
|
-
}, [selectedEventId, events])
|
|
1043
|
-
|
|
1044
|
-
// Track visible rows via IntersectionObserver
|
|
1045
|
-
useEffect(() => {
|
|
1046
|
-
if (!tableContainerRef.current || events.length === 0) return
|
|
1047
|
-
const visibleIndices = new Set<number>()
|
|
1048
|
-
const observer = new IntersectionObserver(
|
|
1049
|
-
(entries) => {
|
|
1050
|
-
for (const entry of entries) {
|
|
1051
|
-
const idx = parseInt(entry.target.getAttribute('data-row-index') || '-1', 10)
|
|
1052
|
-
if (idx >= 0) {
|
|
1053
|
-
if (entry.isIntersecting) visibleIndices.add(idx)
|
|
1054
|
-
else visibleIndices.delete(idx)
|
|
1055
|
-
}
|
|
1056
|
-
}
|
|
1057
|
-
if (visibleIndices.size > 0) {
|
|
1058
|
-
const indices = Array.from(visibleIndices)
|
|
1059
|
-
setVisibleRowRange({ first: Math.min(...indices), last: Math.max(...indices) })
|
|
1060
|
-
} else {
|
|
1061
|
-
setVisibleRowRange(null)
|
|
1062
|
-
}
|
|
1063
|
-
},
|
|
1064
|
-
{ root: tableContainerRef.current, threshold: 0.1 }
|
|
1065
|
-
)
|
|
1066
|
-
const timeoutId = setTimeout(() => {
|
|
1067
|
-
rowRefs.current.forEach((row) => observer.observe(row))
|
|
1068
|
-
}, 100)
|
|
1069
|
-
return () => { clearTimeout(timeoutId); observer.disconnect() }
|
|
1070
|
-
}, [events])
|
|
1071
|
-
|
|
1072
|
-
// Visible time range from visible rows
|
|
1073
|
-
const visibleTimeRangeFromRows = useMemo(() => {
|
|
1074
|
-
if (!visibleRowRange || events.length === 0) return null
|
|
1075
|
-
const visibleEvents = events.slice(visibleRowRange.first, visibleRowRange.last + 1)
|
|
1076
|
-
if (visibleEvents.length === 0) return null
|
|
1077
|
-
const timestamps = visibleEvents.map(e => new Date(e.timestamp).getTime())
|
|
1078
|
-
const start = Math.min(...timestamps)
|
|
1079
|
-
const end = Math.max(...timestamps)
|
|
1080
|
-
const timeSpan = end - start
|
|
1081
|
-
const padding = Math.max(timeSpan * 0.1, 60000)
|
|
1082
|
-
return { start: start - padding, end: end + padding }
|
|
1083
|
-
}, [events, visibleRowRange])
|
|
1084
|
-
|
|
1085
|
-
const now = Date.now()
|
|
1086
|
-
const { start: startTime, windowMs } = calculateTimeRange(zoom, now)
|
|
1087
|
-
const zoomIndex = ZOOM_LEVELS.indexOf(zoom)
|
|
1088
|
-
const canZoomIn = zoomIndex > 0
|
|
1089
|
-
const canZoomOut = zoomIndex < ZOOM_LEVELS.length - 1
|
|
1090
|
-
const handleZoomIn = () => { if (canZoomIn) onZoomChange(ZOOM_LEVELS[zoomIndex - 1]) }
|
|
1091
|
-
const handleZoomOut = () => { if (canZoomOut) onZoomChange(ZOOM_LEVELS[zoomIndex + 1]) }
|
|
1092
|
-
const localTimeToX = (ts: number) => timeToX(ts, startTime, windowMs)
|
|
1093
|
-
|
|
1094
|
-
// Build swimlanes
|
|
1095
|
-
const swimlanes = useMemo(() => {
|
|
1096
|
-
type SwimLane = {
|
|
1097
|
-
id: string; label: string
|
|
1098
|
-
spans: { start: number; end: number; health: string }[]
|
|
1099
|
-
events: TimelineEvent[]
|
|
1100
|
-
createdAt?: number; createdBeforeWindow: boolean
|
|
1101
|
-
}
|
|
1102
|
-
|
|
1103
|
-
if (resourceLanes.length === 0) {
|
|
1104
|
-
const mainResourceEvents = events.filter(e => e.kind === resourceKind && e.name === resourceName)
|
|
1105
|
-
const healthResult = buildHealthSpans(mainResourceEvents.filter(e => isChangeEvent(e)), startTime, now, mainResourceEvents)
|
|
1106
|
-
return [{ id: 'main', label: `${resourceKind}: ${resourceName}`, spans: healthResult.spans, events: mainResourceEvents, createdAt: healthResult.createdAt, createdBeforeWindow: healthResult.createdBeforeWindow }]
|
|
1107
|
-
}
|
|
1248
|
+
const id = setInterval(() => setNow(Date.now()), 30_000)
|
|
1249
|
+
return () => clearInterval(id)
|
|
1250
|
+
}, [])
|
|
1108
1251
|
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1252
|
+
// Drop untimeable events once, at the source, so the swimlane, the list's date
|
|
1253
|
+
// grouping, and selection all agree. The Go/K8s zero time (0001-01-01) parses to
|
|
1254
|
+
// a valid-but-meaningless Date the swimlane can't place, but the list would
|
|
1255
|
+
// otherwise bucket it under a bogus "1/1/1" header (and selecting it strands the
|
|
1256
|
+
// pan). NaN / future timestamps are stripped for the same reason.
|
|
1257
|
+
const cleanEvents = useMemo(() => {
|
|
1258
|
+
const ceiling = Date.now() + 60_000
|
|
1259
|
+
return events.filter((e) => {
|
|
1260
|
+
const t = new Date(e.timestamp).getTime()
|
|
1261
|
+
return Number.isFinite(t) && t > 0 && t <= ceiling
|
|
1118
1262
|
})
|
|
1263
|
+
}, [events])
|
|
1119
1264
|
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1265
|
+
// Fit the swimlane's window to this resource's events. Uncontrolled, the
|
|
1266
|
+
// swimlane anchors to now and shows the last hour — but a resource's events can
|
|
1267
|
+
// be days old (a Deployment created last week), so that window is empty. Default
|
|
1268
|
+
// to the events' span; a user pan/zoom (userWindow) then takes over. Deriving the
|
|
1269
|
+
// default (not latching it in state) means it always tracks the current events —
|
|
1270
|
+
// latching once locked onto the pre-scoping event set and left the window wrong.
|
|
1271
|
+
const eventSpan = useMemo<TimeWindow | null>(() => {
|
|
1272
|
+
if (cleanEvents.length === 0) return null
|
|
1273
|
+
let lo = Infinity, hi = -Infinity
|
|
1274
|
+
for (const e of cleanEvents) {
|
|
1275
|
+
const t = new Date(e.timestamp).getTime()
|
|
1276
|
+
if (t < lo) lo = t
|
|
1277
|
+
if (t > hi) hi = t
|
|
1130
1278
|
}
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1279
|
+
const pad = Math.max((hi - lo) * 0.05, 60_000)
|
|
1280
|
+
// Never extend into the future: the right edge is the newest event or now,
|
|
1281
|
+
// whichever is earlier, so health bars stop at the Now line.
|
|
1282
|
+
return { fromMs: lo - pad, toMs: Math.min(hi + pad, now) }
|
|
1283
|
+
}, [cleanEvents, now])
|
|
1284
|
+
const [userWindow, setUserWindow] = useState<TimeWindow | null>(null)
|
|
1285
|
+
const viewWindow = userWindow ?? eventSpan
|
|
1286
|
+
|
|
1287
|
+
// Hard bounds for pan/zoom: the window can never leave [oldest event, now], so
|
|
1288
|
+
// scrolling back can't run off into empty/invalid-date territory and zoom-out
|
|
1289
|
+
// can't exceed the resource's actual lifespan.
|
|
1290
|
+
const bounds = useMemo<TimeWindow | null>(
|
|
1291
|
+
() => (eventSpan ? { fromMs: eventSpan.fromMs, toMs: now } : null),
|
|
1292
|
+
[eventSpan, now],
|
|
1293
|
+
)
|
|
1137
1294
|
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1295
|
+
// Selecting an event — from the list OR the swimlane — pans the swimlane window
|
|
1296
|
+
// to include it in the SAME update, THEN sets the shared selection. Panning
|
|
1297
|
+
// atomically is what prevents the flicker: the swimlane resolves selectedEventId
|
|
1298
|
+
// against the window on the same render, so an off-window event never briefly
|
|
1299
|
+
// resolves to null and oscillate against a separate pan effect. Refs read the
|
|
1300
|
+
// current window/events without making this callback churn.
|
|
1301
|
+
const selRefs = useRef({ viewWindow, events: cleanEvents, bounds })
|
|
1302
|
+
selRefs.current = { viewWindow, events: cleanEvents, bounds }
|
|
1303
|
+
const handleSelect = useCallback((id: string | null) => {
|
|
1304
|
+
if (id) {
|
|
1305
|
+
const { viewWindow: vw, events: evs, bounds: bd } = selRefs.current
|
|
1306
|
+
const evt = evs.find((e) => e.id === id)
|
|
1307
|
+
const t = evt ? new Date(evt.timestamp).getTime() : NaN
|
|
1308
|
+
if (vw && Number.isFinite(t) && (t < vw.fromMs || t > vw.toMs)) {
|
|
1309
|
+
const width = vw.toMs - vw.fromMs
|
|
1310
|
+
const now = Date.now()
|
|
1311
|
+
let fromMs = t - width / 2
|
|
1312
|
+
let toMs = t + width / 2
|
|
1313
|
+
if (toMs > now) { toMs = now; fromMs = now - width }
|
|
1314
|
+
const lo = bd?.fromMs
|
|
1315
|
+
if (lo != null && fromMs < lo) { fromMs = lo; toMs = lo + width }
|
|
1316
|
+
setUserWindow({ fromMs, toMs })
|
|
1317
|
+
}
|
|
1146
1318
|
}
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
}, [resourceLanes, events, resourceKind, resourceName, startTime, now])
|
|
1150
|
-
|
|
1151
|
-
// Time axis ticks
|
|
1152
|
-
const tickCount = 8
|
|
1153
|
-
const ticks = Array.from({ length: tickCount + 1 }, (_, i) => {
|
|
1154
|
-
const t = startTime + (windowMs * i) / tickCount
|
|
1155
|
-
return { time: t, label: formatAxisTime(new Date(t)) }
|
|
1156
|
-
})
|
|
1157
|
-
|
|
1158
|
-
const formatTimeRangeDisplay = () => {
|
|
1159
|
-
const start = new Date(startTime)
|
|
1160
|
-
const end = new Date(now)
|
|
1161
|
-
return `${start.toLocaleString([], { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })} → ${end.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}`
|
|
1162
|
-
}
|
|
1163
|
-
|
|
1164
|
-
const nowX = localTimeToX(now)
|
|
1319
|
+
onSelectEvent(id)
|
|
1320
|
+
}, [onSelectEvent])
|
|
1165
1321
|
|
|
1166
1322
|
if (isLoading) {
|
|
1167
1323
|
return (
|
|
@@ -1174,191 +1330,80 @@ function EventsTab({
|
|
|
1174
1330
|
|
|
1175
1331
|
return (
|
|
1176
1332
|
<div className="h-full flex flex-col overflow-hidden">
|
|
1177
|
-
{/*
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
{lane.spans.map((span, i) => {
|
|
1215
|
-
const left = Math.max(0, localTimeToX(span.start))
|
|
1216
|
-
const right = Math.min(100, localTimeToX(span.end))
|
|
1217
|
-
const width = right - left
|
|
1218
|
-
const showCreatedBefore = i === 0 && lane.createdBeforeWindow && lane.createdAt
|
|
1219
|
-
return (
|
|
1220
|
-
<HealthSpan
|
|
1221
|
-
key={i}
|
|
1222
|
-
health={span.health}
|
|
1223
|
-
left={left}
|
|
1224
|
-
width={width}
|
|
1225
|
-
title={`${span.health} (${new Date(span.start).toLocaleTimeString()} - ${new Date(span.end).toLocaleTimeString()})`}
|
|
1226
|
-
createdBefore={showCreatedBefore ? new Date(lane.createdAt!) : undefined}
|
|
1227
|
-
/>
|
|
1228
|
-
)
|
|
1229
|
-
})}
|
|
1230
|
-
{lane.events.map((evt, i) => {
|
|
1231
|
-
const x = localTimeToX(new Date(evt.timestamp).getTime())
|
|
1232
|
-
if (x < 0 || x > 100) return null
|
|
1233
|
-
return (
|
|
1234
|
-
<EventMarker
|
|
1235
|
-
key={`${evt.id}-${i}`}
|
|
1236
|
-
event={evt}
|
|
1237
|
-
x={x}
|
|
1238
|
-
selected={selectedEventId === evt.id}
|
|
1239
|
-
onClick={() => onSelectEvent(selectedEventId === evt.id ? null : evt.id)}
|
|
1240
|
-
small
|
|
1241
|
-
/>
|
|
1242
|
-
)
|
|
1243
|
-
})}
|
|
1244
|
-
</div>
|
|
1245
|
-
</div>
|
|
1246
|
-
))}
|
|
1247
|
-
</div>
|
|
1248
|
-
|
|
1249
|
-
{/* Time axis */}
|
|
1250
|
-
<div className="flex">
|
|
1251
|
-
<div className="w-[280px] shrink-0 bg-theme-surface/50 border-r border-theme-border" />
|
|
1252
|
-
<div className="flex-1 relative h-5 bg-theme-elevated/30">
|
|
1253
|
-
{ticks.map((tick, i) => {
|
|
1254
|
-
const x = localTimeToX(tick.time)
|
|
1255
|
-
return (
|
|
1256
|
-
<div key={i} className="absolute top-0 flex flex-col items-center" style={{ left: `${x}%`, transform: 'translateX(-50%)' }}>
|
|
1257
|
-
<div className="h-1.5 w-px bg-theme-border" />
|
|
1258
|
-
<span className="text-[10px] text-theme-text-tertiary">{tick.label}</span>
|
|
1259
|
-
</div>
|
|
1260
|
-
)
|
|
1261
|
-
})}
|
|
1262
|
-
</div>
|
|
1263
|
-
</div>
|
|
1333
|
+
{/* Swimlane — the shared TimelineSwimlanes widget (kind chips, top axis with
|
|
1334
|
+
ticks + Now line, event clustering), flat + compact for a single subject.
|
|
1335
|
+
Its drawer is suppressed (compact); the list below is the detail surface. */}
|
|
1336
|
+
<div className="shrink-0 border-b border-theme-border">
|
|
1337
|
+
<TimelineSwimlanes
|
|
1338
|
+
events={cleanEvents}
|
|
1339
|
+
grouping="flat"
|
|
1340
|
+
compact
|
|
1341
|
+
topology={topology}
|
|
1342
|
+
onResourceClick={onResourceClick}
|
|
1343
|
+
viewWindow={viewWindow ?? undefined}
|
|
1344
|
+
bounds={bounds ?? undefined}
|
|
1345
|
+
nowMs={now}
|
|
1346
|
+
isLive={userWindow == null}
|
|
1347
|
+
onViewWindowChange={(w) => {
|
|
1348
|
+
const now = Date.now()
|
|
1349
|
+
const MIN = 15 * 60_000
|
|
1350
|
+
let { fromMs, toMs } = w
|
|
1351
|
+
// Floor zoom-in at ~15 min: a tiny empty sub-window strands the user
|
|
1352
|
+
// (the "move the strip above" hint has no strip here).
|
|
1353
|
+
if (toMs - fromMs < MIN) {
|
|
1354
|
+
const c = (fromMs + toMs) / 2
|
|
1355
|
+
fromMs = c - MIN / 2
|
|
1356
|
+
toMs = c + MIN / 2
|
|
1357
|
+
}
|
|
1358
|
+
// Never pan/zoom past now: shift the window back so its right edge is
|
|
1359
|
+
// at most the present (there's nothing in the future to show).
|
|
1360
|
+
if (toMs > now) {
|
|
1361
|
+
const width = toMs - fromMs
|
|
1362
|
+
toMs = now
|
|
1363
|
+
fromMs = now - width
|
|
1364
|
+
}
|
|
1365
|
+
setUserWindow({ fromMs, toMs })
|
|
1366
|
+
}}
|
|
1367
|
+
selectedEventId={selectedEventId}
|
|
1368
|
+
onSelectedEventChange={handleSelect}
|
|
1369
|
+
/>
|
|
1264
1370
|
</div>
|
|
1265
1371
|
|
|
1266
|
-
{/*
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
{events.length === 0 ? (
|
|
1280
|
-
<tr>
|
|
1281
|
-
<td colSpan={5} className="px-4 py-8 text-center text-theme-text-tertiary">
|
|
1282
|
-
No events in this time range
|
|
1283
|
-
</td>
|
|
1284
|
-
</tr>
|
|
1285
|
-
) : (
|
|
1286
|
-
events.map((evt, evtIdx) => {
|
|
1287
|
-
const isSelected = selectedEventId === evt.id
|
|
1288
|
-
const isHovered = hoveredEventId === evt.id
|
|
1289
|
-
const isWarning = isProblematicEvent(evt)
|
|
1290
|
-
return (
|
|
1291
|
-
<tr
|
|
1292
|
-
key={`${evt.id}-${evtIdx}`}
|
|
1293
|
-
ref={(el) => { if (el) rowRefs.current.set(evtIdx, el); else rowRefs.current.delete(evtIdx) }}
|
|
1294
|
-
data-row-index={evtIdx}
|
|
1295
|
-
onClick={() => onSelectEvent(isSelected ? null : evt.id)}
|
|
1296
|
-
onMouseEnter={() => setHoveredEventId(evt.id)}
|
|
1297
|
-
onMouseLeave={() => setHoveredEventId(null)}
|
|
1298
|
-
className={clsx(
|
|
1299
|
-
'cursor-pointer transition-colors',
|
|
1300
|
-
isSelected ? 'bg-blue-500/10' : isHovered ? 'bg-blue-500/5' : 'hover:bg-theme-surface/50'
|
|
1301
|
-
)}
|
|
1302
|
-
>
|
|
1303
|
-
<td className="px-4 py-3">
|
|
1304
|
-
<div className="flex items-center gap-2">
|
|
1305
|
-
<EventDot event={evt} />
|
|
1306
|
-
<span className={clsx('font-medium', isWarning ? 'text-amber-500' : 'text-theme-text-primary')}>
|
|
1307
|
-
{isHistoricalEvent(evt) && evt.reason ? evt.reason : isChangeEvent(evt) ? evt.eventType : evt.reason}
|
|
1308
|
-
</span>
|
|
1309
|
-
</div>
|
|
1310
|
-
</td>
|
|
1311
|
-
<td className="px-4 py-3 text-theme-text-secondary">
|
|
1312
|
-
{evt.message || evt.diff?.summary || '-'}
|
|
1313
|
-
</td>
|
|
1314
|
-
<td className="px-4 py-3 text-theme-text-tertiary">
|
|
1315
|
-
{new Date(evt.timestamp).toLocaleString([], { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit', second: '2-digit' })}
|
|
1316
|
-
</td>
|
|
1317
|
-
<td className="px-4 py-3">
|
|
1318
|
-
<span className={clsx('badge-sm', getKindBadgeColor(evt.kind))}>
|
|
1319
|
-
{evt.kind}
|
|
1320
|
-
</span>
|
|
1321
|
-
</td>
|
|
1322
|
-
<td className="px-4 py-3">
|
|
1323
|
-
{isWarning ? (
|
|
1324
|
-
<span className="badge status-degraded">Active</span>
|
|
1325
|
-
) : evt.healthState ? (
|
|
1326
|
-
<span className={clsx('badge', getHealthBadgeColor(evt.healthState))}>{evt.healthState}</span>
|
|
1327
|
-
) : null}
|
|
1328
|
-
</td>
|
|
1329
|
-
</tr>
|
|
1330
|
-
)
|
|
1331
|
-
})
|
|
1332
|
-
)}
|
|
1333
|
-
</tbody>
|
|
1334
|
-
</table>
|
|
1372
|
+
{/* Event list — the shared TimelineList: same event icons/colors as the
|
|
1373
|
+
swimlane, kind badge + resource link, namespace, message, and inline diff,
|
|
1374
|
+
all shown directly. compact drops its toolbar (the swimlane above owns the
|
|
1375
|
+
window). */}
|
|
1376
|
+
<div className="min-h-0 flex-1">
|
|
1377
|
+
<TimelineList
|
|
1378
|
+
compact
|
|
1379
|
+
events={cleanEvents}
|
|
1380
|
+
isLoading={false}
|
|
1381
|
+
onResourceClick={onResourceClick}
|
|
1382
|
+
selectedEventId={selectedEventId}
|
|
1383
|
+
onSelectEvent={handleSelect}
|
|
1384
|
+
/>
|
|
1335
1385
|
</div>
|
|
1336
1386
|
</div>
|
|
1337
1387
|
)
|
|
1338
1388
|
}
|
|
1339
1389
|
|
|
1340
|
-
function EventDot({ event }: { event: TimelineEvent }) {
|
|
1341
|
-
const isWarning = isProblematicEvent(event)
|
|
1342
|
-
const isDelete = event.eventType === 'delete'
|
|
1343
|
-
const isAdd = event.eventType === 'add'
|
|
1344
|
-
return (
|
|
1345
|
-
<div className={clsx(
|
|
1346
|
-
'w-3 h-3 rounded-full shrink-0',
|
|
1347
|
-
isWarning ? 'bg-amber-500' : isDelete ? 'bg-red-500' : isAdd ? 'bg-green-500' : 'bg-blue-500'
|
|
1348
|
-
)} />
|
|
1349
|
-
)
|
|
1350
|
-
}
|
|
1351
|
-
|
|
1352
1390
|
// ============================================================================
|
|
1353
|
-
// INFO TAB —
|
|
1391
|
+
// INFO TAB — full-page workload triage overview
|
|
1354
1392
|
// ============================================================================
|
|
1355
1393
|
|
|
1356
1394
|
function InfoTab({
|
|
1357
1395
|
resource,
|
|
1358
1396
|
selectedResource,
|
|
1359
1397
|
relationships,
|
|
1398
|
+
certificateInfo,
|
|
1360
1399
|
hpaDiagnosis,
|
|
1361
1400
|
scalerDiagnostics,
|
|
1401
|
+
workloadPods,
|
|
1402
|
+
workloadPodsLoading,
|
|
1403
|
+
workloadPodsError,
|
|
1404
|
+
servingResources,
|
|
1405
|
+
renderServicePortAction,
|
|
1406
|
+
renderServicePortPanel,
|
|
1362
1407
|
isLoading,
|
|
1363
1408
|
error,
|
|
1364
1409
|
onNavigate,
|
|
@@ -1368,6 +1413,8 @@ function InfoTab({
|
|
|
1368
1413
|
isSavingSecret,
|
|
1369
1414
|
onOpenLogs,
|
|
1370
1415
|
onSwitchToTimeline,
|
|
1416
|
+
onSwitchToLogs,
|
|
1417
|
+
onSwitchToTopology,
|
|
1371
1418
|
rendererOverrides,
|
|
1372
1419
|
resolvedEnvFrom,
|
|
1373
1420
|
events,
|
|
@@ -1376,13 +1423,21 @@ function InfoTab({
|
|
|
1376
1423
|
eventsError,
|
|
1377
1424
|
updatesError,
|
|
1378
1425
|
extraContent,
|
|
1426
|
+
introContent,
|
|
1379
1427
|
leadContent,
|
|
1380
1428
|
}: {
|
|
1381
1429
|
resource: any
|
|
1382
1430
|
selectedResource: SelectedResource
|
|
1383
1431
|
relationships?: Relationships
|
|
1432
|
+
certificateInfo?: any
|
|
1384
1433
|
hpaDiagnosis?: HPADiagnosis
|
|
1385
1434
|
scalerDiagnostics?: ScalerDiagnosis[]
|
|
1435
|
+
workloadPods?: WorkloadPodInfo[]
|
|
1436
|
+
workloadPodsLoading?: boolean
|
|
1437
|
+
workloadPodsError?: Error | null
|
|
1438
|
+
servingResources?: ServingResourceDetail[]
|
|
1439
|
+
renderServicePortAction?: (props: ServicePortRenderProps) => ReactNode
|
|
1440
|
+
renderServicePortPanel?: (props: ServicePortRenderProps) => ReactNode
|
|
1386
1441
|
isLoading: boolean
|
|
1387
1442
|
error?: unknown
|
|
1388
1443
|
onNavigate?: NavigateToResource
|
|
@@ -1392,6 +1447,8 @@ function InfoTab({
|
|
|
1392
1447
|
isSavingSecret?: boolean
|
|
1393
1448
|
onOpenLogs?: (podName: string, containerName: string) => void
|
|
1394
1449
|
onSwitchToTimeline?: () => void
|
|
1450
|
+
onSwitchToLogs?: () => void
|
|
1451
|
+
onSwitchToTopology?: () => void
|
|
1395
1452
|
rendererOverrides?: RendererOverrides
|
|
1396
1453
|
resolvedEnvFrom?: ResolvedEnvFrom
|
|
1397
1454
|
events?: TimelineEvent[]
|
|
@@ -1400,61 +1457,2214 @@ function InfoTab({
|
|
|
1400
1457
|
eventsError?: Error | null
|
|
1401
1458
|
updatesError?: Error | null
|
|
1402
1459
|
extraContent?: ReactNode
|
|
1460
|
+
introContent?: ReactNode
|
|
1403
1461
|
leadContent?: ReactNode
|
|
1404
1462
|
}) {
|
|
1405
1463
|
if (!resource) {
|
|
1406
1464
|
return <FetchResult loading={isLoading} error={error} className="h-full" />
|
|
1407
1465
|
}
|
|
1408
1466
|
|
|
1467
|
+
if (!isRuntimeWorkloadOverviewKind(selectedResource.kind)) {
|
|
1468
|
+
return (
|
|
1469
|
+
<div className="h-full overflow-auto">
|
|
1470
|
+
{leadContent && (
|
|
1471
|
+
<div className="px-4 pt-4">
|
|
1472
|
+
{leadContent}
|
|
1473
|
+
</div>
|
|
1474
|
+
)}
|
|
1475
|
+
{introContent && (
|
|
1476
|
+
<div className="px-4 pt-4">
|
|
1477
|
+
{introContent}
|
|
1478
|
+
</div>
|
|
1479
|
+
)}
|
|
1480
|
+
<ResourceRendererDispatch
|
|
1481
|
+
resource={selectedResource}
|
|
1482
|
+
data={resource}
|
|
1483
|
+
relationships={relationships}
|
|
1484
|
+
certificateInfo={certificateInfo}
|
|
1485
|
+
hpaDiagnosis={hpaDiagnosis}
|
|
1486
|
+
scalerDiagnostics={scalerDiagnostics}
|
|
1487
|
+
onCopy={onCopy}
|
|
1488
|
+
copied={copied}
|
|
1489
|
+
onNavigate={onNavigate ? (ref) => onNavigate(refToSelectedResource(ref)) : undefined}
|
|
1490
|
+
onSaveSecretValue={onSaveSecretValue}
|
|
1491
|
+
isSavingSecret={isSavingSecret}
|
|
1492
|
+
showCommonSections={true}
|
|
1493
|
+
showMetrics={false}
|
|
1494
|
+
onOpenLogs={onOpenLogs}
|
|
1495
|
+
rendererOverrides={rendererOverrides}
|
|
1496
|
+
resolvedEnvFrom={resolvedEnvFrom}
|
|
1497
|
+
events={events}
|
|
1498
|
+
eventsLoading={eventsLoading}
|
|
1499
|
+
updates={updates}
|
|
1500
|
+
eventsError={eventsError}
|
|
1501
|
+
updatesError={updatesError}
|
|
1502
|
+
eventsHint={onSwitchToTimeline && (
|
|
1503
|
+
<button
|
|
1504
|
+
onClick={onSwitchToTimeline}
|
|
1505
|
+
className="text-xs text-theme-text-tertiary transition-colors hover:text-theme-text-secondary"
|
|
1506
|
+
>
|
|
1507
|
+
Showing recent events for this resource. Switch to the <span className="underline">Timeline</span> tab for full history and relationships.
|
|
1508
|
+
</button>
|
|
1509
|
+
)}
|
|
1510
|
+
renderSidebar={(sidebarSections) => (
|
|
1511
|
+
<div className="border-theme-border lg:w-[35%] lg:shrink-0 lg:border-l">
|
|
1512
|
+
<div className="space-y-4 p-4">
|
|
1513
|
+
{sidebarSections}
|
|
1514
|
+
</div>
|
|
1515
|
+
</div>
|
|
1516
|
+
)}
|
|
1517
|
+
mainFooter={extraContent}
|
|
1518
|
+
/>
|
|
1519
|
+
</div>
|
|
1520
|
+
)
|
|
1521
|
+
}
|
|
1522
|
+
|
|
1409
1523
|
return (
|
|
1410
|
-
<
|
|
1411
|
-
{
|
|
1412
|
-
|
|
1524
|
+
<WorkloadOverviewTab
|
|
1525
|
+
resource={resource}
|
|
1526
|
+
selectedResource={selectedResource}
|
|
1527
|
+
relationships={relationships}
|
|
1528
|
+
hpaDiagnosis={hpaDiagnosis}
|
|
1529
|
+
scalerDiagnostics={scalerDiagnostics}
|
|
1530
|
+
workloadPods={workloadPods}
|
|
1531
|
+
workloadPodsLoading={workloadPodsLoading}
|
|
1532
|
+
workloadPodsError={workloadPodsError}
|
|
1533
|
+
servingResources={servingResources}
|
|
1534
|
+
renderServicePortAction={renderServicePortAction}
|
|
1535
|
+
renderServicePortPanel={renderServicePortPanel}
|
|
1536
|
+
onNavigate={onNavigate}
|
|
1537
|
+
onSwitchToTimeline={onSwitchToTimeline}
|
|
1538
|
+
onSwitchToLogs={onSwitchToLogs}
|
|
1539
|
+
onSwitchToTopology={onSwitchToTopology}
|
|
1540
|
+
events={events}
|
|
1541
|
+
eventsLoading={eventsLoading}
|
|
1542
|
+
updates={updates}
|
|
1543
|
+
eventsError={eventsError}
|
|
1544
|
+
updatesError={updatesError}
|
|
1545
|
+
extraContent={extraContent}
|
|
1546
|
+
introContent={introContent}
|
|
1547
|
+
leadContent={leadContent}
|
|
1548
|
+
/>
|
|
1549
|
+
)
|
|
1550
|
+
}
|
|
1551
|
+
|
|
1552
|
+
const POD_VISIBLE_LIMIT = 5
|
|
1553
|
+
const EVENT_VISIBLE_LIMIT = 5
|
|
1554
|
+
const RELATIONSHIP_GROUP_LIMIT = 5
|
|
1555
|
+
const RELATIONSHIP_REF_LIMIT = 5
|
|
1556
|
+
const LOGS_TAB_WITHOUT_PODS_KINDS = new Set([
|
|
1557
|
+
'jobs',
|
|
1558
|
+
'cronjobs',
|
|
1559
|
+
'workflows',
|
|
1560
|
+
'cronworkflows',
|
|
1561
|
+
'workflowtemplates',
|
|
1562
|
+
'clusterworkflowtemplates',
|
|
1563
|
+
'scaledjobs',
|
|
1564
|
+
])
|
|
1565
|
+
const RUNTIME_WORKLOAD_OVERVIEW_KINDS = new Set(['deployments', 'statefulsets', 'daemonsets', 'jobs', 'cronjobs'])
|
|
1566
|
+
type RuntimeOverviewShape = 'replicated' | 'job' | 'cronjob'
|
|
1567
|
+
|
|
1568
|
+
function isRuntimeWorkloadOverviewKind(kind: string) {
|
|
1569
|
+
return RUNTIME_WORKLOAD_OVERVIEW_KINDS.has(kindToPlural(kind).toLowerCase())
|
|
1570
|
+
}
|
|
1571
|
+
|
|
1572
|
+
function runtimeOverviewShape(kind: string): RuntimeOverviewShape {
|
|
1573
|
+
const k = kindToPlural(kind).toLowerCase()
|
|
1574
|
+
if (k === 'jobs') return 'job'
|
|
1575
|
+
if (k === 'cronjobs') return 'cronjob'
|
|
1576
|
+
return 'replicated'
|
|
1577
|
+
}
|
|
1578
|
+
|
|
1579
|
+
function WorkloadOverviewTab({
|
|
1580
|
+
resource,
|
|
1581
|
+
selectedResource,
|
|
1582
|
+
relationships,
|
|
1583
|
+
hpaDiagnosis,
|
|
1584
|
+
scalerDiagnostics,
|
|
1585
|
+
workloadPods,
|
|
1586
|
+
workloadPodsLoading,
|
|
1587
|
+
workloadPodsError,
|
|
1588
|
+
servingResources,
|
|
1589
|
+
renderServicePortAction,
|
|
1590
|
+
renderServicePortPanel,
|
|
1591
|
+
onNavigate,
|
|
1592
|
+
onSwitchToTimeline,
|
|
1593
|
+
onSwitchToLogs,
|
|
1594
|
+
onSwitchToTopology,
|
|
1595
|
+
events = [],
|
|
1596
|
+
updates = [],
|
|
1597
|
+
eventsLoading,
|
|
1598
|
+
eventsError,
|
|
1599
|
+
updatesError,
|
|
1600
|
+
extraContent,
|
|
1601
|
+
introContent,
|
|
1602
|
+
leadContent,
|
|
1603
|
+
}: {
|
|
1604
|
+
resource: any
|
|
1605
|
+
selectedResource: SelectedResource
|
|
1606
|
+
relationships?: Relationships
|
|
1607
|
+
hpaDiagnosis?: HPADiagnosis
|
|
1608
|
+
scalerDiagnostics?: ScalerDiagnosis[]
|
|
1609
|
+
workloadPods?: WorkloadPodInfo[]
|
|
1610
|
+
workloadPodsLoading?: boolean
|
|
1611
|
+
workloadPodsError?: Error | null
|
|
1612
|
+
servingResources?: ServingResourceDetail[]
|
|
1613
|
+
renderServicePortAction?: (props: ServicePortRenderProps) => ReactNode
|
|
1614
|
+
renderServicePortPanel?: (props: ServicePortRenderProps) => ReactNode
|
|
1615
|
+
onNavigate?: NavigateToResource
|
|
1616
|
+
onSwitchToTimeline?: () => void
|
|
1617
|
+
onSwitchToLogs?: () => void
|
|
1618
|
+
onSwitchToTopology?: () => void
|
|
1619
|
+
events?: TimelineEvent[]
|
|
1620
|
+
updates?: TimelineEvent[]
|
|
1621
|
+
eventsLoading?: boolean
|
|
1622
|
+
eventsError?: Error | null
|
|
1623
|
+
updatesError?: Error | null
|
|
1624
|
+
extraContent?: ReactNode
|
|
1625
|
+
introContent?: ReactNode
|
|
1626
|
+
leadContent?: ReactNode
|
|
1627
|
+
}) {
|
|
1628
|
+
const apiKind = kindToPlural(selectedResource.kind)
|
|
1629
|
+
const resourceKind = resource?.kind || displayKindName(apiKind, resource?.kind)
|
|
1630
|
+
const readiness = getReadinessSummary(resource, apiKind)
|
|
1631
|
+
const podSummary = getPodSummary(workloadPods, relationships, apiKind)
|
|
1632
|
+
const scaling = getScalingSummary(resource, apiKind, relationships, scalerDiagnostics, hpaDiagnosis)
|
|
1633
|
+
const controlSurface = runtimeControlSurface(apiKind)
|
|
1634
|
+
const overviewShape = runtimeOverviewShape(apiKind)
|
|
1635
|
+
const summaryMetrics = buildSummaryMetrics(resource, apiKind, resourceKind, readiness, podSummary, scaling)
|
|
1636
|
+
const relationshipGroups = buildRelationshipGroups(relationships)
|
|
1637
|
+
const servingRelationshipGroups = buildServingRelationshipGroups(relationships)
|
|
1638
|
+
const dependencyRelationshipGroups = buildDependencyRelationshipGroups(relationships)
|
|
1639
|
+
const showServingPath = servingRelationshipGroups.length > 0 && overviewShape !== 'job' && overviewShape !== 'cronjob'
|
|
1640
|
+
const secondaryRelationshipGroups = showServingPath ? dependencyRelationshipGroups : relationshipGroups
|
|
1641
|
+
const relationshipCardTitle =
|
|
1642
|
+
overviewShape === 'job' || overviewShape === 'cronjob'
|
|
1643
|
+
? 'Related resources'
|
|
1644
|
+
: showServingPath
|
|
1645
|
+
? 'Dependencies'
|
|
1646
|
+
: 'Related resources'
|
|
1647
|
+
|
|
1648
|
+
if (overviewShape === 'replicated') {
|
|
1649
|
+
const state = getReplicatedWorkloadState(resource, apiKind)
|
|
1650
|
+
const serviceAccountName = getPodTemplateSpec(resource, apiKind)?.serviceAccountName || 'default'
|
|
1651
|
+
return (
|
|
1652
|
+
<div className="h-full overflow-auto bg-theme-base">
|
|
1653
|
+
<div className="space-y-4 p-4">
|
|
1413
1654
|
{leadContent}
|
|
1655
|
+
{introContent}
|
|
1656
|
+
|
|
1657
|
+
<WorkloadStatusStrip
|
|
1658
|
+
state={state}
|
|
1659
|
+
readiness={readiness}
|
|
1660
|
+
podSummary={podSummary}
|
|
1661
|
+
servingSummary={showServingPath ? buildServingStripSummary(servingRelationshipGroups, servingResources) : undefined}
|
|
1662
|
+
/>
|
|
1663
|
+
|
|
1664
|
+
<div className="grid items-start gap-4 xl:grid-cols-[minmax(0,1fr)_360px] 2xl:grid-cols-[minmax(0,1fr)_400px]">
|
|
1665
|
+
<div className="space-y-4">
|
|
1666
|
+
<div className="grid items-start gap-4 2xl:grid-cols-[minmax(0,1fr)_minmax(320px,420px)]">
|
|
1667
|
+
<div className="space-y-4">
|
|
1668
|
+
{showServingPath && (
|
|
1669
|
+
<ServingConfigurationCard
|
|
1670
|
+
groups={servingRelationshipGroups}
|
|
1671
|
+
details={servingResources}
|
|
1672
|
+
renderPortAction={renderServicePortAction}
|
|
1673
|
+
renderPortPanel={renderServicePortPanel}
|
|
1674
|
+
onNavigate={onNavigate}
|
|
1675
|
+
onSwitchToTopology={onSwitchToTopology}
|
|
1676
|
+
/>
|
|
1677
|
+
)}
|
|
1678
|
+
<RuntimeCard
|
|
1679
|
+
resource={resource}
|
|
1680
|
+
apiKind={apiKind}
|
|
1681
|
+
state={state}
|
|
1682
|
+
readiness={readiness}
|
|
1683
|
+
scaling={scaling}
|
|
1684
|
+
namespace={selectedResource.namespace}
|
|
1685
|
+
workloadPods={workloadPods}
|
|
1686
|
+
workloadPodsLoading={workloadPodsLoading}
|
|
1687
|
+
workloadPodsError={workloadPodsError}
|
|
1688
|
+
relationshipPods={relationships?.pods}
|
|
1689
|
+
scalerDiagnostics={scalerDiagnostics}
|
|
1690
|
+
hpaDiagnosis={hpaDiagnosis}
|
|
1691
|
+
relationships={relationships}
|
|
1692
|
+
onNavigate={onNavigate}
|
|
1693
|
+
onSwitchToLogs={onSwitchToLogs}
|
|
1694
|
+
/>
|
|
1695
|
+
</div>
|
|
1696
|
+
<div className="space-y-4">
|
|
1697
|
+
<ConfigurationInputsCard
|
|
1698
|
+
serviceAccountName={serviceAccountName}
|
|
1699
|
+
namespace={selectedResource.namespace}
|
|
1700
|
+
relationships={relationships}
|
|
1701
|
+
onNavigate={onNavigate}
|
|
1702
|
+
onSwitchToTopology={onSwitchToTopology}
|
|
1703
|
+
/>
|
|
1704
|
+
{extraContent}
|
|
1705
|
+
</div>
|
|
1706
|
+
</div>
|
|
1707
|
+
</div>
|
|
1708
|
+
|
|
1709
|
+
<div className="space-y-4">
|
|
1710
|
+
<ActivityCard
|
|
1711
|
+
events={events}
|
|
1712
|
+
updates={updates}
|
|
1713
|
+
loading={eventsLoading}
|
|
1714
|
+
eventsError={eventsError}
|
|
1715
|
+
updatesError={updatesError}
|
|
1716
|
+
onSwitchToTimeline={onSwitchToTimeline}
|
|
1717
|
+
/>
|
|
1718
|
+
</div>
|
|
1719
|
+
</div>
|
|
1414
1720
|
</div>
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
className="text-xs text-theme-text-tertiary hover:text-theme-text-secondary transition-colors"
|
|
1441
|
-
>
|
|
1442
|
-
Showing recent events across this workload. Switch to the <span className="underline">Timeline</span> tab for full history and resource relationships.
|
|
1443
|
-
</button>
|
|
1721
|
+
</div>
|
|
1722
|
+
)
|
|
1723
|
+
}
|
|
1724
|
+
|
|
1725
|
+
return (
|
|
1726
|
+
<div className="h-full overflow-auto bg-theme-base">
|
|
1727
|
+
<div className="space-y-4 p-4">
|
|
1728
|
+
{leadContent}
|
|
1729
|
+
{introContent}
|
|
1730
|
+
|
|
1731
|
+
<div className="grid grid-cols-1 gap-3 md:grid-cols-2 2xl:grid-cols-4">
|
|
1732
|
+
{summaryMetrics.map((metric) => (
|
|
1733
|
+
<SummaryMetric key={metric.label} {...metric} />
|
|
1734
|
+
))}
|
|
1735
|
+
</div>
|
|
1736
|
+
|
|
1737
|
+
{showServingPath && (
|
|
1738
|
+
<ServingConfigurationCard
|
|
1739
|
+
groups={servingRelationshipGroups}
|
|
1740
|
+
details={servingResources}
|
|
1741
|
+
renderPortAction={renderServicePortAction}
|
|
1742
|
+
renderPortPanel={renderServicePortPanel}
|
|
1743
|
+
onNavigate={onNavigate}
|
|
1744
|
+
onSwitchToTopology={onSwitchToTopology}
|
|
1745
|
+
/>
|
|
1444
1746
|
)}
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
|
|
1747
|
+
|
|
1748
|
+
<div className="grid items-start gap-4 xl:grid-cols-[minmax(0,1fr)_360px] 2xl:grid-cols-[minmax(0,1fr)_400px]">
|
|
1749
|
+
<div className="space-y-4">
|
|
1750
|
+
<CurrentStateCard resource={resource} apiKind={apiKind} readiness={readiness} />
|
|
1751
|
+
<div className="grid items-start gap-4 2xl:grid-cols-2">
|
|
1752
|
+
{overviewShape === 'cronjob' ? (
|
|
1753
|
+
<CronJobRunsCard
|
|
1754
|
+
resource={resource}
|
|
1755
|
+
namespace={selectedResource.namespace}
|
|
1756
|
+
onNavigate={onNavigate}
|
|
1757
|
+
onSwitchToTopology={onSwitchToTopology}
|
|
1758
|
+
/>
|
|
1759
|
+
) : (
|
|
1760
|
+
<PodsAttentionCard
|
|
1761
|
+
namespace={selectedResource.namespace}
|
|
1762
|
+
emptyText={overviewShape === 'job' ? 'No Pods are currently retained for this Job.' : undefined}
|
|
1763
|
+
workloadPods={workloadPods}
|
|
1764
|
+
workloadPodsLoading={workloadPodsLoading}
|
|
1765
|
+
workloadPodsError={workloadPodsError}
|
|
1766
|
+
relationshipPods={relationships?.pods}
|
|
1767
|
+
onNavigate={onNavigate}
|
|
1768
|
+
onSwitchToLogs={onSwitchToLogs}
|
|
1769
|
+
/>
|
|
1770
|
+
)}
|
|
1771
|
+
<ScalingCard
|
|
1772
|
+
title={controlSurface.cardTitle}
|
|
1773
|
+
icon={controlSurface.icon}
|
|
1774
|
+
scaling={scaling}
|
|
1775
|
+
scalerDiagnostics={scalerDiagnostics}
|
|
1776
|
+
hpaDiagnosis={hpaDiagnosis}
|
|
1777
|
+
relationships={relationships}
|
|
1778
|
+
onNavigate={onNavigate}
|
|
1779
|
+
/>
|
|
1449
1780
|
</div>
|
|
1781
|
+
{secondaryRelationshipGroups.length > 0 ? (
|
|
1782
|
+
<ServingDependenciesCard
|
|
1783
|
+
title={relationshipCardTitle}
|
|
1784
|
+
relationships={relationships}
|
|
1785
|
+
groups={secondaryRelationshipGroups}
|
|
1786
|
+
onNavigate={onNavigate}
|
|
1787
|
+
onSwitchToTopology={onSwitchToTopology}
|
|
1788
|
+
/>
|
|
1789
|
+
) : null}
|
|
1790
|
+
{extraContent && (
|
|
1791
|
+
<div className="grid items-start gap-4 2xl:grid-cols-2">
|
|
1792
|
+
{extraContent}
|
|
1793
|
+
</div>
|
|
1794
|
+
)}
|
|
1795
|
+
</div>
|
|
1796
|
+
|
|
1797
|
+
<div className="space-y-4">
|
|
1798
|
+
<ActivityCard
|
|
1799
|
+
events={events}
|
|
1800
|
+
updates={updates}
|
|
1801
|
+
loading={eventsLoading}
|
|
1802
|
+
eventsError={eventsError}
|
|
1803
|
+
updatesError={updatesError}
|
|
1804
|
+
onSwitchToTimeline={onSwitchToTimeline}
|
|
1805
|
+
/>
|
|
1450
1806
|
</div>
|
|
1451
|
-
)}
|
|
1452
|
-
/>
|
|
1453
|
-
{extraContent && (
|
|
1454
|
-
<div className="px-4 pb-4">
|
|
1455
|
-
{extraContent}
|
|
1456
1807
|
</div>
|
|
1457
|
-
|
|
1808
|
+
|
|
1809
|
+
</div>
|
|
1458
1810
|
</div>
|
|
1459
1811
|
)
|
|
1460
1812
|
}
|
|
1813
|
+
|
|
1814
|
+
function OverviewCard({
|
|
1815
|
+
title,
|
|
1816
|
+
icon: Icon,
|
|
1817
|
+
action,
|
|
1818
|
+
children,
|
|
1819
|
+
}: {
|
|
1820
|
+
title: string
|
|
1821
|
+
icon: ComponentType<{ className?: string }>
|
|
1822
|
+
action?: ReactNode
|
|
1823
|
+
children: ReactNode
|
|
1824
|
+
}) {
|
|
1825
|
+
return (
|
|
1826
|
+
<section className="rounded-lg border border-theme-border bg-theme-surface p-4 shadow-theme-sm">
|
|
1827
|
+
<div className="mb-3 flex min-w-0 items-center justify-between gap-3">
|
|
1828
|
+
<div className="flex min-w-0 items-center gap-2">
|
|
1829
|
+
<Icon className="h-4 w-4 shrink-0 text-theme-text-secondary" />
|
|
1830
|
+
<h2 className="truncate text-[11px] font-semibold uppercase tracking-wide text-theme-text-secondary">{title}</h2>
|
|
1831
|
+
</div>
|
|
1832
|
+
{action}
|
|
1833
|
+
</div>
|
|
1834
|
+
{children}
|
|
1835
|
+
</section>
|
|
1836
|
+
)
|
|
1837
|
+
}
|
|
1838
|
+
|
|
1839
|
+
function SummaryMetric({
|
|
1840
|
+
label,
|
|
1841
|
+
value,
|
|
1842
|
+
detail,
|
|
1843
|
+
icon: Icon,
|
|
1844
|
+
mono,
|
|
1845
|
+
}: {
|
|
1846
|
+
label: string
|
|
1847
|
+
value: ReactNode
|
|
1848
|
+
detail?: ReactNode
|
|
1849
|
+
icon: ComponentType<{ className?: string }>
|
|
1850
|
+
mono?: boolean
|
|
1851
|
+
}) {
|
|
1852
|
+
return (
|
|
1853
|
+
<section className="min-w-0 rounded-lg border border-theme-border bg-theme-surface p-4 shadow-theme-sm">
|
|
1854
|
+
<div className="mb-3 flex items-center gap-2 text-[11px] font-semibold uppercase tracking-wide text-theme-text-secondary">
|
|
1855
|
+
<Icon className="h-4 w-4 shrink-0" />
|
|
1856
|
+
<span>{label}</span>
|
|
1857
|
+
</div>
|
|
1858
|
+
<div className={clsx('truncate text-lg font-semibold text-theme-text-primary', mono && 'font-mono')}>{value}</div>
|
|
1859
|
+
{detail && <div className="mt-1 truncate text-xs text-theme-text-tertiary">{detail}</div>}
|
|
1860
|
+
</section>
|
|
1861
|
+
)
|
|
1862
|
+
}
|
|
1863
|
+
|
|
1864
|
+
interface ReplicatedWorkloadState {
|
|
1865
|
+
label: string
|
|
1866
|
+
detail: string
|
|
1867
|
+
badgeClass: string
|
|
1868
|
+
level: 'healthy' | 'neutral' | 'degraded' | 'unhealthy' | 'unknown'
|
|
1869
|
+
healthy: boolean
|
|
1870
|
+
needsAttention: boolean
|
|
1871
|
+
}
|
|
1872
|
+
|
|
1873
|
+
function WorkloadStatusStrip({
|
|
1874
|
+
state,
|
|
1875
|
+
readiness,
|
|
1876
|
+
podSummary,
|
|
1877
|
+
servingSummary,
|
|
1878
|
+
}: {
|
|
1879
|
+
state: ReplicatedWorkloadState
|
|
1880
|
+
readiness: ReadinessSummary
|
|
1881
|
+
podSummary: ReturnType<typeof getPodSummary>
|
|
1882
|
+
servingSummary?: string
|
|
1883
|
+
}) {
|
|
1884
|
+
const servingMode = Boolean(servingSummary)
|
|
1885
|
+
const stateDetail = servingMode && state.healthy ? 'runtime health' : state.detail
|
|
1886
|
+
const podItem = servingMode && typeof podSummary.total === 'number'
|
|
1887
|
+
? {
|
|
1888
|
+
label: 'Pods',
|
|
1889
|
+
value: `${podSummary.total}`,
|
|
1890
|
+
detail: podSummary.total === 0
|
|
1891
|
+
? 'none running'
|
|
1892
|
+
: (podSummary.notReady ?? 0) > 0
|
|
1893
|
+
? `${podSummary.notReady} need attention`
|
|
1894
|
+
: 'runtime instances',
|
|
1895
|
+
}
|
|
1896
|
+
: { label: 'Pods', value: podSummary.label, detail: podSummary.detail }
|
|
1897
|
+
const items = [
|
|
1898
|
+
{ label: 'State', value: <span className={clsx('badge-sm', state.badgeClass)}>{state.label}</span>, detail: stateDetail },
|
|
1899
|
+
{ label: 'Ready', value: readiness.readyLabel, detail: readiness.detail },
|
|
1900
|
+
podItem,
|
|
1901
|
+
servingSummary ? { label: 'Exposure', value: servingSummary, detail: 'configured, not probed' } : null,
|
|
1902
|
+
].filter(Boolean) as Array<{ label: string; value: ReactNode; detail?: ReactNode }>
|
|
1903
|
+
|
|
1904
|
+
return (
|
|
1905
|
+
<section className="rounded-lg border border-theme-border bg-theme-surface px-4 py-3 shadow-theme-sm">
|
|
1906
|
+
<div className="grid gap-3 sm:grid-cols-2 2xl:grid-cols-4">
|
|
1907
|
+
{items.map((item) => (
|
|
1908
|
+
<div key={item.label} className="min-w-0">
|
|
1909
|
+
<div className="text-[10px] font-semibold uppercase tracking-wide text-theme-text-tertiary">{item.label}</div>
|
|
1910
|
+
<div className="mt-1 truncate text-sm font-semibold text-theme-text-primary">{item.value}</div>
|
|
1911
|
+
{item.detail ? <div className="mt-0.5 truncate text-xs text-theme-text-tertiary">{item.detail}</div> : null}
|
|
1912
|
+
</div>
|
|
1913
|
+
))}
|
|
1914
|
+
</div>
|
|
1915
|
+
</section>
|
|
1916
|
+
)
|
|
1917
|
+
}
|
|
1918
|
+
|
|
1919
|
+
function getReplicatedWorkloadState(resource: any, apiKind: string): ReplicatedWorkloadState {
|
|
1920
|
+
const status = resource?.status || {}
|
|
1921
|
+
const spec = resource?.spec || {}
|
|
1922
|
+
const metadata = resource?.metadata || {}
|
|
1923
|
+
const k = apiKind.toLowerCase()
|
|
1924
|
+
const generation = metadata.generation
|
|
1925
|
+
const observedGeneration = status.observedGeneration
|
|
1926
|
+
const controllerBehind =
|
|
1927
|
+
typeof generation === 'number' &&
|
|
1928
|
+
typeof observedGeneration === 'number' &&
|
|
1929
|
+
observedGeneration < generation
|
|
1930
|
+
|
|
1931
|
+
if (k === 'daemonsets') {
|
|
1932
|
+
const desired = status.desiredNumberScheduled ?? 0
|
|
1933
|
+
const ready = status.numberReady ?? 0
|
|
1934
|
+
const updated = status.updatedNumberScheduled ?? 0
|
|
1935
|
+
const unavailable = status.numberUnavailable ?? Math.max(0, desired - ready)
|
|
1936
|
+
if (desired === 0) return stateSummary('No targets', 'selector matches no nodes', 'status-neutral', 'neutral')
|
|
1937
|
+
if (controllerBehind) return stateSummary('Applying', 'controller has not observed latest spec', 'status-neutral', 'neutral')
|
|
1938
|
+
if (updated < desired) return stateSummary('Rolling out', `${updated}/${desired} updated`, 'status-neutral', 'neutral')
|
|
1939
|
+
if (ready === desired && unavailable === 0) return stateSummary('Healthy', `${ready}/${desired} ready`, 'status-healthy', 'healthy')
|
|
1940
|
+
if (ready > 0) return stateSummary('Degraded', `${ready}/${desired} ready`, 'status-degraded', 'degraded')
|
|
1941
|
+
return stateSummary('Unhealthy', 'no scheduled pods are ready', 'status-unhealthy', 'unhealthy')
|
|
1942
|
+
}
|
|
1943
|
+
|
|
1944
|
+
const desired = spec.replicas ?? status.replicas ?? 0
|
|
1945
|
+
const ready = status.readyReplicas ?? 0
|
|
1946
|
+
const updated = status.updatedReplicas ?? 0
|
|
1947
|
+
const available = status.availableReplicas ?? ready
|
|
1948
|
+
const unavailable = status.unavailableReplicas ?? Math.max(0, desired - available)
|
|
1949
|
+
if (desired === 0) return stateSummary('Scaled to zero', 'no replicas requested', 'status-neutral', 'neutral')
|
|
1950
|
+
if (k === 'deployments' && spec.paused) return stateSummary('Paused', `${ready}/${desired} ready`, 'status-neutral', 'neutral')
|
|
1951
|
+
const progressCondition = Array.isArray(status.conditions)
|
|
1952
|
+
? status.conditions.find((condition: any) => condition.type === 'Progressing')
|
|
1953
|
+
: undefined
|
|
1954
|
+
if (progressCondition?.status === 'False' || progressCondition?.reason === 'ProgressDeadlineExceeded') {
|
|
1955
|
+
return stateSummary('Stalled', progressCondition.message || `${ready}/${desired} ready`, 'status-unhealthy', 'unhealthy')
|
|
1956
|
+
}
|
|
1957
|
+
if (controllerBehind) return stateSummary('Applying', 'controller has not observed latest spec', 'status-neutral', 'neutral')
|
|
1958
|
+
const statefulSetRolling = k === 'statefulsets' && status.updateRevision && status.currentRevision && status.updateRevision !== status.currentRevision
|
|
1959
|
+
if (updated < desired || statefulSetRolling) return stateSummary('Rolling out', `${updated}/${desired} updated`, 'status-neutral', 'neutral')
|
|
1960
|
+
if (ready === desired && available === desired && unavailable === 0) return stateSummary('Healthy', `${ready}/${desired} ready`, 'status-healthy', 'healthy')
|
|
1961
|
+
if (ready > 0) return stateSummary('Degraded', `${ready}/${desired} ready`, 'status-degraded', 'degraded')
|
|
1962
|
+
return stateSummary('Unhealthy', 'no replicas are ready', 'status-unhealthy', 'unhealthy')
|
|
1963
|
+
}
|
|
1964
|
+
|
|
1965
|
+
function stateSummary(
|
|
1966
|
+
label: string,
|
|
1967
|
+
detail: string,
|
|
1968
|
+
badgeClass: string,
|
|
1969
|
+
level: ReplicatedWorkloadState['level'],
|
|
1970
|
+
): ReplicatedWorkloadState {
|
|
1971
|
+
return {
|
|
1972
|
+
label,
|
|
1973
|
+
detail,
|
|
1974
|
+
badgeClass,
|
|
1975
|
+
level,
|
|
1976
|
+
healthy: level === 'healthy',
|
|
1977
|
+
needsAttention: level === 'degraded' || level === 'unhealthy',
|
|
1978
|
+
}
|
|
1979
|
+
}
|
|
1980
|
+
|
|
1981
|
+
function buildServingStripSummary(groups: RelationshipGroup[], details?: ServingResourceDetail[]): string {
|
|
1982
|
+
const services = groups.find((group) => group.label === 'Services')?.refs ?? []
|
|
1983
|
+
const entrypoints = groups.find((group) => group.label === 'Entry points')?.refs ?? []
|
|
1984
|
+
const serviceResources = (details ?? [])
|
|
1985
|
+
.filter((detail) => services.some((ref) => resourceRefId(ref) === resourceRefId(detail.ref)))
|
|
1986
|
+
.map((detail) => detail.resource)
|
|
1987
|
+
.filter(Boolean)
|
|
1988
|
+
const serviceTypes = new Set(serviceResources.map((service) => service?.spec?.type || 'ClusterIP'))
|
|
1989
|
+
if (entrypoints.length > 0) return 'External entrypoint'
|
|
1990
|
+
if ([...serviceTypes].some((type) => ['LoadBalancer', 'NodePort', 'ExternalName'].includes(String(type)))) return 'External service'
|
|
1991
|
+
if (services.length > 0) return 'Internal service'
|
|
1992
|
+
return 'None'
|
|
1993
|
+
}
|
|
1994
|
+
|
|
1995
|
+
interface SummaryMetricConfig {
|
|
1996
|
+
label: string
|
|
1997
|
+
value: ReactNode
|
|
1998
|
+
detail?: ReactNode
|
|
1999
|
+
icon: ComponentType<{ className?: string }>
|
|
2000
|
+
mono?: boolean
|
|
2001
|
+
}
|
|
2002
|
+
|
|
2003
|
+
function buildSummaryMetrics(
|
|
2004
|
+
resource: any,
|
|
2005
|
+
apiKind: string,
|
|
2006
|
+
resourceKind: string,
|
|
2007
|
+
readiness: ReadinessSummary,
|
|
2008
|
+
podSummary: ReturnType<typeof getPodSummary>,
|
|
2009
|
+
scaling: ScalingSummary,
|
|
2010
|
+
): SummaryMetricConfig[] {
|
|
2011
|
+
const spec = resource?.spec || {}
|
|
2012
|
+
const status = resource?.status || {}
|
|
2013
|
+
const k = apiKind.toLowerCase()
|
|
2014
|
+
|
|
2015
|
+
if (k === 'jobs') {
|
|
2016
|
+
const duration = formatJobDuration(resource)
|
|
2017
|
+
return [
|
|
2018
|
+
{
|
|
2019
|
+
label: 'State',
|
|
2020
|
+
value: <span className={clsx('badge', readiness.healthBadge)}>{readiness.healthLabel}</span>,
|
|
2021
|
+
detail: resourceKind,
|
|
2022
|
+
icon: readiness.hasIssue ? AlertTriangle : Activity,
|
|
2023
|
+
},
|
|
2024
|
+
{
|
|
2025
|
+
label: 'Progress',
|
|
2026
|
+
value: readiness.readyLabel,
|
|
2027
|
+
detail: readiness.detail,
|
|
2028
|
+
icon: CheckCircle2,
|
|
2029
|
+
},
|
|
2030
|
+
{
|
|
2031
|
+
label: 'Duration',
|
|
2032
|
+
value: duration || '-',
|
|
2033
|
+
detail: status.startTime ? (status.completionTime ? 'finished run' : 'current run') : 'not started',
|
|
2034
|
+
icon: Clock3,
|
|
2035
|
+
},
|
|
2036
|
+
{
|
|
2037
|
+
label: 'Pods',
|
|
2038
|
+
value: podSummary.label,
|
|
2039
|
+
detail: podSummary.detail,
|
|
2040
|
+
icon: Boxes,
|
|
2041
|
+
},
|
|
2042
|
+
]
|
|
2043
|
+
}
|
|
2044
|
+
|
|
2045
|
+
if (k === 'cronjobs') {
|
|
2046
|
+
const active = Array.isArray(status.active) ? status.active.length : 0
|
|
2047
|
+
return [
|
|
2048
|
+
{
|
|
2049
|
+
label: 'State',
|
|
2050
|
+
value: <span className={clsx('badge', readiness.healthBadge)}>{readiness.healthLabel}</span>,
|
|
2051
|
+
detail: spec.suspend ? 'schedule suspended' : active > 0 ? 'run in progress' : 'waiting for next run',
|
|
2052
|
+
icon: readiness.hasIssue ? AlertTriangle : Clock3,
|
|
2053
|
+
},
|
|
2054
|
+
{
|
|
2055
|
+
label: 'Schedule',
|
|
2056
|
+
value: spec.schedule || '-',
|
|
2057
|
+
detail: spec.schedule ? cronToHuman(spec.schedule) : 'cron unavailable',
|
|
2058
|
+
icon: Clock3,
|
|
2059
|
+
mono: Boolean(spec.schedule),
|
|
2060
|
+
},
|
|
2061
|
+
{
|
|
2062
|
+
label: 'Latest run',
|
|
2063
|
+
value: status.lastScheduleTime ? formatAge(status.lastScheduleTime) : 'Never',
|
|
2064
|
+
detail: status.lastSuccessfulTime ? `last success ${formatAge(status.lastSuccessfulTime)}` : 'no successful run recorded',
|
|
2065
|
+
icon: Activity,
|
|
2066
|
+
},
|
|
2067
|
+
{
|
|
2068
|
+
label: 'Active jobs',
|
|
2069
|
+
value: active,
|
|
2070
|
+
detail: active > 0 ? 'currently running' : 'none running',
|
|
2071
|
+
icon: Boxes,
|
|
2072
|
+
},
|
|
2073
|
+
]
|
|
2074
|
+
}
|
|
2075
|
+
|
|
2076
|
+
const control = runtimeControlSurface(apiKind)
|
|
2077
|
+
return [
|
|
2078
|
+
{
|
|
2079
|
+
label: 'State',
|
|
2080
|
+
value: <span className={clsx('badge', readiness.healthBadge)}>{readiness.healthLabel}</span>,
|
|
2081
|
+
detail: resourceKind,
|
|
2082
|
+
icon: readiness.hasIssue ? AlertTriangle : CheckCircle2,
|
|
2083
|
+
},
|
|
2084
|
+
{
|
|
2085
|
+
label: 'Readiness',
|
|
2086
|
+
value: readiness.readyLabel,
|
|
2087
|
+
detail: readiness.detail,
|
|
2088
|
+
icon: Server,
|
|
2089
|
+
},
|
|
2090
|
+
{
|
|
2091
|
+
label: 'Pods',
|
|
2092
|
+
value: podSummary.label,
|
|
2093
|
+
detail: podSummary.detail,
|
|
2094
|
+
icon: Boxes,
|
|
2095
|
+
},
|
|
2096
|
+
{
|
|
2097
|
+
label: control.metricLabel,
|
|
2098
|
+
value: scaling.label,
|
|
2099
|
+
detail: scaling.detail,
|
|
2100
|
+
icon: control.icon,
|
|
2101
|
+
},
|
|
2102
|
+
]
|
|
2103
|
+
}
|
|
2104
|
+
|
|
2105
|
+
interface ReadinessSummary {
|
|
2106
|
+
readyLabel: string
|
|
2107
|
+
detail: string
|
|
2108
|
+
healthLabel: string
|
|
2109
|
+
healthBadge: string
|
|
2110
|
+
hasIssue: boolean
|
|
2111
|
+
stats: Array<{ label: string; value: ReactNode; tone?: 'default' | 'good' | 'warn' }>
|
|
2112
|
+
}
|
|
2113
|
+
|
|
2114
|
+
function getReadinessSummary(resource: any, apiKind: string): ReadinessSummary {
|
|
2115
|
+
const status = resource?.status || {}
|
|
2116
|
+
const spec = resource?.spec || {}
|
|
2117
|
+
const k = apiKind.toLowerCase()
|
|
2118
|
+
|
|
2119
|
+
if (k === 'daemonsets') {
|
|
2120
|
+
const desired = status.desiredNumberScheduled ?? 0
|
|
2121
|
+
const ready = status.numberReady ?? 0
|
|
2122
|
+
const updated = status.updatedNumberScheduled ?? 0
|
|
2123
|
+
const available = status.numberAvailable ?? ready
|
|
2124
|
+
const unavailable = status.numberUnavailable ?? Math.max(0, desired - available)
|
|
2125
|
+
const noTargets = desired === 0
|
|
2126
|
+
const healthy = !noTargets && ready === desired && unavailable === 0
|
|
2127
|
+
return {
|
|
2128
|
+
readyLabel: `${ready}/${desired}`,
|
|
2129
|
+
detail: `${updated}/${desired} up-to-date`,
|
|
2130
|
+
healthLabel: noTargets ? 'No targets' : healthy ? 'Healthy' : 'Degraded',
|
|
2131
|
+
healthBadge: noTargets ? 'bg-theme-elevated text-theme-text-secondary' : healthy ? 'status-healthy' : 'status-degraded',
|
|
2132
|
+
hasIssue: !healthy && !noTargets,
|
|
2133
|
+
stats: [
|
|
2134
|
+
{ label: 'Desired', value: desired },
|
|
2135
|
+
{ label: 'Ready', value: ready, tone: ready === desired ? 'good' : 'warn' },
|
|
2136
|
+
{ label: 'Available', value: available },
|
|
2137
|
+
{ label: 'Unavailable', value: unavailable, tone: unavailable > 0 ? 'warn' : 'default' },
|
|
2138
|
+
],
|
|
2139
|
+
}
|
|
2140
|
+
}
|
|
2141
|
+
|
|
2142
|
+
if (k === 'jobs') {
|
|
2143
|
+
const desired = spec.completions ?? 1
|
|
2144
|
+
const succeeded = status.succeeded ?? 0
|
|
2145
|
+
const active = status.active ?? 0
|
|
2146
|
+
const failed = status.failed ?? 0
|
|
2147
|
+
const phase = jobPhase(resource)
|
|
2148
|
+
const complete = phase === 'Complete'
|
|
2149
|
+
return {
|
|
2150
|
+
readyLabel: `${succeeded}/${desired}`,
|
|
2151
|
+
detail: complete ? 'completed successfully' : phase === 'Failed' ? jobConditionMessage(resource) || `${failed} failed` : active > 0 ? `${active} active` : 'waiting for pods',
|
|
2152
|
+
healthLabel: phase,
|
|
2153
|
+
healthBadge: phase === 'Failed' ? 'status-unhealthy' : phase === 'Pending' ? 'status-degraded' : complete ? 'status-healthy' : 'status-neutral',
|
|
2154
|
+
hasIssue: phase === 'Failed',
|
|
2155
|
+
stats: [
|
|
2156
|
+
{ label: 'Completions', value: `${succeeded}/${desired}`, tone: complete ? 'good' : 'default' },
|
|
2157
|
+
{ label: 'Active', value: active },
|
|
2158
|
+
{ label: 'Failed attempts', value: failed, tone: phase === 'Failed' ? 'warn' : 'default' },
|
|
2159
|
+
{ label: 'Parallelism', value: spec.parallelism ?? '-' },
|
|
2160
|
+
],
|
|
2161
|
+
}
|
|
2162
|
+
}
|
|
2163
|
+
|
|
2164
|
+
if (k === 'cronjobs') {
|
|
2165
|
+
const active = Array.isArray(status.active) ? status.active.length : 0
|
|
2166
|
+
const suspended = !!spec.suspend
|
|
2167
|
+
const lastSchedule = status.lastScheduleTime ? Date.parse(status.lastScheduleTime) : 0
|
|
2168
|
+
const lastSuccess = status.lastSuccessfulTime ? Date.parse(status.lastSuccessfulTime) : 0
|
|
2169
|
+
const missingRecentSuccess = !suspended && active === 0 && lastSchedule > 0 && lastSchedule > lastSuccess
|
|
2170
|
+
return {
|
|
2171
|
+
readyLabel: active > 0 ? `${active} active` : 'Idle',
|
|
2172
|
+
detail: spec.schedule ? `schedule ${spec.schedule}` : 'scheduled job',
|
|
2173
|
+
healthLabel: missingRecentSuccess ? 'No recent success' : suspended ? 'Suspended' : active > 0 ? 'Active' : 'Scheduled',
|
|
2174
|
+
healthBadge: missingRecentSuccess ? 'status-degraded' : suspended ? 'status-neutral' : active > 0 ? 'status-neutral' : 'status-healthy',
|
|
2175
|
+
hasIssue: missingRecentSuccess,
|
|
2176
|
+
stats: [
|
|
2177
|
+
{ label: 'Schedule', value: spec.schedule || '-' },
|
|
2178
|
+
{ label: 'Active jobs', value: active, tone: active > 0 ? 'warn' : 'default' },
|
|
2179
|
+
{ label: 'Suspend', value: suspended ? 'Yes' : 'No', tone: suspended ? 'warn' : 'default' },
|
|
2180
|
+
{ label: 'Last schedule', value: status.lastScheduleTime ? formatAge(status.lastScheduleTime) : '-' },
|
|
2181
|
+
{ label: 'Last success', value: status.lastSuccessfulTime ? formatAge(status.lastSuccessfulTime) : '-' },
|
|
2182
|
+
],
|
|
2183
|
+
}
|
|
2184
|
+
}
|
|
2185
|
+
|
|
2186
|
+
const desired = spec.replicas ?? status.replicas ?? 0
|
|
2187
|
+
const current = status.replicas ?? desired
|
|
2188
|
+
const ready = status.readyReplicas ?? 0
|
|
2189
|
+
const updated = status.updatedReplicas ?? 0
|
|
2190
|
+
const available = status.availableReplicas ?? ready
|
|
2191
|
+
const unavailable = status.unavailableReplicas ?? Math.max(0, desired - available)
|
|
2192
|
+
const scaledToZero = desired === 0
|
|
2193
|
+
const healthy = !scaledToZero && ready === desired && unavailable === 0
|
|
2194
|
+
return {
|
|
2195
|
+
readyLabel: `${ready}/${desired}`,
|
|
2196
|
+
detail: `${updated}/${desired} updated · ${available} available`,
|
|
2197
|
+
healthLabel: scaledToZero ? 'Scaled to zero' : healthy ? 'Healthy' : 'Degraded',
|
|
2198
|
+
healthBadge: scaledToZero ? 'bg-theme-elevated text-theme-text-secondary' : healthy ? 'status-healthy' : 'status-degraded',
|
|
2199
|
+
hasIssue: !healthy && !scaledToZero,
|
|
2200
|
+
stats: [
|
|
2201
|
+
{ label: 'Desired', value: desired },
|
|
2202
|
+
{ label: 'Current', value: current },
|
|
2203
|
+
{ label: 'Ready', value: ready, tone: ready === desired ? 'good' : 'warn' },
|
|
2204
|
+
{ label: 'Updated', value: updated },
|
|
2205
|
+
{ label: 'Available', value: available },
|
|
2206
|
+
{ label: 'Unavailable', value: unavailable, tone: unavailable > 0 ? 'warn' : 'default' },
|
|
2207
|
+
],
|
|
2208
|
+
}
|
|
2209
|
+
}
|
|
2210
|
+
|
|
2211
|
+
function getPodSummary(workloadPods: WorkloadPodInfo[] | undefined, relationships: Relationships | undefined, apiKind?: string) {
|
|
2212
|
+
const k = apiKind?.toLowerCase()
|
|
2213
|
+
if (workloadPods) {
|
|
2214
|
+
const ready = workloadPods.filter((pod) => pod.ready).length
|
|
2215
|
+
const total = workloadPods.length
|
|
2216
|
+
const notReady = total - ready
|
|
2217
|
+
return {
|
|
2218
|
+
label: `${ready}/${total}`,
|
|
2219
|
+
detail: total === 0 ? (k === 'jobs' ? 'no pods retained' : 'no pods found') : notReady === 0 ? 'all ready' : `${notReady} need attention`,
|
|
2220
|
+
ready,
|
|
2221
|
+
total,
|
|
2222
|
+
notReady,
|
|
2223
|
+
}
|
|
2224
|
+
}
|
|
2225
|
+
const count = relationships?.pods?.length ?? 0
|
|
2226
|
+
return {
|
|
2227
|
+
label: count ? String(count) : '-',
|
|
2228
|
+
detail: count ? 'status unavailable' : k === 'jobs' ? 'no pods retained' : 'no pod refs',
|
|
2229
|
+
ready: undefined,
|
|
2230
|
+
total: count || undefined,
|
|
2231
|
+
notReady: undefined,
|
|
2232
|
+
}
|
|
2233
|
+
}
|
|
2234
|
+
|
|
2235
|
+
interface ScalingSummary {
|
|
2236
|
+
label: string
|
|
2237
|
+
detail: string
|
|
2238
|
+
stats: Array<{ label: string; value: ReactNode; tone?: 'default' | 'good' | 'warn' }>
|
|
2239
|
+
}
|
|
2240
|
+
|
|
2241
|
+
function runtimeControlSurface(apiKind: string): { metricLabel: string; cardTitle: string; icon: ComponentType<{ className?: string }> } {
|
|
2242
|
+
const k = apiKind.toLowerCase()
|
|
2243
|
+
if (k === 'cronjobs') return { metricLabel: 'Schedule', cardTitle: 'Schedule', icon: Clock3 }
|
|
2244
|
+
if (k === 'jobs') return { metricLabel: 'Execution', cardTitle: 'Execution', icon: Activity }
|
|
2245
|
+
return { metricLabel: 'Scaling', cardTitle: 'Scaling', icon: Scale }
|
|
2246
|
+
}
|
|
2247
|
+
|
|
2248
|
+
function getScalingSummary(
|
|
2249
|
+
resource: any,
|
|
2250
|
+
apiKind: string,
|
|
2251
|
+
relationships: Relationships | undefined,
|
|
2252
|
+
scalerDiagnostics: ScalerDiagnosis[] | undefined,
|
|
2253
|
+
hpaDiagnosis: HPADiagnosis | undefined,
|
|
2254
|
+
): ScalingSummary {
|
|
2255
|
+
const spec = resource?.spec || {}
|
|
2256
|
+
const status = resource?.status || {}
|
|
2257
|
+
const k = apiKind.toLowerCase()
|
|
2258
|
+
const scalers = relationships?.scalers ?? []
|
|
2259
|
+
const primaryDiagnosis = scalerDiagnostics?.find((entry) => entry.diagnosis)?.diagnosis ?? hpaDiagnosis
|
|
2260
|
+
|
|
2261
|
+
if (k === 'daemonsets') {
|
|
2262
|
+
return {
|
|
2263
|
+
label: 'Node scheduled',
|
|
2264
|
+
detail: `${status.desiredNumberScheduled ?? 0} desired nodes`,
|
|
2265
|
+
stats: [
|
|
2266
|
+
{ label: 'Controller', value: 'DaemonSet' },
|
|
2267
|
+
{ label: 'Desired nodes', value: status.desiredNumberScheduled ?? '-' },
|
|
2268
|
+
{ label: 'Misscheduled', value: status.numberMisscheduled ?? 0, tone: (status.numberMisscheduled ?? 0) > 0 ? 'warn' : 'default' },
|
|
2269
|
+
],
|
|
2270
|
+
}
|
|
2271
|
+
}
|
|
2272
|
+
|
|
2273
|
+
if (k === 'jobs') {
|
|
2274
|
+
const phase = jobPhase(resource)
|
|
2275
|
+
return {
|
|
2276
|
+
label: phase,
|
|
2277
|
+
detail: '',
|
|
2278
|
+
stats: [
|
|
2279
|
+
{ label: 'Started', value: status.startTime ? formatAge(status.startTime) : '-' },
|
|
2280
|
+
{ label: 'Finished', value: status.completionTime ? formatAge(status.completionTime) : phase === 'Running' ? 'Running' : '-' },
|
|
2281
|
+
{ label: 'Parallelism', value: spec.parallelism ?? 1 },
|
|
2282
|
+
{ label: 'Completions', value: spec.completions ?? 1 },
|
|
2283
|
+
{ label: 'Completion mode', value: spec.completionMode ?? 'NonIndexed' },
|
|
2284
|
+
{ label: 'Backoff limit', value: spec.backoffLimit ?? 6 },
|
|
2285
|
+
{ label: 'TTL after finish', value: spec.ttlSecondsAfterFinished != null ? `${spec.ttlSecondsAfterFinished}s` : 'None' },
|
|
2286
|
+
],
|
|
2287
|
+
}
|
|
2288
|
+
}
|
|
2289
|
+
|
|
2290
|
+
if (k === 'cronjobs') {
|
|
2291
|
+
return {
|
|
2292
|
+
label: spec.suspend ? 'Suspended' : 'Scheduled',
|
|
2293
|
+
detail: '',
|
|
2294
|
+
stats: [
|
|
2295
|
+
{ label: 'Schedule', value: spec.schedule ?? '-' },
|
|
2296
|
+
{ label: 'Human', value: spec.schedule ? cronToHuman(spec.schedule) : '-' },
|
|
2297
|
+
{ label: 'Concurrency', value: spec.concurrencyPolicy ?? 'Allow' },
|
|
2298
|
+
{ label: 'Starting deadline', value: spec.startingDeadlineSeconds ? `${spec.startingDeadlineSeconds}s` : 'None' },
|
|
2299
|
+
{ label: 'Success history', value: spec.successfulJobsHistoryLimit ?? 3 },
|
|
2300
|
+
{ label: 'Failed history', value: spec.failedJobsHistoryLimit ?? 1 },
|
|
2301
|
+
{ label: 'Suspend', value: spec.suspend ? 'Yes' : 'No', tone: spec.suspend ? 'warn' : 'default' },
|
|
2302
|
+
],
|
|
2303
|
+
}
|
|
2304
|
+
}
|
|
2305
|
+
|
|
2306
|
+
if (scalers.length > 0 || primaryDiagnosis) {
|
|
2307
|
+
return {
|
|
2308
|
+
label: scalers.length > 0 ? `${scalers.length} controller${scalers.length === 1 ? '' : 's'}` : hpaStateLabel(primaryDiagnosis!.state),
|
|
2309
|
+
detail: primaryDiagnosis ? hpaStateLabel(primaryDiagnosis.state) : 'replicas controlled externally',
|
|
2310
|
+
stats: [
|
|
2311
|
+
{ label: 'Desired', value: primaryDiagnosis?.bounds?.desired ?? spec.replicas ?? '-' },
|
|
2312
|
+
{ label: 'Current', value: primaryDiagnosis?.bounds?.current ?? status.replicas ?? '-' },
|
|
2313
|
+
{ label: 'Min', value: primaryDiagnosis?.bounds?.min ?? '-' },
|
|
2314
|
+
{ label: 'Max', value: primaryDiagnosis?.bounds?.max ?? '-' },
|
|
2315
|
+
],
|
|
2316
|
+
}
|
|
2317
|
+
}
|
|
2318
|
+
|
|
2319
|
+
return {
|
|
2320
|
+
label: spec.replicas !== undefined ? `${spec.replicas} replicas` : 'Manual',
|
|
2321
|
+
detail: 'replicas from workload spec',
|
|
2322
|
+
stats: [
|
|
2323
|
+
{ label: 'Desired', value: spec.replicas ?? '-' },
|
|
2324
|
+
{ label: 'Current', value: status.replicas ?? '-' },
|
|
2325
|
+
{ label: 'Strategy', value: spec.strategy?.type || spec.updateStrategy?.type || '-' },
|
|
2326
|
+
],
|
|
2327
|
+
}
|
|
2328
|
+
}
|
|
2329
|
+
|
|
2330
|
+
function CurrentStateCard({ resource, apiKind, readiness }: { resource: any; apiKind: string; readiness: ReadinessSummary }) {
|
|
2331
|
+
const metadata = resource?.metadata || {}
|
|
2332
|
+
const templateSpec = getPodTemplateSpec(resource, apiKind)
|
|
2333
|
+
const containers = templateSpec?.containers ?? []
|
|
2334
|
+
const image = containers[0]?.image
|
|
2335
|
+
const k = apiKind.toLowerCase()
|
|
2336
|
+
const title = k === 'jobs' ? 'Execution state' : k === 'cronjobs' ? 'Schedule state' : 'Current state'
|
|
2337
|
+
const generation = metadata.generation
|
|
2338
|
+
const observedGeneration = resource?.status?.observedGeneration
|
|
2339
|
+
const controllerBehind =
|
|
2340
|
+
typeof generation === 'number' &&
|
|
2341
|
+
typeof observedGeneration === 'number' &&
|
|
2342
|
+
observedGeneration < generation
|
|
2343
|
+
|
|
2344
|
+
return (
|
|
2345
|
+
<OverviewCard title={title} icon={k === 'jobs' ? Activity : k === 'cronjobs' ? Clock3 : Server}>
|
|
2346
|
+
<div className="grid gap-x-8 gap-y-3 sm:grid-cols-2 lg:grid-cols-3">
|
|
2347
|
+
{readiness.stats.map((stat) => (
|
|
2348
|
+
<OverviewStat key={String(stat.label)} label={stat.label} value={stat.value} tone={stat.tone} />
|
|
2349
|
+
))}
|
|
2350
|
+
{controllerBehind && (
|
|
2351
|
+
<>
|
|
2352
|
+
<OverviewStat label="Spec generation" value={generation} tone="warn" />
|
|
2353
|
+
<OverviewStat label="Controller seen" value={observedGeneration} tone="warn" />
|
|
2354
|
+
</>
|
|
2355
|
+
)}
|
|
2356
|
+
<OverviewStat label="Age" value={metadata.creationTimestamp ? formatAge(metadata.creationTimestamp) : '-'} />
|
|
2357
|
+
</div>
|
|
2358
|
+
<div className="mt-4 grid gap-2 border-t border-theme-border pt-3 text-sm sm:grid-cols-2">
|
|
2359
|
+
<InlineFact label="Kind" value={displayKindName(apiKind, resource?.kind)} />
|
|
2360
|
+
{templateSpec && <InlineFact label="Service account" value={templateSpec.serviceAccountName || 'default'} />}
|
|
2361
|
+
{image && <InlineFact label="Image" value={<span className="font-mono text-xs">{image}</span>} />}
|
|
2362
|
+
</div>
|
|
2363
|
+
</OverviewCard>
|
|
2364
|
+
)
|
|
2365
|
+
}
|
|
2366
|
+
|
|
2367
|
+
function getPodTemplateSpec(resource: any, apiKind: string) {
|
|
2368
|
+
const spec = resource?.spec || {}
|
|
2369
|
+
return apiKind.toLowerCase() === 'cronjobs'
|
|
2370
|
+
? spec.jobTemplate?.spec?.template?.spec
|
|
2371
|
+
: spec.template?.spec
|
|
2372
|
+
}
|
|
2373
|
+
|
|
2374
|
+
function jobPhase(job: any): 'Complete' | 'Failed' | 'Suspended' | 'Running' | 'Pending' {
|
|
2375
|
+
const status = job?.status || {}
|
|
2376
|
+
const spec = job?.spec || {}
|
|
2377
|
+
const conditions = Array.isArray(status.conditions) ? status.conditions : []
|
|
2378
|
+
if (conditions.some((condition: any) => condition.type === 'Failed' && condition.status === 'True')) return 'Failed'
|
|
2379
|
+
if (conditions.some((condition: any) => condition.type === 'Complete' && condition.status === 'True')) return 'Complete'
|
|
2380
|
+
if (spec.suspend) return 'Suspended'
|
|
2381
|
+
if ((status.active ?? 0) > 0) return 'Running'
|
|
2382
|
+
return 'Pending'
|
|
2383
|
+
}
|
|
2384
|
+
|
|
2385
|
+
function jobConditionMessage(job: any): string {
|
|
2386
|
+
const conditions = Array.isArray(job?.status?.conditions) ? job.status.conditions : []
|
|
2387
|
+
const condition = conditions.find((entry: any) => (entry.type === 'Failed' || entry.type === 'Complete') && entry.status === 'True')
|
|
2388
|
+
return condition?.message || condition?.reason || ''
|
|
2389
|
+
}
|
|
2390
|
+
|
|
2391
|
+
function formatJobDuration(job: any): string {
|
|
2392
|
+
const startTime = job?.status?.startTime
|
|
2393
|
+
if (!startTime) return ''
|
|
2394
|
+
const start = Date.parse(startTime)
|
|
2395
|
+
const endRaw = job?.status?.completionTime
|
|
2396
|
+
const end = endRaw ? Date.parse(endRaw) : Date.now()
|
|
2397
|
+
if (Number.isNaN(start) || Number.isNaN(end) || end < start) return ''
|
|
2398
|
+
return formatDuration(end - start, true)
|
|
2399
|
+
}
|
|
2400
|
+
|
|
2401
|
+
function OverviewStat({ label, value, tone = 'default' }: { label: string; value: ReactNode; tone?: 'default' | 'good' | 'warn' }) {
|
|
2402
|
+
return (
|
|
2403
|
+
<div className="min-w-0">
|
|
2404
|
+
<div className="text-[10px] font-semibold uppercase tracking-wide text-theme-text-tertiary">{label}</div>
|
|
2405
|
+
<div className={clsx(
|
|
2406
|
+
'mt-1 truncate text-sm font-semibold',
|
|
2407
|
+
tone === 'good' ? 'text-emerald-500' : tone === 'warn' ? 'text-amber-500' : 'text-theme-text-primary',
|
|
2408
|
+
)}>
|
|
2409
|
+
{value}
|
|
2410
|
+
</div>
|
|
2411
|
+
</div>
|
|
2412
|
+
)
|
|
2413
|
+
}
|
|
2414
|
+
|
|
2415
|
+
function InlineFact({ label, value }: { label: string; value: ReactNode }) {
|
|
2416
|
+
return (
|
|
2417
|
+
<div className="min-w-0">
|
|
2418
|
+
<span className="text-theme-text-tertiary">{label}</span>
|
|
2419
|
+
<span className="mx-1 text-theme-text-tertiary">·</span>
|
|
2420
|
+
<span className="break-all text-theme-text-primary">{value}</span>
|
|
2421
|
+
</div>
|
|
2422
|
+
)
|
|
2423
|
+
}
|
|
2424
|
+
|
|
2425
|
+
function RuntimeCard({
|
|
2426
|
+
resource,
|
|
2427
|
+
apiKind,
|
|
2428
|
+
state,
|
|
2429
|
+
readiness,
|
|
2430
|
+
scaling,
|
|
2431
|
+
namespace,
|
|
2432
|
+
workloadPods,
|
|
2433
|
+
workloadPodsLoading,
|
|
2434
|
+
workloadPodsError,
|
|
2435
|
+
relationshipPods,
|
|
2436
|
+
scalerDiagnostics,
|
|
2437
|
+
hpaDiagnosis,
|
|
2438
|
+
relationships,
|
|
2439
|
+
onNavigate,
|
|
2440
|
+
onSwitchToLogs,
|
|
2441
|
+
}: {
|
|
2442
|
+
resource: any
|
|
2443
|
+
apiKind: string
|
|
2444
|
+
state: ReplicatedWorkloadState
|
|
2445
|
+
readiness: ReadinessSummary
|
|
2446
|
+
scaling: ScalingSummary
|
|
2447
|
+
namespace: string
|
|
2448
|
+
workloadPods?: WorkloadPodInfo[]
|
|
2449
|
+
workloadPodsLoading?: boolean
|
|
2450
|
+
workloadPodsError?: Error | null
|
|
2451
|
+
relationshipPods?: ResourceRef[]
|
|
2452
|
+
scalerDiagnostics?: ScalerDiagnosis[]
|
|
2453
|
+
hpaDiagnosis?: HPADiagnosis
|
|
2454
|
+
relationships?: Relationships
|
|
2455
|
+
onNavigate?: NavigateToResource
|
|
2456
|
+
onSwitchToLogs?: () => void
|
|
2457
|
+
}) {
|
|
2458
|
+
const progress = rolloutProgress(resource, apiKind, state)
|
|
2459
|
+
const headline = rolloutHeadline(resource, apiKind, readiness, scaling)
|
|
2460
|
+
const facts = rolloutFacts(resource, apiKind, scaling, Boolean(progress))
|
|
2461
|
+
const scalerRefs = relationships?.scalers ?? []
|
|
2462
|
+
const navigateRef = onNavigate ? (ref: ResourceRef) => onNavigate(refToSelectedResource(ref)) : undefined
|
|
2463
|
+
|
|
2464
|
+
return (
|
|
2465
|
+
<OverviewCard
|
|
2466
|
+
title="Runtime"
|
|
2467
|
+
icon={Boxes}
|
|
2468
|
+
action={onSwitchToLogs && (
|
|
2469
|
+
<button type="button" onClick={onSwitchToLogs} className="inline-flex items-center gap-1 text-xs text-accent-text hover:underline">
|
|
2470
|
+
<Terminal className="h-3.5 w-3.5" />
|
|
2471
|
+
Logs
|
|
2472
|
+
</button>
|
|
2473
|
+
)}
|
|
2474
|
+
>
|
|
2475
|
+
<div className="space-y-4">
|
|
2476
|
+
<div className="space-y-3">
|
|
2477
|
+
<div className="flex min-w-0 items-center gap-2">
|
|
2478
|
+
<span className={clsx('h-2 w-2 shrink-0 rounded-full', rolloutStateDotClass(state.level))} />
|
|
2479
|
+
<div className="min-w-0 truncate text-sm font-medium text-theme-text-primary">{headline}</div>
|
|
2480
|
+
</div>
|
|
2481
|
+
|
|
2482
|
+
<div className="flex flex-wrap gap-1.5">
|
|
2483
|
+
{facts.map((fact) => (
|
|
2484
|
+
<RolloutFactChip key={String(fact.label)} label={fact.label} value={fact.value} tone={fact.tone} />
|
|
2485
|
+
))}
|
|
2486
|
+
</div>
|
|
2487
|
+
|
|
2488
|
+
{progress && <RolloutProgressBar progress={progress} state={state} />}
|
|
2489
|
+
|
|
2490
|
+
{scalerRefs.length > 0 ? (
|
|
2491
|
+
<div className="rounded-md border border-theme-border bg-theme-base px-3 py-2">
|
|
2492
|
+
<div className="mb-1 text-xs font-medium text-theme-text-tertiary">Controlled by</div>
|
|
2493
|
+
<div className="flex flex-wrap gap-1.5">
|
|
2494
|
+
{scalerRefs.slice(0, RELATIONSHIP_REF_LIMIT).map((ref) => (
|
|
2495
|
+
<ResourceRefBadge key={`${ref.kind}/${ref.namespace}/${ref.name}`} resourceRef={ref} onClick={navigateRef} wrapAtSeparator />
|
|
2496
|
+
))}
|
|
2497
|
+
</div>
|
|
2498
|
+
{scalerDiagnostics?.map((entry) => (
|
|
2499
|
+
<div key={`${entry.ref.kind}/${entry.ref.namespace}/${entry.ref.name}`} className="mt-2 text-xs text-theme-text-secondary">
|
|
2500
|
+
{entry.loading ? 'Loading autoscaler diagnosis...' : entry.error ? 'Autoscaler diagnosis unavailable' : entry.diagnosis ? hpaStateLabel(entry.diagnosis.state) : 'Autoscaler status unavailable'}
|
|
2501
|
+
{entry.diagnosis?.summary && <span className="ml-1 text-theme-text-tertiary">{entry.diagnosis.summary}</span>}
|
|
2502
|
+
</div>
|
|
2503
|
+
))}
|
|
2504
|
+
</div>
|
|
2505
|
+
) : hpaDiagnosis ? (
|
|
2506
|
+
<div className="rounded-md border border-theme-border bg-theme-base px-3 py-2 text-xs text-theme-text-secondary">
|
|
2507
|
+
{hpaStateLabel(hpaDiagnosis.state)} <span className="text-theme-text-tertiary">{hpaDiagnosis.summary}</span>
|
|
2508
|
+
</div>
|
|
2509
|
+
) : null}
|
|
2510
|
+
</div>
|
|
2511
|
+
|
|
2512
|
+
<div className="border-t border-theme-border pt-3">
|
|
2513
|
+
<div className="mb-2 text-xs font-medium text-theme-text-tertiary">Pods</div>
|
|
2514
|
+
<PodsListContent
|
|
2515
|
+
namespace={namespace}
|
|
2516
|
+
workloadPods={workloadPods}
|
|
2517
|
+
workloadPodsLoading={workloadPodsLoading}
|
|
2518
|
+
workloadPodsError={workloadPodsError}
|
|
2519
|
+
relationshipPods={relationshipPods}
|
|
2520
|
+
onNavigate={onNavigate}
|
|
2521
|
+
/>
|
|
2522
|
+
</div>
|
|
2523
|
+
</div>
|
|
2524
|
+
</OverviewCard>
|
|
2525
|
+
)
|
|
2526
|
+
}
|
|
2527
|
+
|
|
2528
|
+
function PodsAttentionCard({
|
|
2529
|
+
namespace,
|
|
2530
|
+
emptyText,
|
|
2531
|
+
workloadPods,
|
|
2532
|
+
workloadPodsLoading,
|
|
2533
|
+
workloadPodsError,
|
|
2534
|
+
relationshipPods,
|
|
2535
|
+
onNavigate,
|
|
2536
|
+
onSwitchToLogs,
|
|
2537
|
+
}: {
|
|
2538
|
+
namespace: string
|
|
2539
|
+
emptyText?: string
|
|
2540
|
+
workloadPods?: WorkloadPodInfo[]
|
|
2541
|
+
workloadPodsLoading?: boolean
|
|
2542
|
+
workloadPodsError?: Error | null
|
|
2543
|
+
relationshipPods?: ResourceRef[]
|
|
2544
|
+
onNavigate?: NavigateToResource
|
|
2545
|
+
onSwitchToLogs?: () => void
|
|
2546
|
+
}) {
|
|
2547
|
+
return (
|
|
2548
|
+
<OverviewCard
|
|
2549
|
+
title="Pods"
|
|
2550
|
+
icon={Boxes}
|
|
2551
|
+
action={onSwitchToLogs && (
|
|
2552
|
+
<button type="button" onClick={onSwitchToLogs} className="inline-flex items-center gap-1 text-xs text-accent-text hover:underline">
|
|
2553
|
+
<Terminal className="h-3.5 w-3.5" />
|
|
2554
|
+
Logs
|
|
2555
|
+
</button>
|
|
2556
|
+
)}
|
|
2557
|
+
>
|
|
2558
|
+
<PodsListContent
|
|
2559
|
+
namespace={namespace}
|
|
2560
|
+
emptyText={emptyText}
|
|
2561
|
+
workloadPods={workloadPods}
|
|
2562
|
+
workloadPodsLoading={workloadPodsLoading}
|
|
2563
|
+
workloadPodsError={workloadPodsError}
|
|
2564
|
+
relationshipPods={relationshipPods}
|
|
2565
|
+
onNavigate={onNavigate}
|
|
2566
|
+
/>
|
|
2567
|
+
</OverviewCard>
|
|
2568
|
+
)
|
|
2569
|
+
}
|
|
2570
|
+
|
|
2571
|
+
function PodsListContent({
|
|
2572
|
+
namespace,
|
|
2573
|
+
emptyText,
|
|
2574
|
+
workloadPods,
|
|
2575
|
+
workloadPodsLoading,
|
|
2576
|
+
workloadPodsError,
|
|
2577
|
+
relationshipPods,
|
|
2578
|
+
onNavigate,
|
|
2579
|
+
}: {
|
|
2580
|
+
namespace: string
|
|
2581
|
+
emptyText?: string
|
|
2582
|
+
workloadPods?: WorkloadPodInfo[]
|
|
2583
|
+
workloadPodsLoading?: boolean
|
|
2584
|
+
workloadPodsError?: Error | null
|
|
2585
|
+
relationshipPods?: ResourceRef[]
|
|
2586
|
+
onNavigate?: NavigateToResource
|
|
2587
|
+
}) {
|
|
2588
|
+
const sortedPods = [...(workloadPods ?? [])].sort(compareWorkloadPods)
|
|
2589
|
+
const [expandedPods, setExpandedPods] = useState(false)
|
|
2590
|
+
const hasPodOverflow = sortedPods.length > POD_VISIBLE_LIMIT
|
|
2591
|
+
const visiblePods = sortedPods.slice(0, POD_VISIBLE_LIMIT)
|
|
2592
|
+
const overflowPods = sortedPods.slice(POD_VISIBLE_LIMIT)
|
|
2593
|
+
const hiddenPodCount = Math.max(0, sortedPods.length - POD_VISIBLE_LIMIT)
|
|
2594
|
+
const fallbackPodRefs = relationshipPods ?? []
|
|
2595
|
+
const hasFallbackOverflow = fallbackPodRefs.length > POD_VISIBLE_LIMIT
|
|
2596
|
+
const fallbackPods = fallbackPodRefs.slice(0, POD_VISIBLE_LIMIT)
|
|
2597
|
+
const overflowFallbackPods = fallbackPodRefs.slice(POD_VISIBLE_LIMIT)
|
|
2598
|
+
const hiddenFallbackCount = Math.max(0, fallbackPodRefs.length - POD_VISIBLE_LIMIT)
|
|
2599
|
+
|
|
2600
|
+
return (
|
|
2601
|
+
<>
|
|
2602
|
+
{workloadPodsLoading ? (
|
|
2603
|
+
<div className="text-sm text-theme-text-tertiary">Loading pod readiness…</div>
|
|
2604
|
+
) : workloadPodsError ? (
|
|
2605
|
+
<div className="rounded-md border border-amber-500/30 bg-amber-500/10 p-3 text-sm text-amber-600 dark:text-amber-300">
|
|
2606
|
+
Pod readiness unavailable. Use Logs or Timeline for deeper inspection.
|
|
2607
|
+
</div>
|
|
2608
|
+
) : workloadPods ? (
|
|
2609
|
+
visiblePods.length > 0 ? (
|
|
2610
|
+
<PodListFrame
|
|
2611
|
+
expanded={expandedPods}
|
|
2612
|
+
hasOverflow={hasPodOverflow}
|
|
2613
|
+
overflow={overflowPods.map((pod) => (
|
|
2614
|
+
<PodRow
|
|
2615
|
+
key={pod.name}
|
|
2616
|
+
name={pod.name}
|
|
2617
|
+
namespace={namespace}
|
|
2618
|
+
ready={pod.ready}
|
|
2619
|
+
healthLevel={pod.healthLevel}
|
|
2620
|
+
detail={workloadPodDetail(pod)}
|
|
2621
|
+
onNavigate={onNavigate}
|
|
2622
|
+
/>
|
|
2623
|
+
))}
|
|
2624
|
+
toggle={(
|
|
2625
|
+
<PodListToggle
|
|
2626
|
+
expanded={expandedPods}
|
|
2627
|
+
hiddenCount={hiddenPodCount}
|
|
2628
|
+
label="pods"
|
|
2629
|
+
onToggle={() => setExpandedPods((value) => !value)}
|
|
2630
|
+
/>
|
|
2631
|
+
)}
|
|
2632
|
+
>
|
|
2633
|
+
{visiblePods.map((pod) => (
|
|
2634
|
+
<PodRow
|
|
2635
|
+
key={pod.name}
|
|
2636
|
+
name={pod.name}
|
|
2637
|
+
namespace={namespace}
|
|
2638
|
+
ready={pod.ready}
|
|
2639
|
+
healthLevel={pod.healthLevel}
|
|
2640
|
+
detail={workloadPodDetail(pod)}
|
|
2641
|
+
onNavigate={onNavigate}
|
|
2642
|
+
/>
|
|
2643
|
+
))}
|
|
2644
|
+
</PodListFrame>
|
|
2645
|
+
) : (
|
|
2646
|
+
<div className="text-sm text-theme-text-tertiary">{emptyText || 'No pods currently match this workload.'}</div>
|
|
2647
|
+
)
|
|
2648
|
+
) : fallbackPods.length > 0 ? (
|
|
2649
|
+
<PodListFrame
|
|
2650
|
+
expanded={expandedPods}
|
|
2651
|
+
hasOverflow={hasFallbackOverflow}
|
|
2652
|
+
overflow={overflowFallbackPods.map((pod) => (
|
|
2653
|
+
<PodRow
|
|
2654
|
+
key={`${pod.namespace}/${pod.name}`}
|
|
2655
|
+
name={pod.name}
|
|
2656
|
+
namespace={pod.namespace || namespace}
|
|
2657
|
+
ready={null}
|
|
2658
|
+
detail="status unavailable"
|
|
2659
|
+
onNavigate={onNavigate}
|
|
2660
|
+
/>
|
|
2661
|
+
))}
|
|
2662
|
+
toggle={(
|
|
2663
|
+
<PodListToggle
|
|
2664
|
+
expanded={expandedPods}
|
|
2665
|
+
hiddenCount={hiddenFallbackCount}
|
|
2666
|
+
label="pods"
|
|
2667
|
+
onToggle={() => setExpandedPods((value) => !value)}
|
|
2668
|
+
/>
|
|
2669
|
+
)}
|
|
2670
|
+
>
|
|
2671
|
+
{fallbackPods.map((pod) => (
|
|
2672
|
+
<PodRow
|
|
2673
|
+
key={`${pod.namespace}/${pod.name}`}
|
|
2674
|
+
name={pod.name}
|
|
2675
|
+
namespace={pod.namespace || namespace}
|
|
2676
|
+
ready={null}
|
|
2677
|
+
detail="status unavailable"
|
|
2678
|
+
onNavigate={onNavigate}
|
|
2679
|
+
/>
|
|
2680
|
+
))}
|
|
2681
|
+
</PodListFrame>
|
|
2682
|
+
) : (
|
|
2683
|
+
<div className="text-sm text-theme-text-tertiary">{emptyText || 'No pod relationships found.'}</div>
|
|
2684
|
+
)}
|
|
2685
|
+
</>
|
|
2686
|
+
)
|
|
2687
|
+
}
|
|
2688
|
+
|
|
2689
|
+
function PodRow({
|
|
2690
|
+
name,
|
|
2691
|
+
namespace,
|
|
2692
|
+
ready,
|
|
2693
|
+
healthLevel,
|
|
2694
|
+
detail,
|
|
2695
|
+
onNavigate,
|
|
2696
|
+
}: {
|
|
2697
|
+
name: string
|
|
2698
|
+
namespace: string
|
|
2699
|
+
ready: boolean | null
|
|
2700
|
+
healthLevel?: WorkloadPodInfo['healthLevel']
|
|
2701
|
+
detail: string
|
|
2702
|
+
onNavigate?: NavigateToResource
|
|
2703
|
+
}) {
|
|
2704
|
+
const statusLabel = podStatusLabel(healthLevel, ready)
|
|
2705
|
+
const statusClass = podStatusClass(healthLevel, ready)
|
|
2706
|
+
const content = (
|
|
2707
|
+
<>
|
|
2708
|
+
<span className={clsx('h-2 w-2 shrink-0 rounded-full', podDotClass(healthLevel, ready))} />
|
|
2709
|
+
<span className="min-w-0 flex-1">
|
|
2710
|
+
<span className="block truncate text-sm font-medium text-theme-text-primary">{name}</span>
|
|
2711
|
+
<span className="block truncate text-xs text-theme-text-tertiary">{detail}</span>
|
|
2712
|
+
</span>
|
|
2713
|
+
<span className={clsx('badge-sm shrink-0', statusClass)}>
|
|
2714
|
+
{statusLabel}
|
|
2715
|
+
</span>
|
|
2716
|
+
</>
|
|
2717
|
+
)
|
|
2718
|
+
|
|
2719
|
+
if (!onNavigate) {
|
|
2720
|
+
return (
|
|
2721
|
+
<div className="flex w-full min-w-0 items-center gap-3 rounded-md border border-theme-border bg-theme-base px-3 py-2 text-left">
|
|
2722
|
+
{content}
|
|
2723
|
+
</div>
|
|
2724
|
+
)
|
|
2725
|
+
}
|
|
2726
|
+
|
|
2727
|
+
return (
|
|
2728
|
+
<button
|
|
2729
|
+
type="button"
|
|
2730
|
+
onClick={() => onNavigate({ kind: 'pods', namespace, name })}
|
|
2731
|
+
className="flex w-full min-w-0 items-center gap-3 rounded-md border border-theme-border bg-theme-base px-3 py-2 text-left transition-colors hover:bg-theme-hover"
|
|
2732
|
+
>
|
|
2733
|
+
{content}
|
|
2734
|
+
</button>
|
|
2735
|
+
)
|
|
2736
|
+
}
|
|
2737
|
+
|
|
2738
|
+
function PodListFrame({
|
|
2739
|
+
expanded,
|
|
2740
|
+
hasOverflow,
|
|
2741
|
+
overflow,
|
|
2742
|
+
toggle,
|
|
2743
|
+
children,
|
|
2744
|
+
}: {
|
|
2745
|
+
expanded: boolean
|
|
2746
|
+
hasOverflow: boolean
|
|
2747
|
+
overflow?: ReactNode
|
|
2748
|
+
toggle?: ReactNode
|
|
2749
|
+
children: ReactNode
|
|
2750
|
+
}) {
|
|
2751
|
+
return (
|
|
2752
|
+
<div className="space-y-2">
|
|
2753
|
+
<div className="space-y-2">
|
|
2754
|
+
{children}
|
|
2755
|
+
</div>
|
|
2756
|
+
{hasOverflow && (
|
|
2757
|
+
<div
|
|
2758
|
+
className={clsx(
|
|
2759
|
+
'grid transition-[grid-template-rows,opacity] duration-200',
|
|
2760
|
+
expanded ? 'grid-rows-[1fr] opacity-100' : 'grid-rows-[0fr] opacity-0',
|
|
2761
|
+
)}
|
|
2762
|
+
style={{ transitionTimingFunction: 'cubic-bezier(0.16, 1, 0.3, 1)' }}
|
|
2763
|
+
aria-hidden={!expanded || undefined}
|
|
2764
|
+
>
|
|
2765
|
+
<div className={clsx('min-h-0 overflow-hidden', expanded && 'max-h-[26rem] overflow-y-auto pr-1')} inert={!expanded || undefined}>
|
|
2766
|
+
<div className="space-y-2">
|
|
2767
|
+
{overflow}
|
|
2768
|
+
</div>
|
|
2769
|
+
</div>
|
|
2770
|
+
</div>
|
|
2771
|
+
)}
|
|
2772
|
+
{toggle}
|
|
2773
|
+
</div>
|
|
2774
|
+
)
|
|
2775
|
+
}
|
|
2776
|
+
|
|
2777
|
+
function PodListToggle({
|
|
2778
|
+
expanded,
|
|
2779
|
+
hiddenCount,
|
|
2780
|
+
label,
|
|
2781
|
+
onToggle,
|
|
2782
|
+
}: {
|
|
2783
|
+
expanded: boolean
|
|
2784
|
+
hiddenCount: number
|
|
2785
|
+
label: string
|
|
2786
|
+
onToggle: () => void
|
|
2787
|
+
}) {
|
|
2788
|
+
if (!expanded && hiddenCount <= 0) return null
|
|
2789
|
+
|
|
2790
|
+
return (
|
|
2791
|
+
<div className="pt-1">
|
|
2792
|
+
<button
|
|
2793
|
+
type="button"
|
|
2794
|
+
aria-expanded={expanded}
|
|
2795
|
+
onClick={onToggle}
|
|
2796
|
+
className="text-xs font-medium text-accent-text hover:underline"
|
|
2797
|
+
>
|
|
2798
|
+
{expanded ? `Collapse ${label}` : `Show ${hiddenCount} more ${label}`}
|
|
2799
|
+
</button>
|
|
2800
|
+
</div>
|
|
2801
|
+
)
|
|
2802
|
+
}
|
|
2803
|
+
|
|
2804
|
+
function compareWorkloadPods(a: WorkloadPodInfo, b: WorkloadPodInfo): number {
|
|
2805
|
+
const severity = podSeverityRank(b) - podSeverityRank(a)
|
|
2806
|
+
if (severity !== 0) return severity
|
|
2807
|
+
const restartDiff = (b.restartCount ?? 0) - (a.restartCount ?? 0)
|
|
2808
|
+
if (restartDiff !== 0) return restartDiff
|
|
2809
|
+
return a.name.localeCompare(b.name)
|
|
2810
|
+
}
|
|
2811
|
+
|
|
2812
|
+
function podSeverityRank(pod: WorkloadPodInfo): number {
|
|
2813
|
+
switch (pod.healthLevel) {
|
|
2814
|
+
case 'unhealthy': return 4
|
|
2815
|
+
case 'degraded': return 3
|
|
2816
|
+
case 'unknown': return 2
|
|
2817
|
+
case 'neutral': return pod.ready ? 0 : 1
|
|
2818
|
+
case 'healthy': return pod.ready ? 0 : 1
|
|
2819
|
+
default: return pod.ready ? 0 : 2
|
|
2820
|
+
}
|
|
2821
|
+
}
|
|
2822
|
+
|
|
2823
|
+
function workloadPodDetail(pod: WorkloadPodInfo): string {
|
|
2824
|
+
const parts: string[] = []
|
|
2825
|
+
if (pod.phase) parts.push(pod.reason ? `${pod.phase} / ${pod.reason}` : pod.phase)
|
|
2826
|
+
else if (pod.reason) parts.push(pod.reason)
|
|
2827
|
+
parts.push(`${pod.containers.length} container${pod.containers.length === 1 ? '' : 's'}`)
|
|
2828
|
+
if ((pod.restartCount ?? 0) > 0) parts.push(`${pod.restartCount} restart${pod.restartCount === 1 ? '' : 's'}`)
|
|
2829
|
+
if (pod.lastTerminationReason && !['Unknown', 'Completed'].includes(pod.lastTerminationReason)) {
|
|
2830
|
+
parts.push(`last ${pod.lastTerminationReason}`)
|
|
2831
|
+
}
|
|
2832
|
+
if (pod.createdAt) parts.push(`${formatAge(pod.createdAt)} old`)
|
|
2833
|
+
return parts.join(' · ')
|
|
2834
|
+
}
|
|
2835
|
+
|
|
2836
|
+
function podStatusLabel(healthLevel: WorkloadPodInfo['healthLevel'] | undefined, ready: boolean | null): string {
|
|
2837
|
+
if (healthLevel === 'unhealthy') return 'Unhealthy'
|
|
2838
|
+
if (healthLevel === 'degraded') return 'Degraded'
|
|
2839
|
+
if (healthLevel === 'neutral') return ready ? 'Ready' : 'Neutral'
|
|
2840
|
+
if (healthLevel === 'unknown') return 'Unknown'
|
|
2841
|
+
return ready === null ? 'Unknown' : ready ? 'Ready' : 'Not ready'
|
|
2842
|
+
}
|
|
2843
|
+
|
|
2844
|
+
function podStatusClass(healthLevel: WorkloadPodInfo['healthLevel'] | undefined, ready: boolean | null): string {
|
|
2845
|
+
if (healthLevel === 'unhealthy') return 'status-unhealthy'
|
|
2846
|
+
if (healthLevel === 'degraded') return 'status-degraded'
|
|
2847
|
+
if (healthLevel === 'neutral') return 'status-neutral'
|
|
2848
|
+
if (healthLevel === 'unknown' || ready === null) return 'status-unknown'
|
|
2849
|
+
return ready ? 'status-healthy' : 'status-degraded'
|
|
2850
|
+
}
|
|
2851
|
+
|
|
2852
|
+
function podDotClass(healthLevel: WorkloadPodInfo['healthLevel'] | undefined, ready: boolean | null): string {
|
|
2853
|
+
if (healthLevel === 'unhealthy') return 'bg-red-500'
|
|
2854
|
+
if (healthLevel === 'degraded') return 'bg-amber-500'
|
|
2855
|
+
if (healthLevel === 'neutral') return 'bg-skyhook-500'
|
|
2856
|
+
if (healthLevel === 'unknown' || ready === null) return 'bg-theme-text-tertiary'
|
|
2857
|
+
return ready ? 'bg-emerald-500' : 'bg-amber-500'
|
|
2858
|
+
}
|
|
2859
|
+
|
|
2860
|
+
function MoreRows({ count, label }: { count: number; label: string }) {
|
|
2861
|
+
return <div className="text-xs text-theme-text-tertiary">+{count} more {label} hidden from this overview.</div>
|
|
2862
|
+
}
|
|
2863
|
+
|
|
2864
|
+
type RelationshipGroup = { label: string; refs: ResourceRef[] }
|
|
2865
|
+
|
|
2866
|
+
function ServingConfigurationCard({
|
|
2867
|
+
groups,
|
|
2868
|
+
details = [],
|
|
2869
|
+
renderPortAction,
|
|
2870
|
+
renderPortPanel,
|
|
2871
|
+
onNavigate,
|
|
2872
|
+
onSwitchToTopology,
|
|
2873
|
+
}: {
|
|
2874
|
+
groups: RelationshipGroup[]
|
|
2875
|
+
details?: ServingResourceDetail[]
|
|
2876
|
+
renderPortAction?: (props: ServicePortRenderProps) => ReactNode
|
|
2877
|
+
renderPortPanel?: (props: ServicePortRenderProps) => ReactNode
|
|
2878
|
+
onNavigate?: NavigateToResource
|
|
2879
|
+
onSwitchToTopology?: () => void
|
|
2880
|
+
}) {
|
|
2881
|
+
const services = groups.find((group) => group.label === 'Services')?.refs ?? []
|
|
2882
|
+
const entrypoints = groups.find((group) => group.label === 'Entry points')?.refs ?? []
|
|
2883
|
+
const detailByRef = new Map(details.map((detail) => [resourceRefId(detail.ref), detail]))
|
|
2884
|
+
const serviceDetails = services.map((ref) => detailByRef.get(resourceRefId(ref))).filter(Boolean) as ServingResourceDetail[]
|
|
2885
|
+
const entrypointDetails = entrypoints.map((ref) => detailByRef.get(resourceRefId(ref))).filter(Boolean) as ServingResourceDetail[]
|
|
2886
|
+
const serviceResources = serviceDetails.map((detail) => detail.resource).filter(Boolean)
|
|
2887
|
+
const entrypointResources = entrypointDetails.map((detail) => detail.resource).filter(Boolean)
|
|
2888
|
+
const serviceDetailsLoading = serviceDetails.some((detail) => detail.loading)
|
|
2889
|
+
const serviceCount = services.length
|
|
2890
|
+
const entrypointCount = entrypoints.length
|
|
2891
|
+
const serviceTypes = summarizeServiceTypes(serviceResources, serviceCount, serviceDetailsLoading)
|
|
2892
|
+
const externalLabels = summarizeExternalLabels(serviceResources, entrypointResources)
|
|
2893
|
+
const exposure = summarizeServingExposure(serviceResources, serviceCount, entrypointCount, externalLabels, serviceDetailsLoading)
|
|
2894
|
+
const pathDetail =
|
|
2895
|
+
entrypointCount > 0 && serviceCount > 0
|
|
2896
|
+
? `${entrypointCount} entry point${entrypointCount === 1 ? '' : 's'} route to ${serviceCount} service${serviceCount === 1 ? '' : 's'}.`
|
|
2897
|
+
: entrypointCount > 0
|
|
2898
|
+
? `${entrypointCount} entry point${entrypointCount === 1 ? '' : 's'} detected. Use Topology to inspect the serving graph.`
|
|
2899
|
+
: `${serviceCount} service${serviceCount === 1 ? ' targets' : 's target'} this workload without an ingress or route.`
|
|
2900
|
+
const navigateRef = onNavigate ? (ref: ResourceRef) => onNavigate(refToSelectedResource(ref)) : undefined
|
|
2901
|
+
|
|
2902
|
+
return (
|
|
2903
|
+
<OverviewCard
|
|
2904
|
+
title="Serving configuration"
|
|
2905
|
+
icon={Network}
|
|
2906
|
+
action={onSwitchToTopology && (
|
|
2907
|
+
<button type="button" onClick={onSwitchToTopology} className="inline-flex items-center gap-1 text-xs text-accent-text hover:underline">
|
|
2908
|
+
<GitBranch className="h-3.5 w-3.5" />
|
|
2909
|
+
Topology
|
|
2910
|
+
</button>
|
|
2911
|
+
)}
|
|
2912
|
+
>
|
|
2913
|
+
<div className="space-y-3">
|
|
2914
|
+
<div className="flex min-w-0 flex-wrap items-center gap-2">
|
|
2915
|
+
<span className="text-base font-semibold text-theme-text-primary">{exposure.label}</span>
|
|
2916
|
+
<Badge severity={exposure.severity} size="sm">{exposure.badge}</Badge>
|
|
2917
|
+
<span className="min-w-[12rem] flex-1 text-sm text-theme-text-secondary">{pathDetail}</span>
|
|
2918
|
+
</div>
|
|
2919
|
+
<div className="grid gap-4 lg:grid-cols-[minmax(0,1.35fr)_minmax(18rem,0.8fr)]">
|
|
2920
|
+
<div className="min-w-0 space-y-3">
|
|
2921
|
+
<div className="grid gap-3 sm:grid-cols-2">
|
|
2922
|
+
<OverviewStat label="Service type" value={serviceTypes} />
|
|
2923
|
+
<OverviewStat label="Services" value={serviceCount} />
|
|
2924
|
+
</div>
|
|
2925
|
+
<ServingPortRows
|
|
2926
|
+
services={serviceResources}
|
|
2927
|
+
loading={serviceDetailsLoading}
|
|
2928
|
+
renderPortAction={renderPortAction}
|
|
2929
|
+
renderPortPanel={renderPortPanel}
|
|
2930
|
+
/>
|
|
2931
|
+
<ServingRefChips label="Services" refs={services} onNavigate={navigateRef} />
|
|
2932
|
+
</div>
|
|
2933
|
+
<div className="min-w-0 space-y-3 border-t border-theme-border pt-3 lg:border-l lg:border-t-0 lg:pl-4 lg:pt-0">
|
|
2934
|
+
<OverviewStat label="Entry points" value={entrypointCount || '-'} />
|
|
2935
|
+
<ServingFactChips label="Addresses & hosts" values={externalLabels} empty={entrypointCount > 0 ? 'Route details unavailable.' : 'Internal cluster traffic only.'} />
|
|
2936
|
+
{entrypoints.length > 0 && <ServingRefChips label="Entry point resources" refs={entrypoints} onNavigate={navigateRef} />}
|
|
2937
|
+
</div>
|
|
2938
|
+
</div>
|
|
2939
|
+
</div>
|
|
2940
|
+
</OverviewCard>
|
|
2941
|
+
)
|
|
2942
|
+
}
|
|
2943
|
+
|
|
2944
|
+
function ServingPortRows({
|
|
2945
|
+
services,
|
|
2946
|
+
loading,
|
|
2947
|
+
renderPortAction,
|
|
2948
|
+
renderPortPanel,
|
|
2949
|
+
}: {
|
|
2950
|
+
services: any[]
|
|
2951
|
+
loading?: boolean
|
|
2952
|
+
renderPortAction?: (props: ServicePortRenderProps) => ReactNode
|
|
2953
|
+
renderPortPanel?: (props: ServicePortRenderProps) => ReactNode
|
|
2954
|
+
}) {
|
|
2955
|
+
const servicesWithPorts = services.filter((service) => (service?.spec?.ports ?? []).length > 0)
|
|
2956
|
+
const visible = servicesWithPorts.slice(0, 2)
|
|
2957
|
+
const hidden = Math.max(0, servicesWithPorts.length - visible.length)
|
|
2958
|
+
return (
|
|
2959
|
+
<div className="min-w-0">
|
|
2960
|
+
<div className="mb-1 text-xs font-medium text-theme-text-tertiary">Ports</div>
|
|
2961
|
+
{visible.length > 0 ? (
|
|
2962
|
+
<div className="space-y-2">
|
|
2963
|
+
{visible.map((service) => {
|
|
2964
|
+
const serviceName = service?.metadata?.name || 'service'
|
|
2965
|
+
return (
|
|
2966
|
+
<div key={service?.metadata?.uid || `${service?.metadata?.namespace}/${serviceName}`} className="min-w-0">
|
|
2967
|
+
{servicesWithPorts.length > 1 && (
|
|
2968
|
+
<div className="mb-1 font-mono text-[11px] text-theme-text-tertiary">{serviceName}</div>
|
|
2969
|
+
)}
|
|
2970
|
+
<div className="max-w-2xl">
|
|
2971
|
+
<ServicePortCards service={service} renderPortAction={renderPortAction} renderPortPanel={renderPortPanel} />
|
|
2972
|
+
</div>
|
|
2973
|
+
</div>
|
|
2974
|
+
)
|
|
2975
|
+
})}
|
|
2976
|
+
{hidden > 0 && <Badge tone="structural" size="sm">+{hidden} services</Badge>}
|
|
2977
|
+
</div>
|
|
2978
|
+
) : (
|
|
2979
|
+
<div className="text-xs text-theme-text-tertiary">{loading ? 'Loading service ports...' : 'No service ports loaded.'}</div>
|
|
2980
|
+
)}
|
|
2981
|
+
</div>
|
|
2982
|
+
)
|
|
2983
|
+
}
|
|
2984
|
+
|
|
2985
|
+
function ServingRefChips({ label, refs, onNavigate }: { label: string; refs: ResourceRef[]; onNavigate?: (ref: ResourceRef) => void }) {
|
|
2986
|
+
const visible = refs.slice(0, RELATIONSHIP_REF_LIMIT)
|
|
2987
|
+
const hidden = Math.max(0, refs.length - visible.length)
|
|
2988
|
+
return (
|
|
2989
|
+
<div className="min-w-0">
|
|
2990
|
+
<div className="mb-1 text-xs font-medium text-theme-text-tertiary">{label}</div>
|
|
2991
|
+
<div className="flex flex-wrap gap-1.5">
|
|
2992
|
+
{visible.map((ref) => (
|
|
2993
|
+
<ResourceRefBadge key={`${ref.kind}/${ref.namespace}/${ref.name}`} resourceRef={ref} onClick={onNavigate} wrapAtSeparator />
|
|
2994
|
+
))}
|
|
2995
|
+
{hidden > 0 && <Badge tone="structural" size="sm">+{hidden}</Badge>}
|
|
2996
|
+
</div>
|
|
2997
|
+
</div>
|
|
2998
|
+
)
|
|
2999
|
+
}
|
|
3000
|
+
|
|
3001
|
+
function ServingFactChips({ label, values, empty }: { label: string; values: string[]; empty: string }) {
|
|
3002
|
+
const visible = values.slice(0, RELATIONSHIP_REF_LIMIT)
|
|
3003
|
+
const hidden = Math.max(0, values.length - visible.length)
|
|
3004
|
+
return (
|
|
3005
|
+
<div>
|
|
3006
|
+
<div className="mb-1 text-xs font-medium text-theme-text-tertiary">{label}</div>
|
|
3007
|
+
{visible.length > 0 ? (
|
|
3008
|
+
<div className="flex flex-wrap gap-1.5">
|
|
3009
|
+
{visible.map((value) => (
|
|
3010
|
+
<Badge key={value} tone="structural" size="sm" className="max-w-full whitespace-normal break-all text-left">
|
|
3011
|
+
{value}
|
|
3012
|
+
</Badge>
|
|
3013
|
+
))}
|
|
3014
|
+
{hidden > 0 && <Badge tone="structural" size="sm">+{hidden}</Badge>}
|
|
3015
|
+
</div>
|
|
3016
|
+
) : (
|
|
3017
|
+
<div className="text-xs text-theme-text-tertiary">{empty}</div>
|
|
3018
|
+
)}
|
|
3019
|
+
</div>
|
|
3020
|
+
)
|
|
3021
|
+
}
|
|
3022
|
+
|
|
3023
|
+
function CronJobRunsCard({
|
|
3024
|
+
resource,
|
|
3025
|
+
namespace,
|
|
3026
|
+
onNavigate,
|
|
3027
|
+
onSwitchToTopology,
|
|
3028
|
+
}: {
|
|
3029
|
+
resource: any
|
|
3030
|
+
namespace: string
|
|
3031
|
+
onNavigate?: NavigateToResource
|
|
3032
|
+
onSwitchToTopology?: () => void
|
|
3033
|
+
}) {
|
|
3034
|
+
const status = resource?.status || {}
|
|
3035
|
+
const spec = resource?.spec || {}
|
|
3036
|
+
const activeJobs = Array.isArray(status.active) ? status.active : []
|
|
3037
|
+
const visibleJobs = activeJobs.slice(0, POD_VISIBLE_LIMIT)
|
|
3038
|
+
const hiddenJobCount = Math.max(0, activeJobs.length - visibleJobs.length)
|
|
3039
|
+
const emptyMessage = spec.suspend
|
|
3040
|
+
? 'No active Jobs. This CronJob is suspended, so new runs will not be scheduled until it is resumed.'
|
|
3041
|
+
: 'No active Jobs. The CronJob is waiting for the next scheduled run.'
|
|
3042
|
+
|
|
3043
|
+
return (
|
|
3044
|
+
<OverviewCard
|
|
3045
|
+
title="Runs"
|
|
3046
|
+
icon={Activity}
|
|
3047
|
+
action={onSwitchToTopology && (
|
|
3048
|
+
<button type="button" onClick={onSwitchToTopology} className="inline-flex items-center gap-1 text-xs text-skyhook-500 hover:underline">
|
|
3049
|
+
<GitBranch className="h-3.5 w-3.5" />
|
|
3050
|
+
Topology
|
|
3051
|
+
</button>
|
|
3052
|
+
)}
|
|
3053
|
+
>
|
|
3054
|
+
<div className="mb-3 grid gap-x-8 gap-y-3 sm:grid-cols-2">
|
|
3055
|
+
<OverviewStat label="Active jobs" value={activeJobs.length} tone={activeJobs.length > 0 ? 'warn' : 'default'} />
|
|
3056
|
+
<OverviewStat label="Last schedule" value={status.lastScheduleTime ? formatAge(status.lastScheduleTime) : 'Never'} />
|
|
3057
|
+
<OverviewStat label="Last success" value={status.lastSuccessfulTime ? formatAge(status.lastSuccessfulTime) : 'Never'} />
|
|
3058
|
+
<OverviewStat label="Retained history" value={`${spec.successfulJobsHistoryLimit ?? 3} ok / ${spec.failedJobsHistoryLimit ?? 1} failed`} />
|
|
3059
|
+
</div>
|
|
3060
|
+
|
|
3061
|
+
{visibleJobs.length > 0 ? (
|
|
3062
|
+
<div className="space-y-2">
|
|
3063
|
+
{visibleJobs.map((job: any) => {
|
|
3064
|
+
const ref: ResourceRef = {
|
|
3065
|
+
kind: job.kind || 'Job',
|
|
3066
|
+
namespace: job.namespace || namespace,
|
|
3067
|
+
name: job.name,
|
|
3068
|
+
}
|
|
3069
|
+
return (
|
|
3070
|
+
<button
|
|
3071
|
+
key={`${ref.namespace}/${ref.name}`}
|
|
3072
|
+
type="button"
|
|
3073
|
+
onClick={() => onNavigate?.(refToSelectedResource(ref))}
|
|
3074
|
+
className={clsx(
|
|
3075
|
+
'flex w-full min-w-0 items-center gap-3 rounded-md border border-theme-border bg-theme-base px-3 py-2 text-left',
|
|
3076
|
+
onNavigate && 'transition-colors hover:bg-theme-hover',
|
|
3077
|
+
)}
|
|
3078
|
+
>
|
|
3079
|
+
<span className="h-2 w-2 shrink-0 rounded-full bg-skyhook-500" />
|
|
3080
|
+
<span className="min-w-0 flex-1">
|
|
3081
|
+
<span className="block truncate text-sm font-medium text-theme-text-primary">{ref.name}</span>
|
|
3082
|
+
<span className="block truncate text-xs text-theme-text-tertiary">{ref.namespace || 'default namespace'}</span>
|
|
3083
|
+
</span>
|
|
3084
|
+
<span className="badge-sm status-neutral">Active</span>
|
|
3085
|
+
</button>
|
|
3086
|
+
)
|
|
3087
|
+
})}
|
|
3088
|
+
{hiddenJobCount > 0 && <MoreRows count={hiddenJobCount} label="jobs" />}
|
|
3089
|
+
</div>
|
|
3090
|
+
) : (
|
|
3091
|
+
<div className="rounded-md border border-theme-border bg-theme-base px-3 py-2 text-sm text-theme-text-tertiary">
|
|
3092
|
+
{emptyMessage}
|
|
3093
|
+
</div>
|
|
3094
|
+
)}
|
|
3095
|
+
</OverviewCard>
|
|
3096
|
+
)
|
|
3097
|
+
}
|
|
3098
|
+
|
|
3099
|
+
function ScalingCard({
|
|
3100
|
+
title,
|
|
3101
|
+
icon,
|
|
3102
|
+
scaling,
|
|
3103
|
+
scalerDiagnostics,
|
|
3104
|
+
hpaDiagnosis,
|
|
3105
|
+
relationships,
|
|
3106
|
+
onNavigate,
|
|
3107
|
+
}: {
|
|
3108
|
+
title: string
|
|
3109
|
+
icon: ComponentType<{ className?: string }>
|
|
3110
|
+
scaling: ScalingSummary
|
|
3111
|
+
scalerDiagnostics?: ScalerDiagnosis[]
|
|
3112
|
+
hpaDiagnosis?: HPADiagnosis
|
|
3113
|
+
relationships?: Relationships
|
|
3114
|
+
onNavigate?: NavigateToResource
|
|
3115
|
+
}) {
|
|
3116
|
+
const scalerRefs = relationships?.scalers ?? []
|
|
3117
|
+
const navigateRef = onNavigate ? (ref: ResourceRef) => onNavigate(refToSelectedResource(ref)) : undefined
|
|
3118
|
+
|
|
3119
|
+
return (
|
|
3120
|
+
<OverviewCard title={title} icon={icon}>
|
|
3121
|
+
<div className="mb-3 grid gap-x-8 gap-y-3 sm:grid-cols-2">
|
|
3122
|
+
{scaling.stats.map((stat) => (
|
|
3123
|
+
<OverviewStat key={String(stat.label)} label={stat.label} value={stat.value} tone={stat.tone} />
|
|
3124
|
+
))}
|
|
3125
|
+
</div>
|
|
3126
|
+
{scalerRefs.length > 0 ? (
|
|
3127
|
+
<div className="space-y-2">
|
|
3128
|
+
<div className="text-xs font-medium text-theme-text-tertiary">Controlled by</div>
|
|
3129
|
+
<div className="flex flex-wrap gap-1.5">
|
|
3130
|
+
{scalerRefs.slice(0, RELATIONSHIP_REF_LIMIT).map((ref) => (
|
|
3131
|
+
<ResourceRefBadge key={`${ref.kind}/${ref.namespace}/${ref.name}`} resourceRef={ref} onClick={navigateRef} wrapAtSeparator />
|
|
3132
|
+
))}
|
|
3133
|
+
</div>
|
|
3134
|
+
{scalerDiagnostics?.map((entry) => (
|
|
3135
|
+
<div key={`${entry.ref.kind}/${entry.ref.namespace}/${entry.ref.name}`} className="rounded-md border border-theme-border bg-theme-base px-3 py-2 text-xs text-theme-text-secondary">
|
|
3136
|
+
{entry.loading ? 'Loading autoscaler diagnosis…' : entry.error ? 'Autoscaler diagnosis unavailable' : entry.diagnosis ? hpaStateLabel(entry.diagnosis.state) : 'Autoscaler status unavailable'}
|
|
3137
|
+
{entry.diagnosis?.summary && <span className="ml-1 text-theme-text-tertiary">{entry.diagnosis.summary}</span>}
|
|
3138
|
+
</div>
|
|
3139
|
+
))}
|
|
3140
|
+
</div>
|
|
3141
|
+
) : hpaDiagnosis ? (
|
|
3142
|
+
<div className="rounded-md border border-theme-border bg-theme-base px-3 py-2 text-xs text-theme-text-secondary">
|
|
3143
|
+
{hpaStateLabel(hpaDiagnosis.state)} <span className="text-theme-text-tertiary">{hpaDiagnosis.summary}</span>
|
|
3144
|
+
</div>
|
|
3145
|
+
) : scaling.detail ? (
|
|
3146
|
+
<div className="text-sm text-theme-text-secondary">{scaling.detail}</div>
|
|
3147
|
+
) : null}
|
|
3148
|
+
</OverviewCard>
|
|
3149
|
+
)
|
|
3150
|
+
}
|
|
3151
|
+
|
|
3152
|
+
interface RolloutProgressSummary {
|
|
3153
|
+
updated: number
|
|
3154
|
+
desired: number
|
|
3155
|
+
ready: number
|
|
3156
|
+
unavailable: number
|
|
3157
|
+
updatedLabel: string
|
|
3158
|
+
readyLabel: string
|
|
3159
|
+
}
|
|
3160
|
+
|
|
3161
|
+
function RolloutProgressBar({ progress, state }: { progress: RolloutProgressSummary; state: ReplicatedWorkloadState }) {
|
|
3162
|
+
const converged = Math.min(progress.updated, progress.ready)
|
|
3163
|
+
const percent = progress.desired > 0 ? Math.max(0, Math.min(100, (converged / progress.desired) * 100)) : 0
|
|
3164
|
+
return (
|
|
3165
|
+
<div className="rounded-md border border-theme-border bg-theme-base px-3 py-2">
|
|
3166
|
+
<div className="mb-1.5 flex min-w-0 items-center justify-between gap-3 text-xs">
|
|
3167
|
+
<span className="font-medium text-theme-text-secondary">Rollout progress</span>
|
|
3168
|
+
<span className="min-w-0 truncate text-theme-text-tertiary">
|
|
3169
|
+
{progress.updatedLabel} · {progress.readyLabel}
|
|
3170
|
+
</span>
|
|
3171
|
+
</div>
|
|
3172
|
+
<div className="h-1.5 overflow-hidden rounded-full bg-theme-elevated">
|
|
3173
|
+
<div
|
|
3174
|
+
className={clsx('h-full rounded-full transition-[width]', rolloutProgressFillClass(state.level))}
|
|
3175
|
+
style={{ width: `${percent}%` }}
|
|
3176
|
+
aria-label={`${progress.updatedLabel}; ${progress.readyLabel}`}
|
|
3177
|
+
/>
|
|
3178
|
+
</div>
|
|
3179
|
+
{progress.unavailable > 0 && (
|
|
3180
|
+
<div className="mt-1.5 text-xs text-amber-600 dark:text-amber-300">
|
|
3181
|
+
{progress.unavailable} unavailable while rollout converges.
|
|
3182
|
+
</div>
|
|
3183
|
+
)}
|
|
3184
|
+
</div>
|
|
3185
|
+
)
|
|
3186
|
+
}
|
|
3187
|
+
|
|
3188
|
+
function rolloutProgressFillClass(level: ReplicatedWorkloadState['level']): string {
|
|
3189
|
+
if (level === 'unhealthy') return 'bg-red-500'
|
|
3190
|
+
if (level === 'degraded') return 'bg-amber-500'
|
|
3191
|
+
return 'bg-accent'
|
|
3192
|
+
}
|
|
3193
|
+
|
|
3194
|
+
function RolloutFactChip({ label, value, tone = 'default' }: { label: string; value: ReactNode; tone?: 'default' | 'good' | 'warn' }) {
|
|
3195
|
+
return (
|
|
3196
|
+
<span className={clsx(
|
|
3197
|
+
'inline-flex min-w-0 max-w-full items-center gap-1 rounded-md border px-2 py-1 text-xs',
|
|
3198
|
+
tone === 'warn'
|
|
3199
|
+
? 'border-amber-500/30 bg-amber-500/10 text-amber-600 dark:text-amber-300'
|
|
3200
|
+
: tone === 'good'
|
|
3201
|
+
? 'border-emerald-500/30 bg-emerald-500/10 text-emerald-600 dark:text-emerald-300'
|
|
3202
|
+
: 'border-theme-border bg-theme-base text-theme-text-secondary',
|
|
3203
|
+
)}>
|
|
3204
|
+
<span className="shrink-0 text-theme-text-tertiary">{label}</span>
|
|
3205
|
+
<span className="min-w-0 truncate font-medium text-theme-text-primary">{value}</span>
|
|
3206
|
+
</span>
|
|
3207
|
+
)
|
|
3208
|
+
}
|
|
3209
|
+
|
|
3210
|
+
function rolloutStateDotClass(level: ReplicatedWorkloadState['level']): string {
|
|
3211
|
+
if (level === 'unhealthy') return 'bg-red-500'
|
|
3212
|
+
if (level === 'degraded') return 'bg-amber-500'
|
|
3213
|
+
if (level === 'neutral') return 'bg-skyhook-500'
|
|
3214
|
+
if (level === 'unknown') return 'bg-theme-text-tertiary'
|
|
3215
|
+
return 'bg-emerald-500'
|
|
3216
|
+
}
|
|
3217
|
+
|
|
3218
|
+
function rolloutFacts(
|
|
3219
|
+
resource: any,
|
|
3220
|
+
apiKind: string,
|
|
3221
|
+
scaling: ScalingSummary,
|
|
3222
|
+
progressVisible = false,
|
|
3223
|
+
): Array<{ label: string; value: ReactNode; tone?: 'default' | 'good' | 'warn' }> {
|
|
3224
|
+
const spec = resource?.spec || {}
|
|
3225
|
+
const status = resource?.status || {}
|
|
3226
|
+
const metadata = resource?.metadata || {}
|
|
3227
|
+
const k = apiKind.toLowerCase()
|
|
3228
|
+
const generation = metadata.generation
|
|
3229
|
+
const observedGeneration = status.observedGeneration
|
|
3230
|
+
const controllerBehind =
|
|
3231
|
+
typeof generation === 'number' &&
|
|
3232
|
+
typeof observedGeneration === 'number' &&
|
|
3233
|
+
observedGeneration < generation
|
|
3234
|
+
const facts: Array<{ label: string; value: ReactNode; tone?: 'default' | 'good' | 'warn' }> = []
|
|
3235
|
+
|
|
3236
|
+
if (k === 'daemonsets') {
|
|
3237
|
+
const desired = status.desiredNumberScheduled ?? 0
|
|
3238
|
+
const current = status.currentNumberScheduled ?? 0
|
|
3239
|
+
const updated = status.updatedNumberScheduled ?? 0
|
|
3240
|
+
const unavailable = status.numberUnavailable ?? 0
|
|
3241
|
+
const misscheduled = status.numberMisscheduled ?? 0
|
|
3242
|
+
facts.push({ label: 'Target nodes', value: desired })
|
|
3243
|
+
if (current !== desired) facts.push({ label: 'Scheduled', value: current, tone: 'warn' })
|
|
3244
|
+
if (!progressVisible && updated !== desired) facts.push({ label: 'Updated', value: `${updated}/${desired}`, tone: 'warn' })
|
|
3245
|
+
if (unavailable > 0) facts.push({ label: 'Unavailable', value: unavailable, tone: 'warn' })
|
|
3246
|
+
if (misscheduled > 0) facts.push({ label: 'Misscheduled', value: misscheduled, tone: 'warn' })
|
|
3247
|
+
facts.push({ label: 'Strategy', value: spec.updateStrategy?.type || 'RollingUpdate' })
|
|
3248
|
+
if (controllerBehind) facts.push({ label: 'Controller seen', value: observedGeneration, tone: 'warn' })
|
|
3249
|
+
return facts
|
|
3250
|
+
}
|
|
3251
|
+
|
|
3252
|
+
const desired = spec.replicas ?? status.replicas ?? 0
|
|
3253
|
+
const current = status.replicas ?? desired
|
|
3254
|
+
const updated = status.updatedReplicas ?? 0
|
|
3255
|
+
const unavailable = status.unavailableReplicas ?? Math.max(0, desired - (status.availableReplicas ?? status.readyReplicas ?? 0))
|
|
3256
|
+
facts.push({ label: 'Target replicas', value: desired })
|
|
3257
|
+
if (current !== desired) facts.push({ label: 'Current', value: current, tone: 'warn' })
|
|
3258
|
+
if (!progressVisible && updated !== desired) facts.push({ label: 'Updated', value: `${updated}/${desired}`, tone: 'warn' })
|
|
3259
|
+
if (unavailable > 0) facts.push({ label: 'Unavailable', value: unavailable, tone: 'warn' })
|
|
3260
|
+
facts.push({ label: 'Strategy', value: spec.strategy?.type || spec.updateStrategy?.type || '-' })
|
|
3261
|
+
|
|
3262
|
+
if (k === 'statefulsets') {
|
|
3263
|
+
if (status.updateRevision && status.currentRevision && status.updateRevision !== status.currentRevision) {
|
|
3264
|
+
facts.push({ label: 'Revision', value: `${midTruncate(status.currentRevision, 18)} -> ${midTruncate(status.updateRevision, 18)}`, tone: 'warn' })
|
|
3265
|
+
} else if (spec.updateStrategy?.rollingUpdate?.partition != null) {
|
|
3266
|
+
facts.push({ label: 'Partition', value: spec.updateStrategy.rollingUpdate.partition })
|
|
3267
|
+
}
|
|
3268
|
+
}
|
|
3269
|
+
|
|
3270
|
+
if (controllerBehind) facts.push({ label: 'Controller seen', value: observedGeneration, tone: 'warn' })
|
|
3271
|
+
|
|
3272
|
+
const externallyScaled = scaling.stats.some((stat) => !['Desired', 'Current', 'Strategy'].includes(String(stat.label)))
|
|
3273
|
+
if (externallyScaled) {
|
|
3274
|
+
for (const stat of scaling.stats) {
|
|
3275
|
+
const label = String(stat.label)
|
|
3276
|
+
if (!facts.some((existing) => existing.label === label)) facts.push(stat)
|
|
3277
|
+
}
|
|
3278
|
+
}
|
|
3279
|
+
return facts
|
|
3280
|
+
}
|
|
3281
|
+
|
|
3282
|
+
function rolloutProgress(resource: any, apiKind: string, state: ReplicatedWorkloadState): RolloutProgressSummary | null {
|
|
3283
|
+
const spec = resource?.spec || {}
|
|
3284
|
+
const status = resource?.status || {}
|
|
3285
|
+
const k = apiKind.toLowerCase()
|
|
3286
|
+
|
|
3287
|
+
if (k === 'daemonsets') {
|
|
3288
|
+
const desired = status.desiredNumberScheduled ?? 0
|
|
3289
|
+
const updated = status.updatedNumberScheduled ?? 0
|
|
3290
|
+
const ready = status.numberReady ?? 0
|
|
3291
|
+
const unavailable = status.numberUnavailable ?? Math.max(0, desired - ready)
|
|
3292
|
+
if (!shouldShowRolloutProgress(desired, updated, ready, unavailable, state)) return null
|
|
3293
|
+
return {
|
|
3294
|
+
updated,
|
|
3295
|
+
desired,
|
|
3296
|
+
ready,
|
|
3297
|
+
unavailable,
|
|
3298
|
+
updatedLabel: `${updated}/${desired} nodes updated`,
|
|
3299
|
+
readyLabel: `${ready}/${desired} nodes ready`,
|
|
3300
|
+
}
|
|
3301
|
+
}
|
|
3302
|
+
|
|
3303
|
+
const desired = spec.replicas ?? status.replicas ?? 0
|
|
3304
|
+
const updated = status.updatedReplicas ?? 0
|
|
3305
|
+
const ready = status.readyReplicas ?? 0
|
|
3306
|
+
const unavailable = status.unavailableReplicas ?? Math.max(0, desired - (status.availableReplicas ?? ready))
|
|
3307
|
+
if (!shouldShowRolloutProgress(desired, updated, ready, unavailable, state)) return null
|
|
3308
|
+
return {
|
|
3309
|
+
updated,
|
|
3310
|
+
desired,
|
|
3311
|
+
ready,
|
|
3312
|
+
unavailable,
|
|
3313
|
+
updatedLabel: `${updated}/${desired} updated`,
|
|
3314
|
+
readyLabel: `${ready}/${desired} ready`,
|
|
3315
|
+
}
|
|
3316
|
+
}
|
|
3317
|
+
|
|
3318
|
+
function shouldShowRolloutProgress(
|
|
3319
|
+
desired: number,
|
|
3320
|
+
updated: number,
|
|
3321
|
+
ready: number,
|
|
3322
|
+
unavailable: number,
|
|
3323
|
+
state: ReplicatedWorkloadState,
|
|
3324
|
+
): boolean {
|
|
3325
|
+
if (desired <= 0) return false
|
|
3326
|
+
if (updated < desired || ready < desired || unavailable > 0) return true
|
|
3327
|
+
return state.label === 'Rolling out' || state.label === 'Applying' || state.label === 'Stalled'
|
|
3328
|
+
}
|
|
3329
|
+
|
|
3330
|
+
function rolloutHeadline(resource: any, apiKind: string, readiness: ReadinessSummary, scaling: ScalingSummary): string {
|
|
3331
|
+
const status = resource?.status || {}
|
|
3332
|
+
const k = apiKind.toLowerCase()
|
|
3333
|
+
if (k === 'daemonsets') {
|
|
3334
|
+
return `${readiness.readyLabel} ready on nodes`
|
|
3335
|
+
}
|
|
3336
|
+
if (k === 'statefulsets') {
|
|
3337
|
+
const revision = status.updateRevision && status.currentRevision && status.updateRevision !== status.currentRevision
|
|
3338
|
+
? `${status.currentRevision} -> ${status.updateRevision}`
|
|
3339
|
+
: status.currentRevision || status.updateRevision
|
|
3340
|
+
return `${readiness.readyLabel} ready · ${scaling.label}${revision && status.updateRevision !== status.currentRevision ? ' · updating revision' : ''}`
|
|
3341
|
+
}
|
|
3342
|
+
return `${readiness.readyLabel} ready · ${scaling.label}`
|
|
3343
|
+
}
|
|
3344
|
+
|
|
3345
|
+
function ServingDependenciesCard({
|
|
3346
|
+
title = 'Serving and dependencies',
|
|
3347
|
+
relationships,
|
|
3348
|
+
groups,
|
|
3349
|
+
onNavigate,
|
|
3350
|
+
onSwitchToTopology,
|
|
3351
|
+
}: {
|
|
3352
|
+
title?: string
|
|
3353
|
+
relationships?: Relationships
|
|
3354
|
+
groups?: RelationshipGroup[]
|
|
3355
|
+
onNavigate?: NavigateToResource
|
|
3356
|
+
onSwitchToTopology?: () => void
|
|
3357
|
+
}) {
|
|
3358
|
+
const relationshipGroups = groups ?? buildRelationshipGroups(relationships)
|
|
3359
|
+
const visibleGroups = relationshipGroups.slice(0, RELATIONSHIP_GROUP_LIMIT)
|
|
3360
|
+
const hiddenRefs =
|
|
3361
|
+
visibleGroups.reduce((sum, group) => sum + Math.max(0, group.refs.length - RELATIONSHIP_REF_LIMIT), 0) +
|
|
3362
|
+
relationshipGroups.slice(RELATIONSHIP_GROUP_LIMIT).reduce((sum, group) => sum + group.refs.length, 0)
|
|
3363
|
+
const navigateRef = onNavigate ? (ref: ResourceRef) => onNavigate(refToSelectedResource(ref)) : undefined
|
|
3364
|
+
|
|
3365
|
+
return (
|
|
3366
|
+
<OverviewCard
|
|
3367
|
+
title={title}
|
|
3368
|
+
icon={Network}
|
|
3369
|
+
action={onSwitchToTopology && (
|
|
3370
|
+
<button type="button" onClick={onSwitchToTopology} className="inline-flex items-center gap-1 text-xs text-skyhook-500 hover:underline">
|
|
3371
|
+
<GitBranch className="h-3.5 w-3.5" />
|
|
3372
|
+
Topology
|
|
3373
|
+
</button>
|
|
3374
|
+
)}
|
|
3375
|
+
>
|
|
3376
|
+
{visibleGroups.length > 0 ? (
|
|
3377
|
+
<div className="space-y-3">
|
|
3378
|
+
{visibleGroups.map((group) => (
|
|
3379
|
+
<div key={group.label}>
|
|
3380
|
+
<div className="mb-1 text-xs font-medium text-theme-text-tertiary">{group.label}</div>
|
|
3381
|
+
<div className="flex flex-wrap gap-1.5">
|
|
3382
|
+
{group.refs.slice(0, RELATIONSHIP_REF_LIMIT).map((ref) => (
|
|
3383
|
+
<ResourceRefBadge key={`${ref.kind}/${ref.namespace}/${ref.name}`} resourceRef={ref} onClick={navigateRef} wrapAtSeparator />
|
|
3384
|
+
))}
|
|
3385
|
+
{group.refs.length > RELATIONSHIP_REF_LIMIT && (
|
|
3386
|
+
<span className="badge bg-theme-elevated text-theme-text-secondary">+{group.refs.length - RELATIONSHIP_REF_LIMIT}</span>
|
|
3387
|
+
)}
|
|
3388
|
+
</div>
|
|
3389
|
+
</div>
|
|
3390
|
+
))}
|
|
3391
|
+
{hiddenRefs > 0 && <div className="text-xs text-theme-text-tertiary">+{hiddenRefs} more relationships; use Topology for the full graph.</div>}
|
|
3392
|
+
</div>
|
|
3393
|
+
) : (
|
|
3394
|
+
<div className="text-sm text-theme-text-tertiary">No serving or dependency relationships found.</div>
|
|
3395
|
+
)}
|
|
3396
|
+
</OverviewCard>
|
|
3397
|
+
)
|
|
3398
|
+
}
|
|
3399
|
+
|
|
3400
|
+
function ConfigurationInputsCard({
|
|
3401
|
+
serviceAccountName,
|
|
3402
|
+
namespace,
|
|
3403
|
+
relationships,
|
|
3404
|
+
onNavigate,
|
|
3405
|
+
onSwitchToTopology,
|
|
3406
|
+
}: {
|
|
3407
|
+
serviceAccountName: string
|
|
3408
|
+
namespace: string
|
|
3409
|
+
relationships?: Relationships
|
|
3410
|
+
onNavigate?: NavigateToResource
|
|
3411
|
+
onSwitchToTopology?: () => void
|
|
3412
|
+
}) {
|
|
3413
|
+
const configRefs = dedupeResourceRefs(relationships?.configRefs ?? []).filter((ref) =>
|
|
3414
|
+
['ConfigMap', 'Secret', 'SealedSecret'].includes(ref.kind),
|
|
3415
|
+
)
|
|
3416
|
+
const policyRefs = dedupeResourceRefs([
|
|
3417
|
+
...(relationships?.pdbs ?? []),
|
|
3418
|
+
...(relationships?.networkPolicies ?? []),
|
|
3419
|
+
...(relationships?.resourceClaims ?? []),
|
|
3420
|
+
])
|
|
3421
|
+
if (!serviceAccountName && configRefs.length === 0 && policyRefs.length === 0) return null
|
|
3422
|
+
const navigateRef = onNavigate ? (ref: ResourceRef) => onNavigate(refToSelectedResource(ref)) : undefined
|
|
3423
|
+
const serviceAccountRef: ResourceRef = {
|
|
3424
|
+
kind: 'ServiceAccount',
|
|
3425
|
+
namespace,
|
|
3426
|
+
name: serviceAccountName || 'default',
|
|
3427
|
+
}
|
|
3428
|
+
|
|
3429
|
+
return (
|
|
3430
|
+
<OverviewCard
|
|
3431
|
+
title="Configuration inputs"
|
|
3432
|
+
icon={Layers}
|
|
3433
|
+
action={onSwitchToTopology && (
|
|
3434
|
+
<button type="button" onClick={onSwitchToTopology} className="inline-flex items-center gap-1 text-xs text-skyhook-500 hover:underline">
|
|
3435
|
+
<GitBranch className="h-3.5 w-3.5" />
|
|
3436
|
+
Topology
|
|
3437
|
+
</button>
|
|
3438
|
+
)}
|
|
3439
|
+
>
|
|
3440
|
+
<div className="space-y-4">
|
|
3441
|
+
<div className="min-w-0">
|
|
3442
|
+
<div className="mb-1 text-xs font-medium text-theme-text-tertiary">Identity</div>
|
|
3443
|
+
<ResourceRefBadge resourceRef={serviceAccountRef} onClick={navigateRef} wrapAtSeparator />
|
|
3444
|
+
</div>
|
|
3445
|
+
<div className="min-w-0">
|
|
3446
|
+
<div className="mb-1 text-xs font-medium text-theme-text-tertiary">Config</div>
|
|
3447
|
+
{configRefs.length > 0 ? (
|
|
3448
|
+
<div className="flex flex-wrap gap-1.5">
|
|
3449
|
+
{configRefs.slice(0, RELATIONSHIP_REF_LIMIT).map((ref) => (
|
|
3450
|
+
<ResourceRefBadge key={`${ref.kind}/${ref.namespace}/${ref.name}`} resourceRef={ref} onClick={navigateRef} wrapAtSeparator />
|
|
3451
|
+
))}
|
|
3452
|
+
{configRefs.length > RELATIONSHIP_REF_LIMIT && <Badge tone="structural" size="sm">+{configRefs.length - RELATIONSHIP_REF_LIMIT}</Badge>}
|
|
3453
|
+
</div>
|
|
3454
|
+
) : (
|
|
3455
|
+
<div className="text-xs text-theme-text-tertiary">No ConfigMap or Secret refs found.</div>
|
|
3456
|
+
)}
|
|
3457
|
+
</div>
|
|
3458
|
+
<div className="min-w-0">
|
|
3459
|
+
<div className="mb-1 text-xs font-medium text-theme-text-tertiary">Policy & storage</div>
|
|
3460
|
+
{policyRefs.length > 0 && onSwitchToTopology ? (
|
|
3461
|
+
<button type="button" onClick={onSwitchToTopology} className="text-left text-xs text-theme-text-secondary hover:text-accent-text">
|
|
3462
|
+
{policyRefs.length} related resource{policyRefs.length === 1 ? '' : 's'} in Topology
|
|
3463
|
+
</button>
|
|
3464
|
+
) : policyRefs.length > 0 ? (
|
|
3465
|
+
<div className="text-xs text-theme-text-secondary">{policyRefs.length} related resource{policyRefs.length === 1 ? '' : 's'} found.</div>
|
|
3466
|
+
) : (
|
|
3467
|
+
<div className="text-xs text-theme-text-tertiary">No PDB, NetworkPolicy, or storage refs found.</div>
|
|
3468
|
+
)}
|
|
3469
|
+
</div>
|
|
3470
|
+
</div>
|
|
3471
|
+
</OverviewCard>
|
|
3472
|
+
)
|
|
3473
|
+
}
|
|
3474
|
+
|
|
3475
|
+
function ActivityCard({
|
|
3476
|
+
events,
|
|
3477
|
+
updates,
|
|
3478
|
+
loading,
|
|
3479
|
+
eventsError,
|
|
3480
|
+
updatesError,
|
|
3481
|
+
onSwitchToTimeline,
|
|
3482
|
+
}: {
|
|
3483
|
+
events: TimelineEvent[]
|
|
3484
|
+
updates: TimelineEvent[]
|
|
3485
|
+
loading?: boolean
|
|
3486
|
+
eventsError?: Error | null
|
|
3487
|
+
updatesError?: Error | null
|
|
3488
|
+
onSwitchToTimeline?: () => void
|
|
3489
|
+
}) {
|
|
3490
|
+
const visibleEvents = mergeAndRankEvents(events, updates).slice(0, EVENT_VISIBLE_LIMIT)
|
|
3491
|
+
|
|
3492
|
+
return (
|
|
3493
|
+
<OverviewCard
|
|
3494
|
+
title="Recent activity"
|
|
3495
|
+
icon={Clock3}
|
|
3496
|
+
action={onSwitchToTimeline && (
|
|
3497
|
+
<button type="button" onClick={onSwitchToTimeline} className="inline-flex items-center gap-1 text-xs text-skyhook-500 hover:underline">
|
|
3498
|
+
<ExternalLink className="h-3.5 w-3.5" />
|
|
3499
|
+
Timeline
|
|
3500
|
+
</button>
|
|
3501
|
+
)}
|
|
3502
|
+
>
|
|
3503
|
+
{loading ? (
|
|
3504
|
+
<div className="text-sm text-theme-text-tertiary">Loading activity…</div>
|
|
3505
|
+
) : visibleEvents.length > 0 ? (
|
|
3506
|
+
<div className="space-y-2">
|
|
3507
|
+
{visibleEvents.map((event, i) => (
|
|
3508
|
+
<div key={`${event.id}-${i}`} className="rounded-md border border-theme-border bg-theme-base px-3 py-2">
|
|
3509
|
+
<div className="flex min-w-0 items-start gap-2">
|
|
3510
|
+
<span className={clsx('mt-1 h-2 w-2 shrink-0 rounded-full', isProblematicEvent(event) ? 'bg-amber-500' : 'bg-skyhook-500')} />
|
|
3511
|
+
<div className="min-w-0 flex-1">
|
|
3512
|
+
<div className="flex items-center justify-between gap-2">
|
|
3513
|
+
<span className="truncate text-sm font-medium text-theme-text-primary">{event.reason || event.eventType || 'update'}</span>
|
|
3514
|
+
<span className="shrink-0 text-xs text-theme-text-tertiary">{formatAge(event.timestamp)}</span>
|
|
3515
|
+
</div>
|
|
3516
|
+
<div className="mt-0.5 line-clamp-2 text-xs text-theme-text-secondary">
|
|
3517
|
+
{event.message || event.diff?.summary || `${event.kind} ${event.name}`}
|
|
3518
|
+
</div>
|
|
3519
|
+
</div>
|
|
3520
|
+
</div>
|
|
3521
|
+
</div>
|
|
3522
|
+
))}
|
|
3523
|
+
{(events.length + updates.length) > EVENT_VISIBLE_LIMIT && (
|
|
3524
|
+
<div className="text-xs text-theme-text-tertiary">Showing highest-signal recent events. Timeline has the full history.</div>
|
|
3525
|
+
)}
|
|
3526
|
+
</div>
|
|
3527
|
+
) : (
|
|
3528
|
+
<div className="text-sm text-theme-text-tertiary">No recent activity captured.</div>
|
|
3529
|
+
)}
|
|
3530
|
+
{eventsError && <div className="mt-2 text-xs text-red-500">Failed to load events: {eventsError.message}</div>}
|
|
3531
|
+
{updatesError && <div className="mt-2 text-xs text-red-500">Failed to load changes: {updatesError.message}</div>}
|
|
3532
|
+
</OverviewCard>
|
|
3533
|
+
)
|
|
3534
|
+
}
|
|
3535
|
+
|
|
3536
|
+
function buildRelationshipGroups(relationships: Relationships | undefined): RelationshipGroup[] {
|
|
3537
|
+
return [
|
|
3538
|
+
...buildServingRelationshipGroups(relationships),
|
|
3539
|
+
...buildDependencyRelationshipGroups(relationships),
|
|
3540
|
+
]
|
|
3541
|
+
}
|
|
3542
|
+
|
|
3543
|
+
function buildServingRelationshipGroups(relationships: Relationships | undefined): RelationshipGroup[] {
|
|
3544
|
+
if (!relationships) return []
|
|
3545
|
+
const routeRefs = dedupeResourceRefs([
|
|
3546
|
+
...(relationships.ingresses ?? []),
|
|
3547
|
+
...(relationships.gateways ?? []),
|
|
3548
|
+
...(relationships.routes ?? []),
|
|
3549
|
+
])
|
|
3550
|
+
return [
|
|
3551
|
+
{ label: 'Services', refs: dedupeResourceRefs(relationships.services ?? []) },
|
|
3552
|
+
{ label: 'Entry points', refs: routeRefs },
|
|
3553
|
+
].filter((group) => group.refs.length > 0)
|
|
3554
|
+
}
|
|
3555
|
+
|
|
3556
|
+
function buildDependencyRelationshipGroups(relationships: Relationships | undefined): RelationshipGroup[] {
|
|
3557
|
+
if (!relationships) return []
|
|
3558
|
+
return [
|
|
3559
|
+
{ label: 'Configuration', refs: dedupeResourceRefs(relationships.configRefs ?? []) },
|
|
3560
|
+
{ label: 'Autoscalers', refs: dedupeResourceRefs(relationships.scalers ?? []) },
|
|
3561
|
+
{ label: 'Disruption budgets', refs: dedupeResourceRefs(relationships.pdbs ?? []) },
|
|
3562
|
+
{ label: 'Network policies', refs: dedupeResourceRefs(relationships.networkPolicies ?? []) },
|
|
3563
|
+
{ label: 'Storage claims', refs: dedupeResourceRefs(relationships.resourceClaims ?? []) },
|
|
3564
|
+
].filter((group) => group.refs.length > 0)
|
|
3565
|
+
}
|
|
3566
|
+
|
|
3567
|
+
function summarizeServiceTypes(serviceResources: any[], serviceCount: number, loading: boolean): string {
|
|
3568
|
+
if (loading && serviceResources.length === 0) return 'Loading'
|
|
3569
|
+
const types = Array.from(new Set(
|
|
3570
|
+
serviceResources
|
|
3571
|
+
.map((service) => service?.spec?.type || 'ClusterIP')
|
|
3572
|
+
.filter(Boolean),
|
|
3573
|
+
))
|
|
3574
|
+
if (types.length === 0) return serviceCount > 0 ? 'Unknown' : '-'
|
|
3575
|
+
if (types.length <= 2) return types.join(' / ')
|
|
3576
|
+
return `${types[0]} +${types.length - 1}`
|
|
3577
|
+
}
|
|
3578
|
+
|
|
3579
|
+
function summarizeExternalLabels(serviceResources: any[], entrypointResources: any[]): string[] {
|
|
3580
|
+
const labels: string[] = []
|
|
3581
|
+
for (const service of serviceResources) {
|
|
3582
|
+
const external = getServiceExternalIP(service)
|
|
3583
|
+
if (external) labels.push(`${service?.metadata?.name || 'service'}: ${external}`)
|
|
3584
|
+
}
|
|
3585
|
+
for (const entrypoint of entrypointResources) {
|
|
3586
|
+
const label = summarizeEntrypointResource(entrypoint)
|
|
3587
|
+
if (label) labels.push(label)
|
|
3588
|
+
}
|
|
3589
|
+
return dedupeStrings(labels)
|
|
3590
|
+
}
|
|
3591
|
+
|
|
3592
|
+
function summarizeEntrypointResource(resource: any): string {
|
|
3593
|
+
const kind = resource?.kind
|
|
3594
|
+
const name = resource?.metadata?.name || kind || 'entrypoint'
|
|
3595
|
+
if (kind === 'Ingress') {
|
|
3596
|
+
const hosts = getIngressHosts(resource)
|
|
3597
|
+
const address = getIngressAddress(resource)
|
|
3598
|
+
return `${name}: ${hosts}${address ? ` @ ${address}` : ' (address pending)'}`
|
|
3599
|
+
}
|
|
3600
|
+
if (kind === 'Gateway') {
|
|
3601
|
+
const listeners = getGatewayListeners(resource)
|
|
3602
|
+
const addresses = getGatewayAddresses(resource)
|
|
3603
|
+
return `${name}: ${listeners}${addresses && addresses !== '-' ? ` @ ${addresses}` : ''}`
|
|
3604
|
+
}
|
|
3605
|
+
const hostnames = resource?.spec?.hostnames
|
|
3606
|
+
if (Array.isArray(hostnames) && hostnames.length > 0) {
|
|
3607
|
+
return `${name}: ${hostnames.slice(0, 2).join(', ')}${hostnames.length > 2 ? ` +${hostnames.length - 2}` : ''}`
|
|
3608
|
+
}
|
|
3609
|
+
const parentRefs = resource?.spec?.parentRefs
|
|
3610
|
+
if (Array.isArray(parentRefs) && parentRefs.length > 0) {
|
|
3611
|
+
return `${name}: ${parentRefs.length} parent${parentRefs.length === 1 ? '' : 's'}`
|
|
3612
|
+
}
|
|
3613
|
+
return name
|
|
3614
|
+
}
|
|
3615
|
+
|
|
3616
|
+
function summarizeServingExposure(
|
|
3617
|
+
serviceResources: any[],
|
|
3618
|
+
serviceCount: number,
|
|
3619
|
+
entrypointCount: number,
|
|
3620
|
+
externalLabels: string[],
|
|
3621
|
+
loading: boolean,
|
|
3622
|
+
): { label: string; badge: string; severity: BadgeSeverity } {
|
|
3623
|
+
if (loading && serviceResources.length === 0) return { label: 'Checking serving exposure', badge: 'Loading', severity: 'neutral' }
|
|
3624
|
+
const pendingLoadBalancer = serviceResources.some((service) =>
|
|
3625
|
+
service?.spec?.type === 'LoadBalancer' && getServiceExternalIP(service) === 'Pending',
|
|
3626
|
+
)
|
|
3627
|
+
const hasExternalService = serviceResources.some((service) =>
|
|
3628
|
+
['LoadBalancer', 'NodePort', 'ExternalName'].includes(service?.spec?.type) || Boolean(getServiceExternalIP(service)),
|
|
3629
|
+
)
|
|
3630
|
+
if (entrypointCount > 0) return { label: 'External entrypoint configured', badge: 'External', severity: 'info' }
|
|
3631
|
+
if (pendingLoadBalancer) return { label: 'External service pending', badge: 'Pending', severity: 'warning' }
|
|
3632
|
+
if (hasExternalService || externalLabels.length > 0) return { label: 'External service configured', badge: 'External', severity: 'info' }
|
|
3633
|
+
if (serviceCount > 0) return { label: 'Internal service only', badge: 'Internal', severity: 'neutral' }
|
|
3634
|
+
return { label: 'No serving path detected', badge: 'None', severity: 'neutral' }
|
|
3635
|
+
}
|
|
3636
|
+
|
|
3637
|
+
function dedupeResourceRefs(refs: ResourceRef[]): ResourceRef[] {
|
|
3638
|
+
const seen = new Set<string>()
|
|
3639
|
+
return refs.filter((ref) => {
|
|
3640
|
+
const key = resourceRefId(ref)
|
|
3641
|
+
if (seen.has(key)) return false
|
|
3642
|
+
seen.add(key)
|
|
3643
|
+
return true
|
|
3644
|
+
})
|
|
3645
|
+
}
|
|
3646
|
+
|
|
3647
|
+
function resourceRefId(ref: ResourceRef): string {
|
|
3648
|
+
return `${ref.kind}/${ref.namespace}/${ref.name}/${ref.group ?? ''}`
|
|
3649
|
+
}
|
|
3650
|
+
|
|
3651
|
+
function dedupeStrings(values: string[]): string[] {
|
|
3652
|
+
return Array.from(new Set(values.filter(Boolean)))
|
|
3653
|
+
}
|
|
3654
|
+
|
|
3655
|
+
function mergeAndRankEvents(events: TimelineEvent[], updates: TimelineEvent[]): TimelineEvent[] {
|
|
3656
|
+
const seen = new Set<string>()
|
|
3657
|
+
return [...events, ...updates]
|
|
3658
|
+
.filter((event) => {
|
|
3659
|
+
const key = event.id || `${event.timestamp}/${event.kind}/${event.name}/${event.reason}/${event.message}/${event.diff?.summary}`
|
|
3660
|
+
if (seen.has(key)) return false
|
|
3661
|
+
seen.add(key)
|
|
3662
|
+
return true
|
|
3663
|
+
})
|
|
3664
|
+
.sort((a, b) => {
|
|
3665
|
+
const aProblem = isProblematicEvent(a) ? 1 : 0
|
|
3666
|
+
const bProblem = isProblematicEvent(b) ? 1 : 0
|
|
3667
|
+
if (aProblem !== bProblem) return bProblem - aProblem
|
|
3668
|
+
return new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime()
|
|
3669
|
+
})
|
|
3670
|
+
}
|