@skyhook-io/k8s-ui 1.16.3 → 1.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (121) hide show
  1. package/package.json +1 -1
  2. package/src/components/applications/ApplicationDetail.test.tsx +15 -0
  3. package/src/components/applications/ApplicationDetail.tsx +17 -15
  4. package/src/components/checks/types.ts +2 -13
  5. package/src/components/cnpg/primitives.tsx +3 -3
  6. package/src/components/gitops/GitOpsTableView.tsx +9 -24
  7. package/src/components/gitops/detail-helpers.ts +1 -1
  8. package/src/components/gitops/insights/GitOpsInsightViews.tsx +5 -12
  9. package/src/components/gitops/tree/GitOpsTreeGraph.tsx +2 -5
  10. package/src/components/issues/issue-timing.ts +2 -2
  11. package/src/components/issues/issues.test.ts +1 -1
  12. package/src/components/logs/WorkloadLogsViewer.notice.test.tsx +59 -0
  13. package/src/components/logs/WorkloadLogsViewer.tsx +5 -0
  14. package/src/components/logs/useLogStream.ts +11 -0
  15. package/src/components/resources/ResourcesView.tsx +7 -8
  16. package/src/components/resources/renderers/AnalysisRunRenderer.tsx +3 -3
  17. package/src/components/resources/renderers/ArgoApplicationRenderer.tsx +6 -5
  18. package/src/components/resources/renderers/CAPIClusterRenderer.tsx +2 -2
  19. package/src/components/resources/renderers/CAPIKubeadmControlPlaneRenderer.tsx +2 -2
  20. package/src/components/resources/renderers/CAPIMachineDeploymentRenderer.tsx +2 -2
  21. package/src/components/resources/renderers/CAPIMachinePoolRenderer.tsx +2 -2
  22. package/src/components/resources/renderers/CAPIMachineRenderer.tsx +2 -2
  23. package/src/components/resources/renderers/CAPIMachineSetRenderer.tsx +2 -2
  24. package/src/components/resources/renderers/CNPGClusterRenderer.tsx +2 -1
  25. package/src/components/resources/renderers/CNPGScheduledBackupRenderer.tsx +2 -2
  26. package/src/components/resources/renderers/CNPGShared.tsx +2 -2
  27. package/src/components/resources/renderers/ClusterComplianceReportRenderer.tsx +2 -2
  28. package/src/components/resources/renderers/ConfigAuditReportRenderer.tsx +2 -2
  29. package/src/components/resources/renderers/CronJobRenderer.tsx +6 -4
  30. package/src/components/resources/renderers/CronWorkflowRenderer.tsx +3 -2
  31. package/src/components/resources/renderers/EndpointSliceRenderer.test.tsx +24 -0
  32. package/src/components/resources/renderers/EndpointSliceRenderer.tsx +1 -1
  33. package/src/components/resources/renderers/EventRenderer.tsx +3 -3
  34. package/src/components/resources/renderers/ExperimentRenderer.tsx +2 -2
  35. package/src/components/resources/renderers/ExposedSecretReportRenderer.tsx +2 -2
  36. package/src/components/resources/renderers/ExternalSecretRenderer.tsx +2 -2
  37. package/src/components/resources/renderers/FluxHelmReleaseRenderer.tsx +4 -4
  38. package/src/components/resources/renderers/GRPCRouteRenderer.tsx +11 -24
  39. package/src/components/resources/renderers/GatewayRouteReferences.test.tsx +63 -0
  40. package/src/components/resources/renderers/GitRepositoryRenderer.tsx +3 -3
  41. package/src/components/resources/renderers/HPARenderer.tsx +2 -2
  42. package/src/components/resources/renderers/HTTPRouteRenderer.tsx +12 -25
  43. package/src/components/resources/renderers/HelmRepositoryRenderer.tsx +3 -3
  44. package/src/components/resources/renderers/IngressClassReferences.test.tsx +52 -0
  45. package/src/components/resources/renderers/IngressClassRenderer.tsx +10 -3
  46. package/src/components/resources/renderers/IngressRenderer.tsx +6 -2
  47. package/src/components/resources/renderers/KarpenterNodeClaimRenderer.tsx +2 -14
  48. package/src/components/resources/renderers/KedaCredentials.test.tsx +85 -0
  49. package/src/components/resources/renderers/KedaScaledObjectRenderer.tsx +2 -2
  50. package/src/components/resources/renderers/KedaTriggerAuthRenderer.tsx +6 -3
  51. package/src/components/resources/renderers/KustomizationRenderer.tsx +2 -2
  52. package/src/components/resources/renderers/KyvernoGlobalContextRenderer.tsx +3 -2
  53. package/src/components/resources/renderers/KyvernoQueueRenderers.tsx +4 -3
  54. package/src/components/resources/renderers/LeaseRenderer.tsx +3 -15
  55. package/src/components/resources/renderers/NamespaceRenderer.test.ts +69 -2
  56. package/src/components/resources/renderers/NamespaceRenderer.tsx +6 -1
  57. package/src/components/resources/renderers/NodeRenderer.test.tsx +81 -1
  58. package/src/components/resources/renderers/NodeRenderer.tsx +56 -15
  59. package/src/components/resources/renderers/OCIRepositoryRenderer.tsx +3 -3
  60. package/src/components/resources/renderers/PodClassReferences.test.tsx +42 -0
  61. package/src/components/resources/renderers/PodNomination.test.tsx +57 -0
  62. package/src/components/resources/renderers/PodRenderer.tsx +21 -7
  63. package/src/components/resources/renderers/RolloutRenderer.test.tsx +1 -1
  64. package/src/components/resources/renderers/RolloutRenderer.tsx +3 -2
  65. package/src/components/resources/renderers/SbomReportRenderer.tsx +2 -2
  66. package/src/components/resources/renderers/SecretRenderer.test.tsx +88 -0
  67. package/src/components/resources/renderers/SecretRenderer.tsx +34 -5
  68. package/src/components/resources/renderers/ServiceRenderer.test.tsx +43 -0
  69. package/src/components/resources/renderers/ServiceRenderer.tsx +11 -3
  70. package/src/components/resources/renderers/SimpleRouteRenderer.tsx +8 -21
  71. package/src/components/resources/renderers/VeleroBSLRenderer.tsx +5 -7
  72. package/src/components/resources/renderers/VeleroBackupRenderer.tsx +3 -3
  73. package/src/components/resources/renderers/VeleroBackupRepositoryRenderer.tsx +2 -2
  74. package/src/components/resources/renderers/VeleroRestoreRenderer.tsx +3 -3
  75. package/src/components/resources/renderers/VeleroScheduleRenderer.tsx +2 -2
  76. package/src/components/resources/renderers/VulnerabilityReportRenderer.tsx +2 -2
  77. package/src/components/resources/renderers/WorkflowRenderer.tsx +4 -3
  78. package/src/components/resources/renderers/kyverno-modern-cells.tsx +1 -1
  79. package/src/components/resources/renderers/rollout/ReplicaSetProgression.tsx +2 -2
  80. package/src/components/resources/resource-utils-cnpg.ts +0 -4
  81. package/src/components/resources/resource-utils-keda.ts +1 -7
  82. package/src/components/resources/resource-utils-kyverno.ts +1 -7
  83. package/src/components/resources/resource-utils-velero.ts +0 -12
  84. package/src/components/resources/resource-utils.ts +17 -33
  85. package/src/components/shared/DrainPlanDialog.tsx +1 -0
  86. package/src/components/shared/GitOpsWriteWarning.test.tsx +86 -0
  87. package/src/components/shared/GitOpsWriteWarning.tsx +135 -0
  88. package/src/components/shared/ResourceActionsBar.tsx +9 -20
  89. package/src/components/shared/ResourceRendererDispatch.tsx +1 -1
  90. package/src/components/shared/SetImageDialog.test.ts +75 -6
  91. package/src/components/shared/SetImageDialog.tsx +92 -84
  92. package/src/components/shared/index.ts +2 -1
  93. package/src/components/timeline/TimelineList.tsx +2 -15
  94. package/src/components/timeline/TimelineSwimlanes.tsx +2 -10
  95. package/src/components/topology/TopologyGraph.tsx +5 -3
  96. package/src/components/ui/ScheduleValue.test.tsx +5 -0
  97. package/src/components/ui/ScheduleValue.tsx +3 -2
  98. package/src/components/ui/drawer-components.test.tsx +66 -1
  99. package/src/components/ui/drawer-components.tsx +43 -29
  100. package/src/components/workload/WorkloadView.deleted.test.tsx +58 -0
  101. package/src/components/workload/WorkloadView.deletedDialog.test.tsx +45 -0
  102. package/src/components/workload/WorkloadView.secretSave.test.tsx +62 -0
  103. package/src/components/workload/WorkloadView.tsx +120 -43
  104. package/src/types/capacity.ts +10 -0
  105. package/src/types/core.ts +32 -1
  106. package/src/utils/format.test.ts +40 -2
  107. package/src/utils/format.ts +5 -1
  108. package/src/utils/gateway-references.test.ts +17 -0
  109. package/src/utils/gateway-references.ts +29 -0
  110. package/src/utils/gitops-write-guard.test.ts +401 -0
  111. package/src/utils/gitops-write-guard.ts +509 -0
  112. package/src/utils/index.ts +2 -0
  113. package/src/utils/ingress-class-references.ts +22 -0
  114. package/src/utils/navigation.test.ts +13 -0
  115. package/src/utils/navigation.ts +5 -0
  116. package/src/utils/node-lifecycle.test.ts +36 -0
  117. package/src/utils/node-lifecycle.ts +77 -0
  118. package/src/utils/resource-hierarchy.test.ts +32 -0
  119. package/src/utils/resource-hierarchy.ts +28 -2
  120. package/src/utils/resource-refs.test.ts +23 -0
  121. package/src/utils/resource-refs.ts +30 -0
