@skyhook-io/k8s-ui 1.14.9 → 1.14.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.
Files changed (80) hide show
  1. package/package.json +3 -2
  2. package/src/components/applications/ApplicationDetail.tsx +24 -11
  3. package/src/components/audit/AuditAlerts.tsx +1 -3
  4. package/src/components/audit/AuditCoveragePresentation.test.tsx +48 -2
  5. package/src/components/checks/ChecksView.test.tsx +55 -0
  6. package/src/components/checks/ChecksView.tsx +38 -27
  7. package/src/components/dock/NodeTerminalTab.test.tsx +89 -0
  8. package/src/components/dock/NodeTerminalTab.tsx +51 -47
  9. package/src/components/gitops/GitOpsHealthSourceNotice.tsx +17 -9
  10. package/src/components/gitops/detail-helpers.test.ts +32 -0
  11. package/src/components/gitops/detail-helpers.ts +21 -0
  12. package/src/components/gitops/health-provenance.test.ts +4 -3
  13. package/src/components/gitops/index.ts +2 -1
  14. package/src/components/gitops/insights/ArgoResourceDiff.tsx +131 -54
  15. package/src/components/issues/IssuesView.tsx +6 -0
  16. package/src/components/issues/ResourceIssuesSection.test.tsx +36 -0
  17. package/src/components/issues/diagnostic.ts +4 -0
  18. package/src/components/logs/LogCore.tsx +3 -3
  19. package/src/components/logs/WorkloadLogsViewer.tsx +41 -11
  20. package/src/components/logs/useLogBuffer.ts +1 -0
  21. package/src/components/logs/useLogStream.ts +1 -2
  22. package/src/components/resources/KueueAdmissionSection.test.tsx +95 -0
  23. package/src/components/resources/KueueAdmissionSection.tsx +87 -0
  24. package/src/components/resources/ResourcesView.default-sort.test.ts +26 -0
  25. package/src/components/resources/ResourcesView.tsx +91 -16
  26. package/src/components/resources/curated-column-ownership.test.ts +45 -0
  27. package/src/components/resources/renderers/CAPIClusterRenderer.tsx +4 -3
  28. package/src/components/resources/renderers/ContainerEnvironmentSection.tsx +10 -3
  29. package/src/components/resources/renderers/JobSetRenderer.test.tsx +280 -0
  30. package/src/components/resources/renderers/JobSetRenderer.tsx +322 -0
  31. package/src/components/resources/renderers/KueueProvisioningRenderers.test.tsx +67 -0
  32. package/src/components/resources/renderers/KueueProvisioningRenderers.tsx +91 -0
  33. package/src/components/resources/renderers/KueueQueueNavigation.test.tsx +69 -0
  34. package/src/components/resources/renderers/KueueQueueRenderers.test.tsx +123 -0
  35. package/src/components/resources/renderers/KueueQueueRenderers.tsx +299 -0
  36. package/src/components/resources/renderers/KueueWorkloadRenderer.test.tsx +121 -0
  37. package/src/components/resources/renderers/KueueWorkloadRenderer.tsx +230 -0
  38. package/src/components/resources/renderers/RayClusterRenderer.test.tsx +25 -0
  39. package/src/components/resources/renderers/RayClusterRenderer.tsx +93 -0
  40. package/src/components/resources/renderers/RayServiceRenderer.test.tsx +62 -0
  41. package/src/components/resources/renderers/RayServiceRenderer.tsx +114 -0
  42. package/src/components/resources/renderers/index.ts +11 -0
  43. package/src/components/resources/resource-utils-gpu-ecosystem.test.ts +103 -2
  44. package/src/components/resources/resource-utils-jobset-lws.ts +43 -9
  45. package/src/components/resources/resource-utils-kueue.ts +145 -50
  46. package/src/components/resources/resource-utils-ray.test.ts +58 -1
  47. package/src/components/resources/resource-utils-ray.ts +56 -90
  48. package/src/components/shared/ResourceRendererDispatch.test.tsx +117 -2
  49. package/src/components/shared/ResourceRendererDispatch.tsx +47 -10
  50. package/src/components/timeline/TimelineList.tsx +5 -0
  51. package/src/components/timeline/TimelineSwimlanes.test.tsx +15 -0
  52. package/src/components/timeline/TimelineSwimlanes.tsx +43 -23
  53. package/src/components/topology/K8sResourceNode.tsx +3 -3
  54. package/src/components/ui/CodeViewer.tsx +4 -0
  55. package/src/components/ui/Toast.tsx +9 -3
  56. package/src/components/ui/drawer-components.tsx +3 -1
  57. package/src/components/workload/PodList.tsx +154 -0
  58. package/src/components/workload/WorkloadView.tsx +29 -159
  59. package/src/components/workload/index.ts +11 -1
  60. package/src/components/workload/workload-logs-availability.test.ts +20 -0
  61. package/src/components/workload/workload-pod-presentation.test.ts +27 -0
  62. package/src/types/core.ts +24 -0
  63. package/src/types/gitops-tree.ts +5 -2
  64. package/src/types/scheduling.ts +71 -0
  65. package/src/utils/api-resources.test.ts +20 -1
  66. package/src/utils/api-resources.ts +26 -0
  67. package/src/utils/application-topology.test.ts +25 -0
  68. package/src/utils/application-topology.ts +2 -1
  69. package/src/utils/applications.test.ts +82 -0
  70. package/src/utils/applications.ts +62 -24
  71. package/src/utils/gitops-route.test.ts +20 -1
  72. package/src/utils/gitops-route.ts +15 -7
  73. package/src/utils/log-format.ts +5 -2
  74. package/src/utils/log-stream-error.test.ts +13 -0
  75. package/src/utils/navigation.test.ts +2 -0
  76. package/src/utils/navigation.ts +1 -1
  77. package/src/utils/resource-hierarchy.test.ts +402 -15
  78. package/src/utils/resource-hierarchy.ts +263 -178
  79. package/src/utils/topology-neighborhood.test.ts +72 -1
  80. package/src/utils/topology-neighborhood.ts +40 -18