@@ -0,0 +1,58 @@
1
+ import { renderToStaticMarkup } from 'react-dom/server'
2
+ import { describe, expect, it, vi } from 'vitest'
3
+
4
+ import { WorkloadView } from './WorkloadView'
5
+
6
+ const pod = {
7
+ apiVersion: 'v1',
8
+ kind: 'Pod',
9
+ metadata: { name: 'web-1', namespace: 'shop' },
10
+ spec: { containers: [{ name: 'app', image: 'nginx' }] },
11
+ status: { phase: 'Running', containerStatuses: [{ name: 'app', ready: true, state: { running: {} } }] },
12
+ }
13
+
14
+ function render(resourceError: unknown) {
15
+ return renderToStaticMarkup(
16
+ <WorkloadView
17
+ kind="pods"
18
+ namespace="shop"
19
+ name="web-1"
20
+ onBack={vi.fn()}
21
+ resource={pod}
22
+ resourceError={resourceError}
23
+ relationships={{ deployment: { kind: 'Deployment', namespace: 'shop', name: 'web' } }}
24
+ onNavigateToResource={vi.fn()}
25
+ actionsBarProps={{ canExec: true, canViewLogs: true, onOpenTerminal: vi.fn(), onOpenLogs: vi.fn(), onDelete: vi.fn(), renderDiagnose: () => 'Diagnose' }}
26
+ hasOperationalIssues
27
+ renderOverviewLead={() => 'Host issues panel'}
28
+ renderOverviewExtra={() => 'Host reachability panel'}
29
+ renderDiagnoseTab={() => 'Host reachability tab'}
30
+ reachableVia={[{ kind: 'Service', namespace: 'shop', name: 'web' }]}
31
+ />,
32
+ )
33
+ }
34
+
35
+ describe('WorkloadView for an object deleted while open', () => {
36
+ it('says it is gone, names its owner, and offers nothing that acts on it', () => {
37
+ const html = render(Object.assign(new Error('pods "web-1" not found'), { status: 404 }))
38
+ expect(html).toContain('This Pod no longer exists in the cluster')
39
+ expect(html).toContain('Go to its Deployment web')
40
+ expect(html).toContain('>Deleted<')
41
+ expect(html).not.toContain('Terminal')
42
+ expect(html).not.toContain('Diagnose')
43
+ expect(html).not.toContain('Host issues panel')
44
+ expect(html).not.toContain('Host reachability panel')
45
+ expect(html).not.toContain('Reachability')
46
+ })
47
+
48
+ it('shows the live view when the fetch failed for another reason', () => {
49
+ const html = render(Object.assign(new Error('boom'), { status: 500 }))
50
+ expect(html).not.toContain('no longer exists')
51
+ expect(html).not.toContain('>Deleted<')
52
+ expect(html).toContain('Terminal')
53
+ expect(html).toContain('Diagnose')
54
+ expect(html).toContain('Host issues panel')
55
+ expect(html).toContain('Host reachability panel')
56
+ expect(html).toContain('Reachability')
57
+ })
58
+ })
@@ -0,0 +1,45 @@
1
+ // @vitest-environment jsdom
2
+ import { act } from 'react'
3
+ import { createRoot } from 'react-dom/client'
4
+ import { expect, it, vi } from 'vitest'
5
+
6
+ import { WorkloadView } from './WorkloadView'
7
+
8
+ vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true)
9
+
10
+ const deployment = {
11
+ apiVersion: 'apps/v1',
12
+ kind: 'Deployment',
13
+ metadata: { name: 'web', namespace: 'shop' },
14
+ spec: { replicas: 1, template: { spec: { containers: [{ name: 'app', image: 'nginx' }] } } },
15
+ status: { replicas: 1, readyReplicas: 1, availableReplicas: 1 },
16
+ }
17
+
18
+ it('closes an open Delete dialog when the object is deleted elsewhere', async () => {
19
+ const element = document.createElement('div')
20
+ document.body.append(element)
21
+ const root = createRoot(element)
22
+ const render = (resourceError: unknown) =>
23
+ root.render(
24
+ <WorkloadView
25
+ kind="deployments"
26
+ namespace="shop"
27
+ name="web"
28
+ onBack={vi.fn()}
29
+ resource={deployment}
30
+ resourceError={resourceError}
31
+ actionsBarProps={{ onDelete: vi.fn() }}
32
+ />,
33
+ )
34
+ const dialogOpen = () => document.body.textContent?.includes('Are you sure you want to delete') ?? false
35
+
36
+ await act(async () => render(null))
37
+ await act(async () => { element.querySelector<HTMLButtonElement>('button:has(svg.lucide-trash-2)')!.click() })
38
+ expect(dialogOpen()).toBe(true)
39
+
40
+ await act(async () => render(Object.assign(new Error('deployments.apps "web" not found'), { status: 404 })))
41
+ expect(dialogOpen()).toBe(false)
42
+
43
+ await act(async () => root.unmount())
44
+ element.remove()
45
+ })
@@ -0,0 +1,62 @@
1
+ // @vitest-environment jsdom
2
+ import { act } from 'react'
3
+ import { createRoot } from 'react-dom/client'
4
+ import { expect, it, vi } from 'vitest'
5
+
6
+ import { WorkloadView } from './WorkloadView'
7
+
8
+ vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true)
9
+
10
+ const secret = {
11
+ apiVersion: 'v1',
12
+ kind: 'Secret',
13
+ metadata: { name: 'db', namespace: 'shop', resourceVersion: '7' },
14
+ type: 'Opaque',
15
+ data: { password: btoa('old') },
16
+ }
17
+
18
+ function button(label: string): HTMLButtonElement | undefined {
19
+ return [...document.querySelectorAll('button')].find(b => b.textContent?.trim() === label || b.title === label)
20
+ }
21
+
22
+ it('saves a Secret value against the version on screen, and offers no edit once the Secret is gone', async () => {
23
+ const element = document.createElement('div')
24
+ document.body.append(element)
25
+ const root = createRoot(element)
26
+ const onUpdateResource = vi.fn().mockResolvedValue(undefined)
27
+ const render = (resourceError: unknown) =>
28
+ root.render(
29
+ <WorkloadView
30
+ kind="secrets"
31
+ namespace="shop"
32
+ name="db"
33
+ onBack={vi.fn()}
34
+ resource={secret}
35
+ resourceError={resourceError}
36
+ canUpdateSecrets
37
+ onUpdateResource={onUpdateResource}
38
+ />,
39
+ )
40
+
41
+ await act(async () => render(null))
42
+ await act(async () => button('Reveal')!.click())
43
+ await act(async () => button('Edit value')!.click())
44
+ await act(async () => button('Save')!.click())
45
+ await act(async () => button('Update')!.click())
46
+ expect(onUpdateResource).toHaveBeenCalledWith(expect.objectContaining({
47
+ kind: 'secrets',
48
+ namespace: 'shop',
49
+ name: 'db',
50
+ reviewedResourceVersion: '7',
51
+ }))
52
+
53
+ await act(async () => button('Edit value')!.click())
54
+ expect(element.querySelector('textarea')).not.toBeNull()
55
+ await act(async () => render(Object.assign(new Error('secrets "db" not found'), { status: 404 })))
56
+ expect(element.textContent).toContain('This Secret no longer exists in the cluster')
57
+ expect(element.querySelector('textarea')).toBeNull()
58
+ expect(button('Edit value')).toBeUndefined()
59
+
60
+ await act(async () => root.unmount())
61
+ element.remove()
62
+ })
@@ -1,3 +1,4 @@
1
+ import { dedupeResourceRefs } from '../../utils/resource-refs'
1
2
  import { PodRow, PodListFrame, PodListToggle, workloadPodDetail } from './PodList'
2
3
  export { workloadPodDetail, podStatusLabel, podStatusClass, podDotClass } from './PodList'
3
4
  import { useState, useMemo, useEffect, useRef, useCallback, type ComponentType, type ReactNode } from 'react'
@@ -37,6 +38,7 @@ import {
37
38
  } from 'lucide-react'
38
39
  import type { TimelineEvent, ResourceRef, Relationships, SelectedResource, ResolvedEnvFrom, Topology, TopologyNode, HPADiagnosis, WorkloadPodInfo } from '../../types'
39
40
  import type { GitOpsStatus } from '../../types/gitops'
41
+ import { isFetchError } from '../../types/fetch-error'
40
42
  import type { NavigateToResource } from '../../utils/navigation'
41
43
  import { refToSelectedResource, pluralToKind, knownKindForPluralWithGroup, kindToPlural, kindToPluralWithGroup, apiVersionToGroup } from '../../utils/navigation'
42
44
  import { neighborhoodFor, seedNodeIds, topologyNodeResourceKind } from '../../utils/topology-neighborhood'
@@ -76,6 +78,7 @@ import { ServicePortCards, type ServicePortRenderProps } from '../resources/rend
76
78
  import { rolloutMayAdvanceAutomatically, type WorkloadRolloutActivity } from '../../utils/workload-rollout'