@@ -46,7 +46,8 @@ import { MiddleEllipsis } from '../ui/MiddleEllipsis'
46
46
  import { CollapseChevron } from '../ui/Collapse'
47
47
  import { Tooltip } from '../ui/Tooltip'
48
48
  import { ResourceRefBadge } from '../ui/drawer-components'
49
- import { buildResourceHierarchy, extractPinnedLanes, removePinnedLanes, isProblematicEvent, laneTrackEvents, isChildVisibleInWindow, collidingLaneKeys, laneCollisionKey, type ResourceLane as BaseResourceLane, type TimelineGrouping, type PinnedLaneRef } from '../../utils/resource-hierarchy'
49
+ import { buildResourceHierarchy, extractPinnedLanes, removePinnedLanes, resolvePinnedLaneIds, isProblematicEvent, laneTrackEvents, isChildVisibleInWindow, collidingLaneKeys, laneCollisionKey, type ResourceLane as BaseResourceLane, type TimelineGrouping, type PinnedLaneRef } from '../../utils/resource-hierarchy'
50
+ import { builtinGroupForKind } from '../../utils/api-resources'
50
51
  import { groupQualifiesLaneId } from '../../utils/navigation'
51
52
  import type { AppMembershipIndex } from '../../utils/applications'
52
53
  import { Layers } from 'lucide-react'