77
79
  import { WorkloadRolloutNotice } from './WorkloadRolloutNotice'
78
80
  import { isCoreBatchJob } from '../../utils/api-resources'
81
+ import { formatRelativeAgeTime } from '../../utils/format'
79
82
 
80
83
  export type WorkloadTabType = 'overview' | 'spec' | 'topology' | 'timeline' | 'logs' | 'metrics' | 'reachability' | 'cost' | 'yaml'
81
84
  type TabType = WorkloadTabType
@@ -281,6 +284,8 @@ interface WorkloadViewProps {
281
284
  * resource rather than from owner labels on a managed object.
282
285
  */
283
286
  onNavigateGitOpsPath?: (path: string) => void
287
+ /** Opens the live traffic view focused on this workload. */
288
+ onOpenLiveTraffic?: () => void
284
289
 
285
290
  // ── Render props for platform-specific content ───────────────────────────
286
291
  /** Render the logs tab content */
@@ -494,6 +499,7 @@ export function WorkloadView({
494
499
  helmOwnerSource,
495
500
  onOpenHelmRelease,
496
501
  onNavigateGitOpsPath,
502
+ onOpenLiveTraffic,
497
503
  }: WorkloadViewProps) {
498
504
  // The live object preserves exact CRD capitalization (RayJob, TFJob); the URL
499
505
  // plural remains the API resource name used by fetches and mutations.
@@ -688,6 +694,11 @@ export function WorkloadView({
688
694
  setTimeout(() => setCopied(null), 2000)
689
695
  }, [])
690
696
 
697
+ // The edit is applied on top of the copy on screen, so the server is asked to
698
+ // reject it if the Secret was deleted or changed since that copy loaded.
699
+ // Otherwise the save would recreate a deleted Secret, or put back the old
700
+ // value of a key someone else changed.
701
+ const resourceVersion: string | undefined = resource?.metadata?.resourceVersion
691
702
  const handleSaveSecretValue = useCallback(async (yaml: string) => {
692
703
  if (!onUpdateResource) return
693
704
  try {
@@ -697,12 +708,14 @@ export function WorkloadView({
697
708
  name,
698
709
  yaml,
699
710
  force: true,
711
+ reviewedResourceVersion: resourceVersion,
700
712
  })
701
713
  setTimeout(() => refetch(), 1000)
702
- } catch {
703
- // Error handled by mutation (toast)
714
+ } catch (error) {
715
+ refetch()
716
+ throw error
704
717
  }
705
- }, [onUpdateResource, apiKind, namespace, name, refetch])
718
+ }, [onUpdateResource, apiKind, namespace, name, resourceVersion, refetch])
706
719
 