@@ -765,8 +766,8 @@ export function TimelineSwimlanes({ events, isLoading, onResourceClick, viewMode
765
766
  // deep-link to GitOps detail rather than the resource drawer — the lane is
766
767
  // already telling the user "this controller had changes/events"; the GitOps
767
768
  // tab is the right place to investigate further.
768
- const handleLaneOpen = useCallback((kind: string, namespace: string, name: string, group?: string) => {
769
- const gitOpsPath = gitOpsRouteForKind(kind, namespace, name)
769
+ const handleLaneOpen = useCallback((kind: string, namespace: string, name: string, group?: string, identityResolved?: boolean) => {
770
+ const gitOpsPath = gitOpsRouteForKind(kind, namespace, name, group, identityResolved)
770
771
  if (gitOpsPath && onNavigatePath) {
771
772
  onNavigatePath(gitOpsPath)
772
773
  return
@@ -1188,7 +1189,10 @@ export function TimelineSwimlanes({ events, isLoading, onResourceClick, viewMode
1188
1189
 
1189
1190
  // Pin MOVES a row: pinned lanes (and pinned children inside groups) leave
1190
1191
  // the regular list entirely — the pinned section is their only home.
1191
- const pinnedIdSetForFilter = useMemo(() => new Set((pinnedLanes ?? []).map((p) => p.id)), [pinnedLanes])
1192
+ const pinnedIdSetForFilter = useMemo(
1193
+ () => resolvePinnedLaneIds(lanes, pinnedLanes ?? []),
1194
+ [lanes, pinnedLanes],
1195
+ )
1192
1196
  const pinnedAppKeys = useMemo(
1193
1197
  () => new Set((pinnedLanes ?? []).flatMap((p) => (p.type === 'appGroup' ? [p.appKey] : []))),
1194
1198
  [pinnedLanes],
@@ -1233,16 +1237,16 @@ export function TimelineSwimlanes({ events, isLoading, onResourceClick, viewMode
1233
1237
  return n
1234
1238
  }, [pinnedLaneRows, visibleWindow])
1235
1239
 
1236
- const pinnedIdSet = useMemo(() => new Set((pinnedLanes ?? []).map((p) => p.id)), [pinnedLanes])
1240
+ const pinnedIdSet = pinnedIdSetForFilter
1237
1241
  // A pin button for a lane, or null when the host wired no pin handler. A pinned
1238
1242
  // lane's button is filled and always visible (in the pinned section or its
1239
1243
  // original spot); an unpinned one reveals on row hover.
1240
1244
  const renderPinButton = useCallback((lane: ResourceLane): React.ReactNode => {
1241
- if (!onTogglePin) return null
1245
+ if (!onTogglePin || lane.identityAmbiguous) return null
1242
1246
  const pinned = pinnedIdSet.has(lane.id)
1243
1247
  const ref: PinnedLaneRef = lane.isAppGroup && lane.appKey
1244
1248
  ? { type: 'appGroup', id: lane.id, appKey: lane.appKey, appName: lane.title ?? lane.name }
1245
- : { id: lane.id, kind: lane.kind, namespace: lane.namespace, name: lane.name }
1249
+ : { id: lane.id, kind: lane.kind, group: lane.group, namespace: lane.namespace, name: lane.name }
1246
1250
  return (
1247
1251
  <PinButton
1248
1252
  pinned={pinned}
@@ -1659,10 +1663,10 @@ export function TimelineSwimlanes({ events, isLoading, onResourceClick, viewMode
1659
1663
  chip + namespace. Only the NAME navigates; the rest is inert. */
1660
1664
  <div className="flex-1 min-w-0 flex flex-col gap-0.5">
1661
1665
  <div className="flex items-center gap-1.5 min-w-0">
1662
- <Tooltip content={lane.name} wrapperClassName="min-w-0 flex-1">
1666
+ <Tooltip content={lane.identityAmbiguous ? `${lane.name} · API group unknown` : lane.name} wrapperClassName="min-w-0 flex-1">
1663
1667
  <span
1664
- onClick={() => handleLaneOpen(lane.kind, lane.namespace, lane.name, lane.group)}
1665
- className={clsx('min-w-0 w-full text-sm text-theme-text-primary hover:text-accent-text hover:underline cursor-pointer', compact ? 'font-medium' : 'font-semibold font-mono')}
1668
+ onClick={lane.identityAmbiguous ? undefined : () => handleLaneOpen(lane.kind, lane.namespace, lane.name, lane.group, lane.identityResolved)}
1669
+ className={clsx('min-w-0 w-full text-sm text-theme-text-primary', !lane.identityAmbiguous && 'hover:text-accent-text hover:underline cursor-pointer', compact ? 'font-medium' : 'font-semibold font-mono')}
1666
1670
  >
1667
1671
  <MiddleEllipsis text={lane.name} className="block" />
1668
1672
  </span>
@@ -1715,7 +1719,7 @@ export function TimelineSwimlanes({ events, isLoading, onResourceClick, viewMode
1715
1719
  hasChildren={hasVisibleChildren}
1716
1720
  expanded={isExpanded}
1717
1721
  onToggle={hasVisibleChildren ? () => toggleLane(lane.id) : undefined}
1718
- onClick={() => handleLaneOpen(lane.kind, lane.namespace, lane.name, lane.group)}
1722
+ onClick={lane.identityAmbiguous ? undefined : () => handleLaneOpen(lane.kind, lane.namespace, lane.name, lane.group, lane.identityResolved)}
1719
1723
  pinButton={renderPinButton(lane)}
1720
1724
  title={
1721
1725
  lane.nestedByContract ? `${lane.name} · linked by naming`
@@ -2116,6 +2120,7 @@ export function TimelineSwimlanes({ events, isLoading, onResourceClick, viewMode
2116
2120
  onClose={closeDrawer}
2117
2121
  onResourceClick={onResourceClick}
2118
2122
  allEvents={filteredEvents}
2123
+ resourceLanes={lanes}
2119
2124
  />
2120
2125
  )}
2121
2126
  </div>
@@ -2251,7 +2256,7 @@ function GroupChip({ group }: { group: string }) {
2251
2256
  )
2252
2257
  }
2253
2258
 
2254
- function ChildLaneLabel({ kind, group, showGroupChip, kindTitle, name, labelWidthClass = 'w-[360px]', isLast, onClick, pinButton, title, depth = 1, hasChildren, expanded, onToggle }: { kind: string; group?: string; showGroupChip?: boolean; kindTitle?: string; name: string; labelWidthClass?: string; isLast: boolean; onClick: () => void; pinButton?: React.ReactNode; title?: string; depth?: number; hasChildren?: boolean; expanded?: boolean; onToggle?: () => void }) {
2259
+ function ChildLaneLabel({ kind, group, showGroupChip, kindTitle, name, labelWidthClass = 'w-[360px]', isLast, onClick, pinButton, title, depth = 1, hasChildren, expanded, onToggle }: { kind: string; group?: string; showGroupChip?: boolean; kindTitle?: string; name: string; labelWidthClass?: string; isLast: boolean; onClick?: () => void; pinButton?: React.ReactNode; title?: string; depth?: number; hasChildren?: boolean; expanded?: boolean; onToggle?: () => void }) {
2255
2260
  // Tree rails: the INCOMING trunk sits under the parent's chevron (rail d-1), the
2256
2261
  // row's own chevron sits on its CHILDREN's rail (rail d). Deriving both from one
2257
2262
  // ROOT keeps every level's vertical aligned under the chevron above it.
@@ -2293,7 +2298,7 @@ function ChildLaneLabel({ kind, group, showGroupChip, kindTitle, name, labelWidt
2293
2298
  <Tooltip content={title ?? name} wrapperClassName="min-w-0 flex-1">
2294
2299
  <span
2295
2300
  onClick={onClick}
2296
- className="min-w-0 w-full text-[13px] font-mono text-theme-text-secondary hover:text-accent-text hover:underline cursor-pointer"
2301
+ className={clsx("min-w-0 w-full text-[13px] font-mono text-theme-text-secondary", onClick && "hover:text-accent-text hover:underline cursor-pointer")}
2297
2302
  >
2298
2303
  <MiddleEllipsis text={name} className="block" />
2299
2304
  </span>
@@ -3003,6 +3008,7 @@ interface EventDetailPanelProps {
3003
3008
  // Every event in view — powers the ±15-min correlation feed ("what else
3004
3009
  // happened around this deploy?"). Absent → the correlation section is omitted.
3005
3010
  allEvents?: TimelineEvent[]
3011
+ resourceLanes?: BaseResourceLane[]
3006
3012
  }
3007
3013
 
3008
3014
  // The ±15-min window a single event's rail pulls correlated neighbours from
@@ -3044,7 +3050,7 @@ function ClusterEventRow({ event, active, onClick }: { event: TimelineEvent; act
3044
3050
  )
3045
3051
  }
3046
3052
 
3047
- export function EventDetailPanel({ events, selectedId, onSelectId, onClose, onResourceClick, allEvents }: EventDetailPanelProps) {
3053
+ export function EventDetailPanel({ events, selectedId, onSelectId, onClose, onResourceClick, allEvents, resourceLanes }: EventDetailPanelProps) {
3048
3054
  // ONE drawer anatomy: rail left, detail right — for one event or
3049
3055
  // fifty. The shape never changes with count, so muscle memory holds. A single
3050
3056
  // clicked dot renders as a rail of one plus its ±15-min correlated neighbors,
@@ -3094,16 +3100,30 @@ export function EventDetailPanel({ events, selectedId, onSelectId, onClose, onRe
3094
3100
  // Index where correlated neighbors start (single-origin rails only) — a
3095
3101
  // divider separates "the dot you clicked" from "what happened around it".
3096
3102
  const neighborsFrom = events.length === 1 && railEvents.length > 1 ? 1 : -1
3097
- const openResource = () =>
3103
+ const selectedLane = useMemo(() => {
3104
+ const pending = [...(resourceLanes ?? [])];
3105
+ while (pending.length > 0) {
3106
+ const lane = pending.pop()!;
3107
+ if (lane.events.some(event => event.id === selected.id)) return lane;
3108
+ pending.push(...(lane.children ?? []));
3109
+ }
3110
+ return undefined;
3111
+ }, [resourceLanes, selected.id]);
3112
+ const group = selected.apiVersion
3113
+ ? apiVersionToGroup(selected.apiVersion)
3114
+ : selectedLane?.identityResolved && selectedLane.kind === selected.kind
3115
+ ? selectedLane.group
3116
+ : builtinGroupForKind(selected.kind);
3117
+ const canOpenResource = !selectedLane?.identityAmbiguous && group !== undefined;
3118
+ const openResource = () => {
3119
+ if (!canOpenResource) return;
3098
3120
  onResourceClick?.({
3099
- kind: kindToPluralWithGroup(
3100
- selected.kind,
3101
- apiVersionToGroup(selected.apiVersion),
3102
- ),
3121
+ kind: kindToPluralWithGroup(selected.kind, group ?? ''),
3103
3122
  namespace: selected.namespace,
3104
3123
  name: selected.name,
3105
- group: apiVersionToGroup(selected.apiVersion),
3106
- })
3124
+ group,
3125
+ });
3126
+ };
3107
3127
 
3108
3128
  return (
3109
3129
  <div
@@ -3205,8 +3225,8 @@ export function EventDetailPanel({ events, selectedId, onSelectId, onClose, onRe
3205
3225
  {/* The ONE click target for the resource — the same badge used by
3206
3226
  every Radar drawer, so it reads as "this navigates". */}
3207
3227
  <ResourceRefBadge
3208
- resourceRef={{ kind: selected.kind || 'Event', namespace: selected.namespace ?? '', name: selected.name }}
3209
- onClick={onResourceClick ? openResource : undefined}
3228
+ resourceRef={{ kind: selected.kind || 'Event', namespace: selected.namespace ?? '', name: selected.name, group }}
3229
+ onClick={onResourceClick && canOpenResource ? openResource : undefined}
3210
3230
  />
3211
3231
  {selected.namespace && <span className="text-xs text-theme-text-tertiary">in {selected.namespace}</span>}
3212
3232
  </dd>
@@ -130,10 +130,10 @@ function getIssueTooltip(issue: string | undefined): React.ReactNode {
130
130
  action: "Check the webhook policy that denied the request.",
131
131
  },
132
132
  WebhookUnavailable: {
133
- title: "Admission Webhook Unavailable",
133
+ title: "Admission Webhook Call Failed",
134
134
  description:
135
- "Pod creation could not reach a required admission webhook backend.",
136
- action: "Restore the webhook Service and its ready endpoints.",
135
+ "Pod creation was blocked because an admission webhook call failed.",
136
+ action: "Check the reported error, webhook connectivity, TLS, and backend health.",
137
137
  },
138
138
  Evicted: {
139
139
  title: "Pod Evicted",
@@ -301,6 +301,10 @@ export function CodeViewer({
301
301
  const handleSearchKeyDown = useCallback((e: React.KeyboardEvent) => {
302
302
  if (e.key === 'Escape') {
303
303
  e.preventDefault()
304
+ // A host that also listens for Escape to close something bigger around
305
+ // this viewer (a full-screen overlay, say) must not see this one — the
306
+ // user closing the search bar, not the whole surface it's embedded in.
307
+ e.stopPropagation()
304
308
  closeSearch()
305
309
  } else if (e.key === 'Enter') {
306
310
  e.preventDefault()
@@ -1,6 +1,6 @@
1
1
  import { createContext, useContext, useState, useCallback, ReactNode, useEffect } from 'react'
2
2
  import { DURATION_TOAST_EXIT } from '../../utils/animation'
3
- import { Check, Terminal, X, AlertTriangle } from 'lucide-react'
3
+ import { Check, Terminal, X, AlertTriangle, Info } from 'lucide-react'
4
4
  import { clsx } from 'clsx'
5
5
 
6
6
  interface Toast {
@@ -172,6 +172,7 @@ function ToastItem({ toast, onDismiss }: { toast: Toast; onDismiss: () => void }
172
172
 
173
173
  const isError = toast.type === 'error'
174
174
  const isSuccess = toast.type === 'success'
175
+ const isNotice = toast.type === 'info' || toast.type === 'warning'
175
176
 
176
177
  return (
177
178
  <div
@@ -191,10 +192,15 @@ function ToastItem({ toast, onDismiss }: { toast: Toast; onDismiss: () => void }
191
192
  <div className={clsx(
192
193
  'w-8 h-8 rounded-full flex items-center justify-center shrink-0',
193
194
  isError ? 'bg-red-500/20' :
194
- isSuccess ? 'bg-emerald-500/20' : 'bg-blue-500/20'
195
+ isSuccess ? 'bg-emerald-500/20' :
196
+ toast.type === 'warning' ? 'bg-amber-500/20' : 'bg-blue-500/20'
195
197
  )}>
196
198
  {isError ? (
197
199
  <AlertTriangle className="w-4 h-4 text-red-400" />
200
+ ) : toast.type === 'warning' ? (
201
+ <AlertTriangle className="w-4 h-4 text-amber-400" />
202
+ ) : toast.type === 'info' ? (
203
+ <Info className="w-4 h-4 text-blue-400" />
198
204
  ) : toast.command ? (
199
205
  <Terminal className={clsx('w-4 h-4', isSuccess ? 'text-emerald-400' : 'text-blue-400')} />
200
206
  ) : (
@@ -208,7 +214,7 @@ function ToastItem({ toast, onDismiss }: { toast: Toast; onDismiss: () => void }
208
214
  <span className={clsx('text-sm font-medium', isError ? 'text-red-200' : isSuccess ? 'text-emerald-50' : 'text-theme-text-primary')}>
209
215
  {toast.message}
210
216
  </span>
211
- {!isError && !toast.action && <Check className={clsx('w-3.5 h-3.5 shrink-0', isSuccess ? 'text-emerald-400' : 'text-green-400')} />}
217
+ {!isError && !isNotice && !toast.action && <Check className={clsx('w-3.5 h-3.5 shrink-0', isSuccess ? 'text-emerald-400' : 'text-green-400')} />}
212
218
  </div>
213
219
  {toast.detail && (
214
220
  toast.onDetailClick ? (
@@ -292,8 +292,10 @@ export function defaultConditionTone(cond: { type?: string; status?: string }):
292
292
  export function ConditionsSection({
293
293
  conditions,
294
294
  getConditionTone,
295
+ defaultExpanded,
295
296
  }: {
296
297
  conditions?: any[]
298
+ defaultExpanded?: boolean
297
299
  getConditionTone?: (condition: any) => ConditionTone | undefined
298
300
  }) {
299
301
  if (!conditions || conditions.length === 0) return null
@@ -312,7 +314,7 @@ export function ConditionsSection({
312
314
  return (
313
315
  <Section
314
316
  title={`Conditions (${conditions.length})${failCount > 0 ? ` · ${failCount} failing` : ''}`}
315
- defaultExpanded={conditions.length <= 6}
317
+ defaultExpanded={defaultExpanded ?? conditions.length <= 6}
316
318
  >
317
319
  <div className="relative">
318
320
  {/* Timeline line — sits between timestamp column and dot */}
@@ -0,0 +1,154 @@
1
+ import type { ReactNode } from 'react'
2
+ import clsx from 'clsx'
3
+ import { Collapse } from '../ui/Collapse'
4
+ import type { WorkloadPodInfo } from '../../types'
5
+ import type { NavigateToResource } from '../../utils/navigation'
6
+ import { formatAge } from '../resources/resource-utils'
7
+
8
+ export function PodRow({
9
+ name,
10
+ namespace,
11
+ ready,
12
+ healthLevel,
13
+ detail,
14
+ onNavigate,
15
+ }: {
16
+ name: string
17
+ namespace: string
18
+ ready: boolean | null
19
+ healthLevel?: WorkloadPodInfo['healthLevel']
20
+ detail: string
21
+ onNavigate?: NavigateToResource
22
+ }) {
23
+ const statusLabel = podStatusLabel(healthLevel, ready)
24
+ const statusClass = podStatusClass(healthLevel, ready)
25
+ const content = (
26
+ <>
27
+ <span className={clsx('h-2 w-2 shrink-0 rounded-full', podDotClass(healthLevel, ready))} />
28
+ <span className="min-w-0 flex-1">
29
+ <span className="block truncate text-sm font-medium text-theme-text-primary">{name}</span>
30
+ <span className="block truncate text-xs text-theme-text-tertiary">{detail}</span>
31
+ </span>
32
+ <span className={clsx('badge-sm shrink-0', statusClass)}>
33
+ {statusLabel}
34
+ </span>
35
+ </>
36
+ )
37
+
38
+ if (!onNavigate) {
39
+ return (
40
+ <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">
41
+ {content}
42
+ </div>
43
+ )
44
+ }
45
+
46
+ return (
47
+ <button
48
+ type="button"
49
+ onClick={() => onNavigate({ kind: 'pods', namespace, name })}
50
+ 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"
51
+ >
52
+ {content}
53
+ </button>
54
+ )
55
+ }
56
+
57
+ export function PodListFrame({
58
+ expanded,
59
+ hasOverflow,
60
+ overflow,
61
+ toggle,
62
+ children,
63
+ }: {
64
+ expanded: boolean
65
+ hasOverflow: boolean
66
+ overflow?: ReactNode
67
+ toggle?: ReactNode
68
+ children: ReactNode
69
+ }) {
70
+ return (
71
+ <div className="space-y-2">
72
+ <div className="space-y-2">
73
+ {children}
74
+ </div>
75
+ {hasOverflow && (
76
+ <Collapse open={expanded}>
77
+ {/* Long overflow lists scroll inside a capped box once open; the
78
+ cap comes off while closed so the collapse measures to zero. */}
79
+ <div className={clsx(expanded && 'max-h-[26rem] overflow-y-auto pr-1')}>
80
+ <div className="space-y-2">
81
+ {overflow}
82
+ </div>
83
+ </div>
84
+ </Collapse>
85
+ )}
86
+ {toggle}
87
+ </div>
88
+ )
89
+ }
90
+
91
+ export function PodListToggle({
92
+ expanded,
93
+ hiddenCount,
94
+ label,
95
+ onToggle,
96
+ }: {
97
+ expanded: boolean
98
+ hiddenCount: number
99
+ label: string
100
+ onToggle: () => void
101
+ }) {
102
+ if (!expanded && hiddenCount <= 0) return null
103
+
104
+ return (
105
+ <div className="pt-1">
106
+ <button
107
+ type="button"
108
+ aria-expanded={expanded}
109
+ onClick={onToggle}
110
+ className="text-xs font-medium text-accent-text hover:underline"
111
+ >
112
+ {expanded ? `Collapse ${label}` : `Show ${hiddenCount} more ${label}`}
113
+ </button>
114
+ </div>
115
+ )
116
+ }
117
+
118
+ export function workloadPodDetail(pod: WorkloadPodInfo): string {
119
+ const parts: string[] = []
120
+ if (pod.phase) parts.push(pod.reason ? `${pod.phase} / ${pod.reason}` : pod.phase)
121
+ else if (pod.reason) parts.push(pod.reason)
122
+ parts.push(`${pod.containers.length} container${pod.containers.length === 1 ? '' : 's'}`)
123
+ if ((pod.restartCount ?? 0) > 0) parts.push(`${pod.restartCount} restart${pod.restartCount === 1 ? '' : 's'}`)
124
+ if (pod.lastTerminationReason && !['Unknown', 'Completed'].includes(pod.lastTerminationReason)) {
125
+ parts.push(`last ${pod.lastTerminationReason}`)
126
+ }
127
+ if (pod.createdAt) parts.push(`${formatAge(pod.createdAt)} old`)
128
+ return parts.join(' · ')
129
+ }
130
+
131
+ export function podStatusLabel(healthLevel: WorkloadPodInfo['healthLevel'] | undefined, ready: boolean | null): string {
132
+ if (healthLevel === 'unhealthy') return 'Unhealthy'
133
+ if (healthLevel === 'degraded') return 'Degraded'
134
+ if (healthLevel === 'neutral') return ready ? 'Ready' : 'Neutral'
135
+ if (healthLevel === 'unknown') return 'Unknown'
136
+ return ready === null ? 'Unknown' : ready ? 'Ready' : 'Not ready'
137
+ }
138
+
139
+ export function podStatusClass(healthLevel: WorkloadPodInfo['healthLevel'] | undefined, ready: boolean | null): string {
140
+ if (healthLevel === 'unhealthy') return 'status-unhealthy'
141
+ if (healthLevel === 'degraded') return 'status-degraded'
142
+ if (healthLevel === 'neutral') return 'status-neutral'
143
+ if (healthLevel === 'unknown' || ready === null) return 'status-unknown'
144
+ return ready ? 'status-healthy' : 'status-degraded'
145
+ }
146
+
147
+ export function podDotClass(healthLevel: WorkloadPodInfo['healthLevel'] | undefined, ready: boolean | null): string {
148
+ if (healthLevel === 'unhealthy') return 'bg-red-500'
149
+ if (healthLevel === 'degraded') return 'bg-amber-500'
150
+ if (healthLevel === 'neutral') return 'bg-skyhook-500'
151
+ if (healthLevel === 'unknown' || ready === null) return 'bg-theme-text-tertiary'
152
+ return ready ? 'bg-emerald-500' : 'bg-amber-500'
153
+ }
154
+