707
720
  const handleSaved = useCallback(() => {
708
721
  setSaveSuccess(true)
@@ -765,7 +778,32 @@ export function WorkloadView({
765
778
  ? getWorkloadDisplayStatus(resource, apiKind, workloadPods)
766
779
  : null
767
780
  const rolloutActivity = rolloutDisplay?.activity ?? null
768
- const status = rolloutDisplay?.status ?? getResourceStatus(apiKind, resource)
781
+ // A 404 with a cached copy: the object was deleted while this view was
782
+ // open. Everything below that copy describes the past.
783
+ const resourceGone = Boolean(resource) && isFetchError(resourceError) && resourceError.status === 404
784
+ const status = resourceGone
785
+ ? { text: 'Deleted', color: 'status-unknown' }
786
+ : rolloutDisplay?.status ?? getResourceStatus(apiKind, resource)
787
+ // The host's actions, and the host's renderers with their per-container
788
+ // terminal, logs, scale and env controls, would act on an object that no
789
+ // longer exists. The plain renderers show the last copy without them.
790
+ const actionsBarPropsForState = resourceGone ? undefined : effectiveActionsBarProps
791
+ const rendererOverridesForState = resourceGone ? undefined : rendererOverrides
792
+ // The host's overview panels and Reachability tab diagnose the live object,
793
+ // and the reachability panel polls for it every few seconds.
794
+ const renderOverviewLeadForState = resourceGone ? undefined : renderOverviewLead
795
+ const renderOverviewExtraForState = resourceGone ? undefined : renderOverviewExtra
796
+ const renderDiagnoseTabForState = resourceGone ? undefined : renderDiagnoseTab
797
+ // A remount closes any dialog the bar had open: once its actions are gone,
798
+ // a confirm button left on screen would do nothing.
799
+ const actionsBarKey = resourceGone ? 'gone' : 'live'
800
+ const deletedNotice = resourceGone ? (
801
+ <DeletedResourceNotice
802
+ kindLabel={displayKindName(apiKind, resource?.kind)}
803
+ owner={relationships?.deployment ?? relationships?.owner}
804
+ onNavigateToResource={onNavigateToResource}
805
+ />
806
+ ) : null
769
807
  const rolloutMayAutoAdvance = rolloutActivity ? rolloutMayAdvanceAutomatically(rolloutActivity) : false
770
808
  useEffect(() => {
771
809
  if (!recentImageSave) return
@@ -778,7 +816,9 @@ export function WorkloadView({
778
816
  const timeout = window.setTimeout(() => setRecentImageSave(false), 15000)
779
817
  return () => window.clearTimeout(timeout)
780
818
  }, [recentImageSave, rolloutActivity?.phase])
781
- useProgressiveRefresh(rolloutMayAutoAdvance || recentImageSave, refetchProp)
819
+ // The rollout state above is the last copy fetched before a 404; a workload
820
+ // the server says is gone has nothing left to roll out.
821
+ useProgressiveRefresh((rolloutMayAutoAdvance || recentImageSave) && !resourceGone, refetchProp)
782
822
  const showOwnershipHeading = kind === 'Pod' && Boolean(ownershipContext)
783
823
  const headerImage = metadata.find(m => m.label === 'Image')?.value
784
824
 
@@ -790,7 +830,7 @@ export function WorkloadView({
790
830
  const renderDiagnose = actionsBarProps?.renderDiagnose as
791
831
  | ((ctx: { kind: string; group?: string; namespace: string; name: string; health?: DiagnoseHealthHint }) => ReactNode)
792
832
  | undefined
793
- const diagnoseAction = renderDiagnose?.({
833
+ const diagnoseAction = resourceGone ? null : renderDiagnose?.({
794
834
  kind: resource?.kind ?? knownKindForPluralWithGroup(apiKind, group ?? '') ?? apiKind,
795
835
  group,
796
836
  namespace,
@@ -827,7 +867,7 @@ export function WorkloadView({
827
867
  id: 'reachability',
828
868
  label: 'Reachability',
829
869
  icon: <Stethoscope className="w-4 h-4" />,
830
- hidden: !(renderDiagnoseTab && (isDiagnoseKind(apiKind, group) || (reachableVia?.length ?? 0) > 0)),
870
+ hidden: !(renderDiagnoseTabForState && (isDiagnoseKind(apiKind, group) || (reachableVia?.length ?? 0) > 0)),
831
871
  },
832
872
  { id: 'cost', label: 'Cost', icon: <Coins className="w-4 h-4" />, hidden: !costTabVisible },
833
873
  { id: 'yaml', label: 'YAML', icon: <FileText className="w-4 h-4" /> },
@@ -929,19 +969,20 @@ export function WorkloadView({
929
969
  </Tooltip>
930
970
  </div>
931
971
  <p className="text-sm text-theme-text-tertiary">{namespace}</p>
932
- {(gitopsOwner || helmOwner || (gitOpsResourcePath && onNavigateGitOpsPath)) && (
972
+ {(gitopsOwner || helmOwner || (gitOpsResourcePath && onNavigateGitOpsPath) || onOpenLiveTraffic) && (
933
973
  <div className="mt-1 flex flex-wrap items-center gap-1.5">
934
974
  {gitopsOwner && <ManagedByChip owner={gitopsOwner} status={gitOpsOwnerStatus} verified={gitOpsOwnerVerified} pending={gitOpsOwnerPending} source={gitOpsOwnerSource} onOpen={onOpenGitOpsResource} />}
935
975
  {helmOwner && <HelmManagedByChip owner={helmOwner} source={helmOwnerSource} onOpen={onOpenHelmRelease} />}
936
976
  {gitOpsResourcePath && onNavigateGitOpsPath && (
937
977
  <OpenInGitOpsChip onClick={() => onNavigateGitOpsPath(gitOpsResourcePath)} />
938
978
  )}
979
+ {onOpenLiveTraffic && <LiveTrafficChip onClick={onOpenLiveTraffic} />}
939
980
  </div>
940
981
  )}
941
982
  </div>
942
983
 
943
984
  {/* Actions bar */}
944
- <ResourceActionsBar resource={selectedResource} data={resource} onClose={onClose} showYaml={showYaml} onToggleYaml={() => switchView(!showYaml)} {...effectiveActionsBarProps} />
985
+ <ResourceActionsBar key={actionsBarKey} resource={selectedResource} data={resource} onClose={onClose} showYaml={showYaml} onToggleYaml={() => switchView(!showYaml)} {...actionsBarPropsForState} />
945
986
  </div>
946
987
 
947
988
  {/* Success animation overlay */}
@@ -956,6 +997,7 @@ export function WorkloadView({
956
997
  damage to this scroll body; ui/Collapse.tsx keeps them from escaping in the first
957
998
  place. */}
958
999
  <div className="relative flex-1 overflow-y-auto" style={{ viewTransitionName: 'drawer-content' }}>
1000
+ {deletedNotice && <div className="px-4 pt-4">{deletedNotice}</div>}
959
1001
  {!resource ? (
960
1002
  // Fill the drawer body so the loading logo centers in it, not in a
961
1003
  // 128px box pinned to the top (matches the splash/PaneLoader centering).
@@ -1007,9 +1049,9 @@ export function WorkloadView({
1007
1049
  drawerSummary
1008
1050
  ) : (
1009
1051
  <>
1010
- {renderOverviewLead && hasOperationalIssues && (
1052
+ {renderOverviewLeadForState && hasOperationalIssues && (
1011
1053
  <div className="px-4 pt-4">
1012
- {renderOverviewLead({ kind, namespace, name })}
1054
+ {renderOverviewLeadForState({ kind, namespace, name })}
1013
1055
  </div>
1014
1056
  )}
1015
1057
  <ResourceRendererDispatch
@@ -1023,9 +1065,9 @@ export function WorkloadView({
1023
1065
  onCopy={copyToClipboard}
1024
1066
  copied={copied}
1025
1067
  onNavigate={onNavigateToResource ? (ref) => onNavigateToResource(refToSelectedResource(ref)) : undefined}
1026
- onSaveSecretValue={canUpdateSecrets ? handleSaveSecretValue : undefined}
1068
+ onSaveSecretValue={canUpdateSecrets && !resourceGone ? handleSaveSecretValue : undefined}
1027
1069
  isSavingSecret={isUpdatingResource}
1028
- rendererOverrides={rendererOverrides}
1070
+ rendererOverrides={rendererOverridesForState}
1029
1071
  resolvedEnvFrom={resolvedEnvFrom}
1030
1072
  renderMetrics={renderMetricsTab}
1031
1073
  events={resourceFocusedK8sEvents}
@@ -1033,7 +1075,7 @@ export function WorkloadView({
1033
1075
  updates={resourceFocusedUpdates}
1034
1076
  eventsError={resourceFocusedK8sError}
1035
1077
  updatesError={resourceFocusedUpdatesError}
1036
- mainFooter={renderOverviewExtra && renderOverviewExtra({ kind, namespace, name, group, context: 'drawer' })}
1078
+ mainFooter={renderOverviewExtraForState && renderOverviewExtraForState({ kind, namespace, name, group, context: 'drawer' })}
1037
1079
  />
1038
1080
  </>
1039
1081
  )}
@@ -1115,6 +1157,7 @@ export function WorkloadView({
1115
1157
  {gitOpsResourcePath && onNavigateGitOpsPath && (
1116
1158
  <OpenInGitOpsChip onClick={() => onNavigateGitOpsPath(gitOpsResourcePath)} />
1117
1159
  )}
1160
+ {onOpenLiveTraffic && <LiveTrafficChip onClick={onOpenLiveTraffic} />}
1118
1161
  {relationships?.owner && !showOwnershipHeading && (
1119
1162
  <span>Owner: <button onClick={() => onNavigateToResource?.(refToSelectedResource(relationships.owner!))} className="text-blue-500 hover:underline">{relationships.owner.name}</button></span>
1120
1163
  )}
@@ -1157,12 +1200,13 @@ export function WorkloadView({
1157
1200
  activeTab={effectiveTab}
1158
1201
  onTabChange={handleSetTab}
1159
1202
  scopeControls={scopeControls}
1160
- tabStripEnd={<ResourceActionsBar resource={selectedResource} data={resource} hideLogs {...effectiveActionsBarProps} />}
1203
+ tabStripEnd={<ResourceActionsBar key={actionsBarKey} resource={selectedResource} data={resource} hideLogs {...actionsBarPropsForState} />}
1161
1204
  overlay={saveSuccess ? <SaveSuccessAnimation /> : null}
1162
1205
  compactHeader={compactHeader}
1163
1206
  >
1164
1207
  <div className="flex h-full min-h-0 flex-col">
1165
- {effectiveTab !== 'overview' && rolloutActivity && (rolloutActivity.phase !== 'idle' || recentImageSave) && (
1208
+ {deletedNotice && <div className="shrink-0 px-4 pt-4">{deletedNotice}</div>}
1209
+ {!resourceGone && effectiveTab !== 'overview' && rolloutActivity && (rolloutActivity.phase !== 'idle' || recentImageSave) && (
1166
1210
  <div className="shrink-0 px-4 pt-4">
1167
1211
  <WorkloadRolloutNotice activity={rolloutActivity} recentImageSave={recentImageSave} />
1168
1212
  </div>
@@ -1173,9 +1217,9 @@ export function WorkloadView({
1173
1217
  <div className="h-full min-h-0 overflow-y-auto">{expandedSummary}</div>
1174
1218
  ) : effectiveTab === 'overview' && expandedOverview ? (
1175
1219
  <div className="h-full min-h-0">
1176
- {hasOperationalIssues && renderOverviewLead && (
1220
+ {hasOperationalIssues && renderOverviewLeadForState && (
1177
1221
  <div className="px-4 pt-4">
1178
- {renderOverviewLead({ kind, namespace, name })}
1222
+ {renderOverviewLeadForState({ kind, namespace, name })}
1179
1223
  </div>
1180
1224
  )}
1181
1225
  {expandedOverview}
@@ -1199,22 +1243,22 @@ export function WorkloadView({
1199
1243
  onNavigate={onNavigateToResource}
1200
1244
  onCopy={copyToClipboard}
1201
1245
  copied={copied}
1202
- onSaveSecretValue={canUpdateSecrets ? handleSaveSecretValue : undefined}
1246
+ onSaveSecretValue={canUpdateSecrets && !resourceGone ? handleSaveSecretValue : undefined}
1203
1247
  isSavingSecret={isUpdatingResource}
1204
1248
  onOpenLogs={handleOpenLogs}
1205
1249
  onSwitchToTimeline={() => handleSetTab('timeline')}
1206
1250
  onSwitchToLogs={logsTabVisible ? () => handleSetTab('logs') : undefined}
1207
1251
  onSwitchToTopology={!topologyTabHidden ? () => handleSetTab('topology') : undefined}
1208
- rendererOverrides={rendererOverrides}
1252
+ rendererOverrides={rendererOverridesForState}
1209
1253
  resolvedEnvFrom={resolvedEnvFrom}
1210
1254
  events={overviewEvents}
1211
1255
  eventsLoading={overviewEventsLoading}
1212
1256
  updates={resourceFocusedUpdates}
1213
1257
  eventsError={overviewEventsError}
1214
1258
  updatesError={resourceFocusedUpdatesError}
1215
- extraContent={renderOverviewExtra && renderOverviewExtra({ kind, namespace, name, group, context: 'expanded' })}
1259
+ extraContent={renderOverviewExtraForState && renderOverviewExtraForState({ kind, namespace, name, group, context: 'expanded' })}
1216
1260
  introContent={overviewIntro}
1217
- leadContent={hasOperationalIssues && renderOverviewLead ? renderOverviewLead({ kind, namespace, name }) : undefined}
1261
+ leadContent={hasOperationalIssues && renderOverviewLeadForState ? renderOverviewLeadForState({ kind, namespace, name }) : undefined}
1218
1262
  onEvaluateCapacity={onEvaluateCapacity}
1219
1263
  recentImageSave={recentImageSave}
1220
1264
  />
@@ -1273,9 +1317,9 @@ export function WorkloadView({
1273
1317
  {renderMetricsTab({ kind: resource?.kind || kind, namespace, name })}
1274
1318
  </div>
1275
1319
  )}
1276
- {effectiveTab === 'reachability' && renderDiagnoseTab && (
1320
+ {effectiveTab === 'reachability' && renderDiagnoseTabForState && (
1277
1321
  <div className="flex h-full min-h-0 flex-col p-3">
1278
- {renderDiagnoseTab({ kind: resource?.kind || kind, namespace, name })}
1322
+ {renderDiagnoseTabForState({ kind: resource?.kind || kind, namespace, name })}
1279
1323
  </div>
1280
1324
  )}
1281
1325
  {effectiveTab === 'cost' && renderCostTab && (
@@ -1406,6 +1450,34 @@ function extractMetadata(kind: string, resource: any): { label: string; value: s
1406
1450
  // SUB-COMPONENTS
1407
1451
  // ============================================================================
1408
1452
 
1453
+ function DeletedResourceNotice({
1454
+ kindLabel,
1455
+ owner,
1456
+ onNavigateToResource,
1457
+ }: {
1458
+ kindLabel: string
1459
+ owner?: ResourceRef
1460
+ onNavigateToResource?: NavigateToResource
1461
+ }) {
1462
+ return (
1463
+ <AlertBanner
1464
+ variant="warning"
1465
+ title={`This ${kindLabel} no longer exists in the cluster`}
1466
+ message="You're looking at the last version Radar loaded before it was deleted."
1467
+ >
1468
+ {owner && onNavigateToResource && (
1469
+ <button
1470
+ type="button"
1471
+ onClick={() => onNavigateToResource(refToSelectedResource(owner))}
1472
+ className="mt-2 text-xs text-accent-text hover:underline"
1473
+ >
1474
+ Go to its {displayKindName(owner.kind, owner.kind)} {owner.name}
1475
+ </button>
1476
+ )}
1477
+ </AlertBanner>
1478
+ )
1479
+ }
1480
+
1409
1481
  function OpenInGitOpsChip({ onClick }: { onClick: () => void }) {
1410
1482
  return (
1411
1483
  <Tooltip content="Open this resource in the GitOps tab (tree + insights + ops)" delay={150}>
@@ -1421,6 +1493,21 @@ function OpenInGitOpsChip({ onClick }: { onClick: () => void }) {
1421
1493
  )
1422
1494
  }
1423
1495
 
1496
+ function LiveTrafficChip({ onClick }: { onClick: () => void }) {
1497
+ return (
1498
+ <Tooltip content="What this workload talks to, from the cluster's traffic source" delay={150}>
1499
+ <button
1500
+ type="button"
1501
+ onClick={onClick}
1502
+ className="inline-flex items-center gap-1 rounded border border-theme-border bg-theme-elevated px-1.5 py-0.5 text-[11px] font-medium text-theme-text-secondary hover:text-theme-text-primary hover:bg-theme-hover transition-colors"
1503
+ >
1504
+ Live traffic
1505
+ <ArrowRight className="h-3 w-3 shrink-0" />
1506
+ </button>
1507
+ </Tooltip>
1508
+ )
1509
+ }
1510
+
1424
1511
  function OwnershipHeading({
1425
1512
  podName,
1426
1513
  context,
@@ -2439,8 +2526,8 @@ function buildSummaryMetrics(
2439
2526
  },
2440
2527
  {
2441
2528
  label: 'Latest run',
2442
- value: status.lastScheduleTime ? formatAge(status.lastScheduleTime) : 'Never',
2443
- detail: status.lastSuccessfulTime ? `last success ${formatAge(status.lastSuccessfulTime)}` : 'no successful run recorded',
2529
+ value: status.lastScheduleTime ? formatRelativeAgeTime(status.lastScheduleTime) : 'Never',
2530
+ detail: status.lastSuccessfulTime ? `last success ${formatRelativeAgeTime(status.lastSuccessfulTime)}` : 'no successful run recorded',
2444
2531
  icon: Activity,
2445
2532
  },
2446
2533
  {
@@ -2556,8 +2643,8 @@ function getReadinessSummary(resource: any, apiKind: string): ReadinessSummary {
2556
2643
  { label: 'Schedule', value: spec.schedule || '-' },
2557
2644
  { label: 'Active jobs', value: active, tone: active > 0 ? 'warn' : 'default' },
2558
2645
  { label: 'Suspend', value: suspended ? 'Yes' : 'No', tone: suspended ? 'warn' : 'default' },
2559
- { label: 'Last schedule', value: status.lastScheduleTime ? formatAge(status.lastScheduleTime) : '-' },
2560
- { label: 'Last success', value: status.lastSuccessfulTime ? formatAge(status.lastSuccessfulTime) : '-' },
2646
+ { label: 'Last schedule', value: status.lastScheduleTime ? formatRelativeAgeTime(status.lastScheduleTime) : '-' },
2647
+ { label: 'Last success', value: status.lastSuccessfulTime ? formatRelativeAgeTime(status.lastSuccessfulTime) : '-' },
2561
2648
  ],
2562
2649
  }
2563
2650
  }
@@ -2656,8 +2743,8 @@ function getScalingSummary(
2656
2743
  label: phase,
2657
2744
  detail: '',
2658
2745
  stats: [
2659
- { label: 'Started', value: status.startTime ? formatAge(status.startTime) : '-' },
2660
- { label: 'Finished', value: status.completionTime ? formatAge(status.completionTime) : phase === 'Running' ? 'Running' : '-' },
2746
+ { label: 'Started', value: status.startTime ? formatRelativeAgeTime(status.startTime) : '-' },
2747
+ { label: 'Finished', value: status.completionTime ? formatRelativeAgeTime(status.completionTime) : phase === 'Running' ? 'Running' : '-' },
2661
2748
  { label: 'Parallelism', value: spec.parallelism ?? 1 },
2662
2749
  { label: 'Completions', value: spec.completions ?? 1 },
2663
2750
  { label: 'Completion mode', value: spec.completionMode ?? 'NonIndexed' },
@@ -3297,8 +3384,8 @@ function CronJobRunsCard({
3297
3384
  >
3298
3385
  <div className="mb-3 grid gap-x-8 gap-y-3 sm:grid-cols-2">
3299
3386
  <OverviewStat label="Active jobs" value={activeJobs.length} tone={activeJobs.length > 0 ? 'warn' : 'default'} />
3300
- <OverviewStat label="Last schedule" value={status.lastScheduleTime ? formatAge(status.lastScheduleTime) : 'Never'} />
3301
- <OverviewStat label="Last success" value={status.lastSuccessfulTime ? formatAge(status.lastSuccessfulTime) : 'Never'} />
3387
+ <OverviewStat label="Last schedule" value={status.lastScheduleTime ? formatRelativeAgeTime(status.lastScheduleTime) : 'Never'} />
3388
+ <OverviewStat label="Last success" value={status.lastSuccessfulTime ? formatRelativeAgeTime(status.lastSuccessfulTime) : 'Never'} />
3302
3389
  <OverviewStat label="Retained history" value={`${spec.successfulJobsHistoryLimit ?? 3} ok / ${spec.failedJobsHistoryLimit ?? 1} failed`} />
3303
3390
  </div>
3304
3391
 
@@ -3755,7 +3842,7 @@ function ActivityCard({
3755
3842
  <div className="min-w-0 flex-1">
3756
3843
  <div className="flex items-center justify-between gap-2">
3757
3844
  <span className="truncate text-sm font-medium text-theme-text-primary">{event.reason || event.eventType || 'update'}</span>
3758
- <span className="shrink-0 text-xs text-theme-text-tertiary">{formatAge(event.timestamp)}</span>
3845
+ <span className="shrink-0 text-xs text-theme-text-tertiary">{formatRelativeAgeTime(event.timestamp)}</span>
3759
3846
  </div>
3760
3847
  <div className="mt-0.5 line-clamp-2 text-xs text-theme-text-secondary">
3761
3848
  {event.message || event.diff?.summary || `${event.kind} ${event.name}`}
@@ -3878,16 +3965,6 @@ function summarizeServingExposure(
3878
3965
  return { label: 'No serving path detected', badge: 'None', severity: 'neutral' }
3879
3966
  }
3880
3967
 
3881
- function dedupeResourceRefs(refs: ResourceRef[]): ResourceRef[] {
3882
- const seen = new Set<string>()
3883
- return refs.filter((ref) => {
3884
- const key = resourceRefId(ref)
3885
- if (seen.has(key)) return false
3886
- seen.add(key)
3887
- return true
3888
- })
3889
- }
3890
-
3891
3968
  function resourceRefId(ref: ResourceRef): string {
3892
3969
  return `${ref.kind}/${ref.namespace}/${ref.name}/${ref.group ?? ''}`
3893
3970
  }
@@ -1,4 +1,5 @@
1
1
  import type { Issue } from "../components/issues/types";
2
+ import type { NodeLifecycleObservation } from "../utils/node-lifecycle";
2
3
 
3
4
  export const CAPACITY_SCHEMA_VERSION = "v1alpha1" as const;
4
5
 
@@ -232,6 +233,14 @@ export interface CapacityClaimLifecycleSummary {
232
233
  }
233
234
 
234
235
  export interface CapacityNodeLifecycleSummary {
236
+ operational?: {
237
+ total: number;
238
+ ready: number;
239
+ notReady: number;
240
+ cordoned: number;
241
+ removing: number;
242
+ removingUnhealthy: number;
243
+ };
235
244
  total: number;
236
245
  ready: number;
237
246
  notReady: number;
@@ -325,6 +334,7 @@ export type CapacityClaimStage =
325
334
  | "unknown";
326
335
 
327
336
  export interface CapacityNodeMember {
337
+ lifecycle?: NodeLifecycleObservation;
328
338
  ready?: boolean;
329
339
  cordoned: boolean;
330
340
  conditions: CapacityCondition[];
package/src/types/core.ts CHANGED
@@ -167,6 +167,9 @@ export interface FeatureCapabilities {
167
167
  podEnvironment?: boolean
168
168
  policyResource?: boolean
169
169
  workloadHistory?: boolean
170
+ serviceEndpointSlices?: boolean
171
+ trafficRecords?: boolean
172
+ gitopsWriteEvidence?: boolean
170
173
  }
171
174
 
172
175
  // DeploymentMode is the closed set of topologies Radar can run in.
@@ -653,6 +656,8 @@ export interface Relationships {
653
656
  consumers?: ResourceRef[]
654
657
  scalers?: ResourceRef[]
655
658
  storageRefs?: ResourceRef[]
659
+ dependencies?: ResourceRef[]
660
+ dependents?: ResourceRef[]
656
661
  scaleTarget?: ResourceRef
657
662
  pdbs?: ResourceRef[] // PodDisruptionBudgets protecting this workload
658
663
  networkPolicies?: ResourceRef[] // NetworkPolicy / CiliumNetworkPolicy / ClusterNetworkPolicy variants selecting this workload
@@ -817,6 +822,7 @@ export interface HelmRevision {
817
822
  appVersion: string
818
823
  description: string
819
824
  updated: string // ISO date string
825
+ deleted?: string
820
826
  }
821
827
 
822
828
  export type HelmOperationKind = 'release_failed' | 'upgrade_failed' | 'upgrade_rolled_back' | 'rollback' | 'pending'
@@ -1284,10 +1290,12 @@ export interface MetricsDataPoint {
1284
1290
  export interface TrafficEndpoint {
1285
1291
  name: string
1286
1292
  namespace: string
1287
- kind: string // Pod, Service, External, Host, Unknown
1293
+ kind: string // Pod, Workload, Service, External, Host, Unknown
1288
1294
  ip?: string
1289
1295
  labels?: Record<string, string>
1290
1296
  workload?: string
1297
+ /** Deployment, StatefulSet, CronJob, … when resolved from the pod's owners. */
1298
+ workloadKind?: string
1291
1299
  port?: number
1292
1300
  }
1293
1301
 
@@ -1453,6 +1461,12 @@ export interface TrafficFlowsResponse {
1453
1461
  * nodeFlowLimit, so older traffic in the window did not fit. */
1454
1462
  coveredSince?: string
1455
1463
  nodeFlowLimit?: number
1464
+ /** Set when the source kept only its newest flowLimit flows in total; the
1465
+ * window is then complete only from coveredSince. */
1466
+ flowLimit?: number
1467
+ /** How many records the aggregation covers. `flows` holds only the newest of
1468
+ * them; a Radar that predates the cap omits this and sends every record. */
1469
+ flowsTotal?: number
1456
1470
  warning?: string // Non-fatal warning (e.g., query errors)
1457
1471
  /** 'transient' (or absent) means the condition may clear on its own and a
1458
1472
  * retry is worthwhile. 'partial' means the flows are correct but some of
@@ -1463,6 +1477,23 @@ export interface TrafficFlowsResponse {
1463
1477
  warningKind?: 'transient' | 'partial' | 'incomplete'
1464
1478
  }
1465
1479
 
1480
+ // Response from GET /api/traffic/flows/records — the newest records behind
1481
+ // one graph selection.
1482
+ export interface TrafficRecordsResponse {
1483
+ source: string
1484
+ timestamp: string
1485
+ /** Newest first, at most the server's records limit. */
1486
+ flows: TrafficFlow[]
1487
+ /** How many records matched; more than flows.length when capped. */
1488
+ matched: number
1489
+ l7ResponsesCallerOriented?: boolean
1490
+ coveredSince?: string
1491
+ nodeFlowLimit?: number
1492
+ flowLimit?: number
1493
+ warning?: string
1494
+ warningKind?: 'transient' | 'partial' | 'incomplete'
1495
+ }
1496
+
1466
1497
  // Wizard state for traffic setup
1467
1498
  export type TrafficWizardState = 'detecting' | 'not_found' | 'wizard' | 'checking' | 'ready'
1468
1499