@skyhook-io/k8s-ui 1.14.5 → 1.14.6

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 (67) hide show
  1. package/package.json +5 -5
  2. package/src/components/audit/AuditCard.tsx +18 -7
  3. package/src/components/audit/AuditCoveragePresentation.test.tsx +45 -0
  4. package/src/components/charts/AreaChart.test.tsx +13 -0
  5. package/src/components/charts/AreaChart.tsx +28 -25
  6. package/src/components/charts/PrometheusChartsView.tsx +12 -0
  7. package/src/components/charts/axis.test.ts +16 -0
  8. package/src/components/charts/axis.ts +3 -1
  9. package/src/components/charts/format.ts +4 -0
  10. package/src/components/charts/nearestSample.test.ts +31 -0
  11. package/src/components/charts/nearestSample.ts +15 -0
  12. package/src/components/checks/ChecksView.tsx +20 -2
  13. package/src/components/gitops/GitOpsDetailLayout.tsx +13 -0
  14. package/src/components/gitops/GitOpsHealthSourceNotice.tsx +141 -0
  15. package/src/components/gitops/health-provenance.test.ts +54 -0
  16. package/src/components/gitops/health-provenance.ts +24 -0
  17. package/src/components/gitops/index.ts +2 -0
  18. package/src/components/gitops/insights/GitOpsInsightViews.tsx +40 -21
  19. package/src/components/gitops/tree/GitOpsTreeGraph.tsx +59 -8
  20. package/src/components/gitops/tree/merge.test.ts +34 -0
  21. package/src/components/gitops/tree/merge.ts +7 -0
  22. package/src/components/resources/ResourcesView.tsx +67 -2
  23. package/src/components/resources/curated-column-ownership.test.ts +3 -3
  24. package/src/components/resources/renderers/AnalysisTemplateRenderer.tsx +150 -0
  25. package/src/components/resources/renderers/ConfigMapRenderer.tsx +8 -1
  26. package/src/components/resources/renderers/ExperimentRenderer.tsx +179 -0
  27. package/src/components/resources/renderers/LimitRangeRenderer.test.tsx +156 -0
  28. package/src/components/resources/renderers/LimitRangeRenderer.tsx +277 -0
  29. package/src/components/resources/renderers/NamespaceRenderer.tsx +24 -1
  30. package/src/components/resources/renderers/NetworkPolicyDiagram.tsx +9 -16
  31. package/src/components/resources/renderers/NetworkPolicyRenderer.test.tsx +75 -0
  32. package/src/components/resources/renderers/NetworkPolicyRenderer.tsx +50 -36
  33. package/src/components/resources/renderers/PodRenderer.tsx +11 -0
  34. package/src/components/resources/renderers/ReflectorSection.test.tsx +281 -0
  35. package/src/components/resources/renderers/ReflectorSection.tsx +296 -0
  36. package/src/components/resources/renderers/RolloutRenderer.test.tsx +117 -0
  37. package/src/components/resources/renderers/RolloutRenderer.tsx +219 -56
  38. package/src/components/resources/renderers/SecretRenderer.tsx +10 -3
  39. package/src/components/resources/renderers/WorkloadRenderer.tsx +11 -1
  40. package/src/components/resources/renderers/index.ts +3 -0
  41. package/src/components/resources/renderers/rollout/CanaryStepTimeline.tsx +199 -0
  42. package/src/components/resources/renderers/rollout/ReplicaSetProgression.tsx +116 -0
  43. package/src/components/resources/resource-utils.ts +18 -9
  44. package/src/components/shared/CreateResourceDialog.tsx +231 -11
  45. package/src/components/shared/DrainPlanDialog.test.tsx +154 -0
  46. package/src/components/shared/DrainPlanDialog.tsx +297 -0
  47. package/src/components/shared/ResourceActionsBar.tsx +53 -26
  48. package/src/components/shared/ResourceRendererDispatch.test.tsx +45 -0
  49. package/src/components/shared/ResourceRendererDispatch.tsx +27 -6
  50. package/src/components/shared/index.ts +4 -0
  51. package/src/components/ui/FetchResult.test.tsx +20 -2
  52. package/src/components/ui/FetchResult.tsx +33 -3
  53. package/src/components/ui/FreshnessControl.tsx +8 -2
  54. package/src/components/workload/WorkloadView.tsx +2 -2
  55. package/src/theme/components.css +16 -0
  56. package/src/types/core.ts +15 -1
  57. package/src/types/gitops-insights.ts +23 -0
  58. package/src/types/gitops-tree.ts +29 -0
  59. package/src/utils/index.ts +1 -0
  60. package/src/utils/navigation.test.ts +20 -1
  61. package/src/utils/navigation.ts +25 -0
  62. package/src/utils/network-policy.test.ts +42 -0
  63. package/src/utils/network-policy.ts +59 -0
  64. package/src/utils/reflector.ts +139 -0
  65. package/src/utils/yaml-file-import.test.ts +189 -0
  66. package/src/utils/yaml-file-import.ts +95 -0
  67. package/src/components/logs/JsonLogLine.tsx +0 -93
@@ -1,3 +1,4 @@
1
+ import { hasReflectorDetails } from '../resources/renderers/ReflectorSection'
1
2
  import { clsx } from 'clsx'
2
3
  import { SEVERITY_BADGE, HEALTH_BADGE_COLORS } from '../../utils/badge-colors'
3
4
  import { isArgoRolloutResource } from '../../utils/workload-rollout'
@@ -120,6 +121,8 @@ import {
120
121
  PVCRenderer,
121
122
  RolloutRenderer,
122
123
  AnalysisRunRenderer,
124
+ AnalysisTemplateRenderer,
125
+ ExperimentRenderer,
123
126
  CertificateRenderer,
124
127
  WorkflowRenderer,
125
128
  PersistentVolumeRenderer,
@@ -231,6 +234,7 @@ import {
231
234
  PriorityClassRenderer,
232
235
  RuntimeClassRenderer,
233
236
  LeaseRenderer,
237
+ LimitRangeRenderer,
234
238
  TraefikIngressRouteRenderer,
235
239
  TraefikMiddlewareRenderer,
236
240
  TraefikServiceRenderer,
@@ -426,7 +430,7 @@ const KNOWN_KINDS = new Set([
426
430
  'pods', 'deployments', 'statefulsets', 'daemonsets', 'replicasets',
427
431
  'services', 'endpointslices', 'ingresses', 'configmaps', 'secrets', 'jobs', 'cronjobs', 'cronworkflows',
428
432
  'hpas', 'horizontalpodautoscalers', 'nodes', 'persistentvolumeclaims',
429
- 'rollouts', 'analysisruns', 'certificates', 'workflows', 'persistentvolumes',
433
+ 'rollouts', 'analysisruns', 'analysistemplates', 'clusteranalysistemplates', 'experiments', 'certificates', 'workflows', 'persistentvolumes',
430
434
  'storageclasses', 'certificaterequests', 'clusterissuers', 'issuers',
431
435
  'orders', 'challenges',
432
436
  'gateways', 'gatewayclasses', 'httproutes', 'grpcroutes', 'tcproutes', 'tlsroutes', 'sealedsecrets', 'workflowtemplates', 'clusterworkflowtemplates',
@@ -473,7 +477,7 @@ const KNOWN_KINDS = new Set([
473
477
  'virtualservices', 'destinationrules', 'serviceentries',
474
478
  'peerauthentications', 'authorizationpolicies',
475
479
  'mutatingwebhookconfigurations', 'validatingwebhookconfigurations',
476
- 'ingressclasses', 'priorityclasses', 'runtimeclasses', 'leases',
480
+ 'ingressclasses', 'priorityclasses', 'runtimeclasses', 'leases', 'limitranges',
477
481
  'knativeservices', 'knativeconfigurations', 'knativerevisions', 'knativeroutes',
478
482
  'brokers', 'triggers', 'eventtypes', 'pingsources', 'apiserversources', 'containersources', 'sinkbindings',
479
483
  'channels', 'inmemorychannels', 'subscriptions', 'sequences', 'parallels',
@@ -678,7 +682,8 @@ export function ResourceRendererDispatch({
678
682
  kind === 'clusters' || kind === 'backups' || kind === 'scheduledbackups' || kind === 'poolers'
679
683
  || kind === 'objectstores' || kind === 'databases' || kind === 'publications'
680
684
  || kind === 'subscriptions' || kind === 'imagecatalogs' || kind === 'clusterimagecatalogs'
681
- || kind === 'policies' || kind === 'rollouts' || kind === 'machines' || kind === 'machinesets'
685
+ || kind === 'policies' || kind === 'rollouts' || kind === 'experiments'
686
+ || kind === 'machines' || kind === 'machinesets'
682
687
  const isCNPGApiVersion = isApiGroup(data?.apiVersion, CNPG_GROUP)
683
688
  const groupGatedMatched =
684
689
  (kind === 'clusters' && (isCNPGApiVersion || isApiGroup(data?.apiVersion, 'cluster.x-k8s.io')))
@@ -691,6 +696,11 @@ export function ResourceRendererDispatch({
691
696
  && (isCNPGApiVersion || isApiGroup(data?.apiVersion, 'messaging.knative.dev')))
692
697
  || (kind === 'policies' && isApiGroup(data?.apiVersion, 'kyverno.io'))
693
698
  || (kind === 'rollouts' && isArgoRolloutResource(data))
699
+ // Katib (kubeflow.org) ships its own, unrelated Experiment CRD sharing
700
+ // this plural — without this gate it got the Argo Rollouts Experiment
701
+ // renderer's mostly-empty status view instead of its actual resource
702
+ // details, the same collision shape as every other check in this block.
703
+ || (kind === 'experiments' && isApiGroup(data?.apiVersion, 'argoproj.io'))
694
704
  || ((kind === 'machines' || kind === 'machinesets')
695
705
  && isApiGroup(data?.apiVersion, 'cluster.x-k8s.io'))
696
706
  const groupGatedFallthrough = isGroupGatedKind && !groupGatedMatched
@@ -741,11 +751,15 @@ export function ResourceRendererDispatch({
741
751
  const HPAComp = rendererOverrides?.HPARenderer ?? HPARenderer
742
752
  const PVCComp = rendererOverrides?.PVCRenderer ?? PVCRenderer
743
753
  const RolloutComp = rendererOverrides?.RolloutRenderer ?? RolloutRenderer
754
+ const showsReflection = (kind === 'configmaps' || kind === 'secrets') && hasReflectorDetails(data, relationships?.reflection)
755
+ const reflectionRefs = showsReflection ? [relationships?.reflection?.source, ...(relationships?.reflection?.mirrors ?? [])].filter((ref): ref is ResourceRef => !!ref) : []
756
+ const withoutReflection = (refs: ResourceRef[] | undefined) => refs?.filter(ref => !reflectionRefs.some(mirror => mirror.kind === ref.kind && mirror.namespace === ref.namespace && mirror.name === ref.name && (mirror.group ?? '') === (ref.group ?? '')))
757
+ const sidebarRelationships = showsReflection && relationships ? { ...relationships, configRefs: withoutReflection(relationships.configRefs), consumers: withoutReflection(relationships.consumers) } : relationships
744
758
  const scaleBlockedBy = replicaScalers(relationships?.scalers)
745
759
 
746
760
  const sidebarContent = showCommonSections && (
747
761
  <>
748
- <RelatedResourcesSection relationships={relationships} onNavigate={onNavigate} />
762
+ <RelatedResourcesSection relationships={sidebarRelationships} onNavigate={onNavigate} />
749
763
  {kind !== 'events' && <EventsSection events={events || []} updates={updates || []} isLoading={eventsLoading ?? false} eventsError={eventsError ?? null} updatesError={updatesError ?? null} hint={eventsHint} />}
750
764
  <LabelsSection data={data} />
751
765
  <AnnotationsSection data={data} />
@@ -773,8 +787,8 @@ export function ResourceRendererDispatch({
773
787
  {kind === 'services' && !data?.apiVersion?.includes('serving.knative.dev') && <ServiceComp data={data} onCopy={onCopy} copied={copied} onNavigate={onNavigate} />}
774
788
  {kind === 'endpointslices' && <EndpointSliceRenderer data={data} onNavigate={onNavigate} />}
775
789
  {kind === 'ingresses' && !data?.apiVersion?.includes('networking.internal.knative.dev') && <IngressRenderer data={data} onNavigate={onNavigate} />}
776
- {kind === 'configmaps' && <ConfigMapRenderer data={data} />}
777
- {kind === 'secrets' && <SecretRenderer data={data} certificateInfo={certificateInfo} resourceData={data} onSaveSecretValue={onSaveSecretValue} isSaving={isSavingSecret} />}
790
+ {kind === 'configmaps' && <ConfigMapRenderer data={data} relationships={relationships} onNavigate={onNavigate} />}
791
+ {kind === 'secrets' && <SecretRenderer data={data} relationships={relationships} onNavigate={onNavigate} certificateInfo={certificateInfo} resourceData={data} onSaveSecretValue={onSaveSecretValue} isSaving={isSavingSecret} />}
778
792
  {kind === 'jobs' && !nonCoreJobFallthrough && <JobRenderer data={data} />}
779
793
  {kind === 'cronjobs' && <CronJobRenderer data={data} onNavigate={onNavigate} />}
780
794
  {kind === 'cronworkflows' && <CronWorkflowRenderer data={data} onNavigate={onNavigate} />}
@@ -783,6 +797,8 @@ export function ResourceRendererDispatch({
783
797
  {kind === 'persistentvolumeclaims' && <PVCComp data={data} onNavigate={onNavigate} />}
784
798
  {kind === 'rollouts' && isArgoRolloutResource(data) && <RolloutComp data={data} onNavigate={onNavigate} />}
785
799
  {kind === 'analysisruns' && <AnalysisRunRenderer data={data} onNavigate={onNavigate} />}
800
+ {(kind === 'analysistemplates' || kind === 'clusteranalysistemplates') && <AnalysisTemplateRenderer data={data} />}
801
+ {kind === 'experiments' && isApiGroup(data?.apiVersion, 'argoproj.io') && <ExperimentRenderer data={data} onNavigate={onNavigate} />}
786
802
  {kind === 'certificates' && !data?.apiVersion?.includes('networking.internal.knative.dev') && <CertificateRenderer data={data} />}
787
803
  {kind === 'workflows' && <WorkflowRenderer data={data} onNavigate={onNavigate} />}
788
804
  {kind === 'persistentvolumes' && <PersistentVolumeRenderer data={data} onNavigate={onNavigate} />}
@@ -920,6 +936,7 @@ export function ResourceRendererDispatch({
920
936
  {kind === 'priorityclasses' && <PriorityClassRenderer data={data} />}
921
937
  {kind === 'runtimeclasses' && <RuntimeClassRenderer data={data} />}
922
938
  {kind === 'leases' && <LeaseRenderer data={data} />}
939
+ {kind === 'limitranges' && <LimitRangeRenderer data={data} />}
923
940
  {/* Knative Serving */}
924
941
  {(kind === 'services' && data?.apiVersion?.includes('serving.knative.dev')) && <KnativeServiceRenderer data={data} onNavigate={onNavigate} />}
925
942
  {kind === 'knativeservices' && <KnativeServiceRenderer data={data} onNavigate={onNavigate} />}
@@ -1103,6 +1120,10 @@ export function getResourceStatus(kind: string, data: any): { text: string; colo
1103
1120
  if (k === 'nodes') return getNodeStatus(data)
1104
1121
  if (k === 'persistentvolumeclaims') return getPVCStatus(data)
1105
1122
  if (k === 'analysisruns') return getAnalysisRunStatus(data)
1123
+ // Same collision guard as the render branch above — Katib's unrelated
1124
+ // Experiment CRD (kubeflow.org) shares this plural and doesn't report the
1125
+ // AnalysisPhase vocabulary getAnalysisRunStatus expects.
1126
+ if (k === 'experiments' && isApiGroup(data?.apiVersion, 'argoproj.io')) return getAnalysisRunStatus(data)
1106
1127
  if (k === 'workflows') return getWorkflowStatus(data)
1107
1128
  if (k === 'cronworkflows') return getCronWorkflowStatus(data)
1108
1129
  if (k === 'certificates') {
@@ -1,6 +1,10 @@
1
1
  export { ResourceRendererDispatch, getResourceStatus, type RendererOverrides } from './ResourceRendererDispatch'
2
2
  export { EditableYamlView, SaveSuccessAnimation } from './EditableYamlView'
3
3
  export { ResourceActionsBar, RevisionHistoryDialog, isRolloutKind } from './ResourceActionsBar'
4
+ export {
5
+ DrainPlanDialog, DrainPlanContent, canConfirmDrain, planMatches, emptyDirPodsAtRisk, DEFAULT_DRAIN_DIALOG_OPTIONS,
6
+ type DrainPlan, type DrainPlanPod, type DrainOutcome, type DrainDialogOptions,
7
+ } from './DrainPlanDialog'
4
8
  export { SetImageDialog, type ManagedImageSource, type SetImageDialogProps } from './SetImageDialog'
5
9
  export { CreateResourceDialog, type CreateResourceDialogProps, type ApplyResult } from './CreateResourceDialog'
6
10
  export { HelmManagedByChip, ManagedByChip, type HelmOwnerRef } from './ManagedByChip'
@@ -2,8 +2,8 @@ import { describe, expect, it } from 'vitest'
2
2
  import { renderToString } from 'react-dom/server'
3
3
  import { FetchResult } from './FetchResult'
4
4
 
5
- function shaped(message: string, status: number) {
6
- return Object.assign(new Error(message), { status })
5
+ function shaped(message: string, status: number, data?: Record<string, unknown>) {
6
+ return Object.assign(new Error(message), { status, data })
7
7
  }
8
8
 
9
9
  describe('FetchResult', () => {
@@ -54,6 +54,24 @@ describe('FetchResult', () => {
54
54
  expect(html).toContain('Resource cache not available')
55
55
  })
56
56
 
57
+ it('does not blame the cluster for a kind_sync_failed 503', () => {
58
+ const html = renderToString(
59
+ <FetchResult
60
+ loading={false}
61
+ error={shaped('pods failed to load within the sync deadline', 503, { error_code: 'kind_sync_failed' })}
62
+ />,
63
+ )
64
+ expect(html).not.toContain('Cluster unavailable')
65
+ expect(html).toContain('Couldn')
66
+ expect(html).toContain('pods failed to load within the sync deadline')
67
+ })
68
+
69
+ it('renders a Retry button only when onRetry is given', () => {
70
+ const err = shaped('internal server error', 500)
71
+ expect(renderToString(<FetchResult loading={false} error={err} onRetry={() => {}} />)).toContain('Retry')
72
+ expect(renderToString(<FetchResult loading={false} error={err} />)).not.toContain('Retry')
73
+ })
74
+
57
75
  it('renders "Sign-in required" on 401', () => {
58
76
  const html = renderToString(
59
77
  <FetchResult loading={false} error={shaped('Unauthorized', 401)} />,
@@ -1,5 +1,5 @@
1
1
  import { useState } from 'react'
2
- import { ShieldOff, AlertTriangle, ServerCrash, LogIn, Copy, Check, type LucideIcon } from 'lucide-react'
2
+ import { ShieldOff, AlertTriangle, ServerCrash, LogIn, Copy, Check, RefreshCw, type LucideIcon } from 'lucide-react'
3
3
  import { clsx } from 'clsx'
4
4
  import { PaneLoader } from './PaneLoader'
5
5
  import { isFetchError } from '../../types/fetch-error'
@@ -14,6 +14,7 @@ import { isFetchError } from '../../types/fetch-error'
14
14
  // loading → <PaneLoader/>
15
15
  // error (403) → "Access denied" + error.message
16
16
  // error (404) → notFoundMessage + error.message
17
+ // error (503 sync failed) → "Couldn't load this view" + error.message
17
18
  // error (503) → "Cluster unavailable" + error.message
18
19
  // error (401) → "Sign-in required" (apiFetch redirects; fallback)
19
20
  // error (other / no shape) → "Couldn't load this view" + error.message
@@ -34,6 +35,8 @@ interface FetchResultProps {
34
35
  notFoundMessage?: string
35
36
  /** Pin to parent height. Existing call sites use "h-32" or "h-full". */
36
37
  className?: string
38
+ /** Renders a Retry button on the error surface. */
39
+ onRetry?: () => void
37
40
  }
38
41
 
39
42
  export function FetchResult({
@@ -41,6 +44,7 @@ export function FetchResult({
41
44
  error,
42
45
  notFoundMessage = 'Resource not found',
43
46
  className = 'h-32',
47
+ onRetry,
44
48
  }: FetchResultProps) {
45
49
  if (loading) {
46
50
  return <PaneLoader className={className} />
@@ -55,16 +59,17 @@ export function FetchResult({
55
59
  </div>
56
60
  )
57
61
  }
58
- return <ErrorSurface error={error} notFoundMessage={notFoundMessage} className={className} />
62
+ return <ErrorSurface error={error} notFoundMessage={notFoundMessage} className={className} onRetry={onRetry} />
59
63
  }
60
64
 
61
65
  interface ErrorSurfaceProps {
62
66
  error: unknown
63
67
  notFoundMessage: string
64
68
  className: string
69
+ onRetry?: () => void
65
70
  }
66
71
 
67
- function ErrorSurface({ error, notFoundMessage, className }: ErrorSurfaceProps) {
72
+ function ErrorSurface({ error, notFoundMessage, className, onRetry }: ErrorSurfaceProps) {
68
73
  const classified = classify(error, notFoundMessage)
69
74
  const Icon = classified.icon
70
75
 
@@ -81,6 +86,16 @@ function ErrorSurface({ error, notFoundMessage, className }: ErrorSurfaceProps)
81
86
  <CopyErrorButton text={classified.detail} />
82
87
  </div>
83
88
  )}
89
+ {onRetry && (
90
+ <button
91
+ type="button"
92
+ onClick={onRetry}
93
+ className="mt-1 flex items-center gap-1.5 rounded-md border border-theme-border bg-theme-elevated px-3 py-1.5 text-xs text-theme-text-secondary hover:bg-theme-hover hover:text-theme-text-primary"
94
+ >
95
+ <RefreshCw className="h-3.5 w-3.5" aria-hidden />
96
+ Retry
97
+ </button>
98
+ )}
84
99
  </div>
85
100
  )
86
101
  }
@@ -101,6 +116,17 @@ function classify(error: unknown, notFoundMessage: string): Classified {
101
116
  case 401:
102
117
  return { headline: 'Sign-in required', detail: error.message, icon: LogIn }
103
118
  case 503:
119
+ // The cluster is connected; one kind's informer missed its sync
120
+ // deadline, so "Cluster unavailable" would send the reader the wrong way.
121
+ if (errorCodeOf(error) === 'kind_sync_failed') {
122
+ return { headline: "Couldn't load this view", detail: error.message, icon: AlertTriangle }
123
+ }
124
+ // The cluster is healthy and still loading — the retry policies keep
125
+ // these surfaces polling, but a caller without one must not tell the
126
+ // user a connecting cluster is down.
127
+ if (errorCodeOf(error) === 'cluster_connecting' || errorCodeOf(error) === 'kind_sync_pending') {
128
+ return { headline: 'Cluster is still loading', detail: error.message, icon: AlertTriangle }
129
+ }
104
130
  return { headline: 'Cluster unavailable', detail: error.message, icon: ServerCrash }
105
131
  default:
106
132
  return { headline: "Couldn't load this view", detail: error.message, icon: AlertTriangle }
@@ -110,6 +136,10 @@ function classify(error: unknown, notFoundMessage: string): Classified {
110
136
  return { headline: "Couldn't load this view", detail: errorMessageOf(error), icon: AlertTriangle }
111
137
  }
112
138
 
139
+ function errorCodeOf(error: unknown): unknown {
140
+ return (error as { data?: { error_code?: unknown } }).data?.error_code
141
+ }
142
+
113
143
  function errorMessageOf(error: unknown): string | null {
114
144
  if (error instanceof Error && error.message) return error.message
115
145
  if (typeof error === 'string') return error
@@ -6,7 +6,10 @@ import { useRefreshAnimation } from '../../hooks/useRefreshAnimation'
6
6
  import { formatUpdatedAgo, msToNextBucket } from '../../utils/format'
7
7
 
8
8
  export type FreshnessMode = 'auto' | 'snapshot'
9
- export type FreshnessConnection = 'connected' | 'disconnected' | 'connecting'
9
+ // 'syncing' is the progressive first-load shell: the app has a live link to
10
+ // the cluster and serves kinds as their initial sync completes — nothing was
11
+ // ever connected before, so 'Reconnecting…' would be untrue there.
12
+ export type FreshnessConnection = 'connected' | 'disconnected' | 'connecting' | 'syncing'
10
13
 
11
14
  interface FreshnessControlProps {
12
15
  // 'auto' — the view keeps itself current (polls or streams). Reads
@@ -85,7 +88,10 @@ export function FreshnessControl({
85
88
  let label: string | null
86
89
  let tooltip: string | null
87
90
  let live = false
88
- if (degraded) {
91
+ if (connectionState === 'syncing') {
92
+ label = 'Loading cluster data…'
93
+ tooltip = 'Initial sync in progress — rows shown are live for the kinds that have finished loading.'
94
+ } else if (degraded) {
89
95
  label = 'Reconnecting…'
90
96
  tooltip = 'Not connected to the cluster — data may be stale until the connection is restored.'
91
97
  } else if (mode === 'auto' && paused) {
@@ -36,7 +36,7 @@ import {
36
36
  import type { TimelineEvent, ResourceRef, Relationships, SelectedResource, ResolvedEnvFrom, Topology, TopologyNode, HPADiagnosis, WorkloadPodInfo } from '../../types'
37
37
  import type { GitOpsStatus } from '../../types/gitops'
38
38
  import type { NavigateToResource } from '../../utils/navigation'
39
- import { refToSelectedResource, pluralToKind, kindToPlural, kindToPluralWithGroup, apiVersionToGroup } from '../../utils/navigation'
39
+ import { refToSelectedResource, pluralToKind, knownKindForPluralWithGroup, kindToPlural, kindToPluralWithGroup, apiVersionToGroup } from '../../utils/navigation'
40
40
  import { neighborhoodFor, seedNodeIds } from '../../utils/topology-neighborhood'
41
41
  import { TopologyGraph } from '../topology/TopologyGraph'
42
42
  import { gitOpsOwnerFromRelationships, type GitOpsOwnerRef } from '../../utils/gitops-owner'
@@ -731,7 +731,7 @@ export function WorkloadView({
731
731
  | ((ctx: { kind: string; group?: string; namespace: string; name: string; health?: DiagnoseHealthHint }) => ReactNode)
732
732
  | undefined
733
733
  const diagnoseAction = renderDiagnose?.({
734
- kind: apiKind,
734
+ kind: resource?.kind ?? knownKindForPluralWithGroup(apiKind, group ?? '') ?? apiKind,
735
735
  group,
736
736
  namespace,
737
737
  name,
@@ -95,6 +95,11 @@
95
95
  border-style: solid;
96
96
  border-color: transparent;
97
97
  line-height: 1.25rem;
98
+ /* A badge sits in table cells the reader can drag narrower than the label.
99
+ Without a ceiling it keeps its intrinsic width and draws straight over
100
+ the next column, so two cells become unreadable instead of one. */
101
+ max-width: 100%;
102
+ overflow: hidden;
98
103
  }
99
104
 
100
105
  .badge-sm {
@@ -108,6 +113,17 @@
108
113
  border-style: solid;
109
114
  border-color: transparent;
110
115
  line-height: 1.25rem;
116
+ max-width: 100%;
117
+ overflow: hidden;
118
+ }
119
+
120
+ /* The icon is the one part of a badge that must never be traded for text:
121
+ a 12px glyph squeezed to 5px reads as a rendering fault, and a severity
122
+ icon that shrinks is worse than one that is cropped away cleanly. Flex
123
+ would otherwise shrink it first, because it is the item with slack. */
124
+ .badge > svg,
125
+ .badge-sm > svg {
126
+ flex-shrink: 0;
111
127
  }
112
128
 
113
129
  /* ── INNER CARDS ── */
package/src/types/core.ts CHANGED
@@ -631,6 +631,11 @@ export interface ResourceRef {
631
631
 
632
632
  // Computed relationships for a resource
633
633
  export interface Relationships {
634
+ reflection?: {
635
+ source?: ResourceRef
636
+ sourceResourceVersion?: string
637
+ mirrors?: ResourceRef[]
638
+ }
634
639
  owner?: ResourceRef
635
640
  deployment?: ResourceRef // Grandparent Deployment (for Pods owned by ReplicaSets)
636
641
  managedBy?: ResourceRef[] // Topmost meaningful manager(s): GitOps controller (ArgoCD Application / Flux Kustomization / Flux HelmRelease), Helm release, or the topmost K8s owner. Synthesized server-side; replaces client-side detectGitOpsOwner.
@@ -1251,7 +1256,7 @@ export interface MetricsDataPoint {
1251
1256
  export interface TrafficEndpoint {
1252
1257
  name: string
1253
1258
  namespace: string
1254
- kind: string // Pod, Service, External
1259
+ kind: string // Pod, Service, External, Host, Unknown
1255
1260
  ip?: string
1256
1261
  labels?: Record<string, string>
1257
1262
  workload?: string
@@ -1292,6 +1297,15 @@ export interface TrafficFlow {
1292
1297
  * status code for no single flow. */
1293
1298
  errorRate?: number
1294
1299
  verdict: string // forwarded, dropped, error
1300
+ /** The network plugin's own account of which policies decided this flow
1301
+ * (Hubble reports it). Absent when the plugin said nothing. */
1302
+ policyVerdict?: {
1303
+ allowedBy?: { kind: string; namespace?: string; name: string }[]
1304
+ deniedBy?: { kind: string; namespace?: string; name: string }[]
1305
+ /** Denying references removed before delivery because the viewer may not
1306
+ * read policies of that kind there; the plugin still named a policy. */
1307
+ withheld?: number
1308
+ }
1295
1309
  lastSeen: string // ISO date string
1296
1310
  }
1297
1311
 
@@ -13,6 +13,7 @@ export interface GitOpsInsight {
13
13
  }
14
14
 
15
15
  import type { GitOpsTool } from './gitops'
16
+ import type { GitOpsHealthMode, GitOpsHealthSource } from './gitops-tree'
16
17
 
17
18
  // Closed enums mirroring `pkg/gitops/insights/vocab.go`. Keeping the FE
18
19
  // vocabulary in lockstep with the Go side means switches over these fields
@@ -79,6 +80,19 @@ export interface GitOpsInsightSummary {
79
80
  // (jqPathExpressions / managedFieldsManagers rules) — the
80
81
  // drift panel may surface fields Argo's own UI suppresses.
81
82
  ignoredDifferences?: GitOpsIgnoredDifferences
83
+ // Argo roots: where Argo keeps per-resource health. 'appTree' means the
84
+ // controller's verdicts are not in the Application object and any
85
+ // per-resource health shown is Radar's own read.
86
+ resourceHealthMode?: GitOpsHealthMode
87
+ // The Application deploys to another cluster; Radar derives nothing about
88
+ // its resources from here.
89
+ remoteDestination?: boolean
90
+ // Per-resource health came from Argo CD's API server: the verdicts shown
91
+ // are Argo's even though the Application object doesn't carry them.
92
+ resourceHealthFromApi?: boolean
93
+ // The controller's API server was asked and didn't answer usefully — why,
94
+ // in the user's words. Only set when the integration is configured.
95
+ resourceHealthApiError?: string
82
96
  }
83
97
 
84
98
  export interface GitOpsIgnoredDifferences {
@@ -116,6 +130,10 @@ export interface GitOpsIssue {
116
130
  // a contextual action button. Nil when no automated remedy applies — the
117
131
  // `action` string still describes the manual path in that case.
118
132
  remediation?: GitOpsRemediation
133
+ // Set on resource-scoped issues: 'controller' when the GitOps controller's
134
+ // own per-resource health produced it, 'radar' when Radar's issues engine
135
+ // did because the controller's verdict wasn't available.
136
+ source?: GitOpsHealthSource
119
137
  }
120
138
 
121
139
  export type GitOpsRemediationKind = 'create-namespace'
@@ -131,6 +149,11 @@ export interface GitOpsChange {
131
149
  category: GitOpsCategory
132
150
  sync?: string
133
151
  health?: string
152
+ // Provenance of `health`; see GitOpsTreeNode.healthSource. `message` holds
153
+ // the health message from whichever source produced `health`.
154
+ healthSource?: GitOpsHealthSource
155
+ healthReason?: string
156
+ healthSeverity?: string
134
157
  message?: string
135
158
  // Per-resource sync failure message (Argo's status.resources[].syncResult).
136
159
  // Distinct from `message` (live health). Empty when sync succeeded.
@@ -24,6 +24,16 @@ export interface GitOpsTreeNode {
24
24
  tool: GitOpsTreeTool
25
25
  sync?: SyncStatus
26
26
  health?: GitOpsHealthStatus
27
+ // Who assessed `health`. 'controller' is the GitOps controller's own
28
+ // verdict; 'radar' is Radar's read of the live object (topology status, or
29
+ // the issues engine's finding when the controller's verdict isn't
30
+ // available). The UI labels 'radar' values so they never pass as the
31
+ // controller's. Absent when `health` is absent. Kept open for sources a
32
+ // newer backend may add — unknown values render without a label.
33
+ healthSource?: GitOpsHealthSource
34
+ healthReason?: string
35
+ healthMessage?: string
36
+ healthSeverity?: 'critical' | 'warning' | (string & {})
27
37
  topologyStatus?: HealthStatus
28
38
  info?: GitOpsTreeInfoItem[]
29
39
  resource?: unknown
@@ -32,6 +42,16 @@ export interface GitOpsTreeNode {
32
42
  data?: Record<string, unknown>
33
43
  }
34
44
 
45
+ // 'controllerApi' is the controller's own verdict read from its API server
46
+ // (argocd-server) because the CR doesn't carry it; same authority as
47
+ // 'controller'.
48
+ export type GitOpsHealthSource = 'controller' | 'controllerApi' | 'radar' | (string & {})
49
+
50
+ // Where an Argo CD Application keeps per-resource health. 'appTree' is the
51
+ // Argo CD 3 default: the controller's per-resource verdicts are not in the
52
+ // Application object, so any node health present came from Radar.
53
+ export type GitOpsHealthMode = 'inline' | 'appTree' | (string & {})
54
+
35
55
  export type GitOpsTreeEdgeType = 'owns' | 'source' | 'dependsOn'
36
56
 
37
57
  export interface GitOpsTreeEdge {
@@ -54,4 +74,13 @@ export interface GitOpsResourceTree {
54
74
  edges: GitOpsTreeEdge[]
55
75
  warnings?: string[]
56
76
  summary?: GitOpsTreeSummary
77
+ healthMode?: GitOpsHealthMode
78
+ // Per-resource health came from the controller's API server, so in
79
+ // appTree mode the verdicts are still the controller's.
80
+ healthFromApi?: boolean
81
+ // The controller's API server was asked and didn't answer usefully.
82
+ healthApiError?: string
83
+ // The Application deploys to another cluster; Radar derives nothing about
84
+ // its resources from here.
85
+ remoteDestination?: boolean
57
86
  }
@@ -30,3 +30,4 @@ export * from './application-history'
30
30
  export * from './bulk-workload-actions'
31
31
  export * from './inClusterConsent'
32
32
  export * from './workflow-execution'
33
+ export * from './network-policy'
@@ -1,6 +1,6 @@
1
1
  import { describe, test, expect, afterEach } from 'vitest'
2
2
  import { englishPlural } from './pluralize'
3
- import { kindToPlural, kindToPluralWithGroup, pluralToKind, refToSelectedResource, initNavigationMap, resetNavigationMap, laneId, laneResourceKey, groupQualifiesLaneId, parseLaneId } from './navigation'
3
+ import { kindToPlural, kindToPluralWithGroup, pluralToKind, knownKindForPluralWithGroup, refToSelectedResource, initNavigationMap, resetNavigationMap, laneId, laneResourceKey, groupQualifiesLaneId, parseLaneId } from './navigation'
4
4
 
5
5
  afterEach(() => {
6
6
  resetNavigationMap()
@@ -223,6 +223,25 @@ describe('initNavigationMap', () => {
223
223
  { group: 'metrics.k8s.io', version: 'v1beta1', kind: 'PodMetrics', name: 'pods', namespaced: true, isCrd: false, verbs: ['get'] },
224
224
  ])
225
225
  expect(pluralToKind('pods')).toBe('Pod')
226
+ expect(knownKindForPluralWithGroup('pods', '')).toBe('Pod')
227
+ expect(knownKindForPluralWithGroup('pods', 'metrics.k8s.io')).toBe('PodMetrics')
228
+ expect(kindToPluralWithGroup('PodMetrics', 'metrics.k8s.io')).toBe('pods')
229
+ })
230
+
231
+ test('resolves discovered plural collisions by API group', () => {
232
+ initNavigationMap([
233
+ { group: 'a.example.io', version: 'v1', kind: 'Widget', name: 'widgets', namespaced: true, isCrd: true, verbs: ['get'] },
234
+ { group: 'b.example.io', version: 'v1', kind: 'OtherWidget', name: 'widgets', namespaced: true, isCrd: true, verbs: ['get'] },
235
+ ])
236
+ expect(knownKindForPluralWithGroup('widgets', 'a.example.io')).toBe('Widget')
237
+ expect(knownKindForPluralWithGroup('widgets', 'b.example.io')).toBe('OtherWidget')
238
+ expect(knownKindForPluralWithGroup('OtherWidget', 'b.example.io')).toBe('OtherWidget')
239
+ })
240
+
241
+ test('does not invent a Kind for an undiscovered irregular CRD', () => {
242
+ expect(knownKindForPluralWithGroup('databases', 'postgresql.cnpg.io')).toBeUndefined()
243
+ expect(kindToPluralWithGroup('Database', 'postgresql.cnpg.io')).toBe('databases')
244
+ expect(kindToPluralWithGroup('databases', 'postgresql.cnpg.io')).toBe('databases')
226
245
  })
227
246
 
228
247
  test('keeps the virtual PodGroup alias distinct from scheduling PodGroup', () => {
@@ -1,5 +1,6 @@
1
1
  import type { SelectedResource, ResourceRef, APIResource } from '../types/core'
2
2
  import { englishPlural, englishSingular, isEnglishPlural } from './pluralize'
3
+ import { CORE_RESOURCES } from './api-resources'
3
4
 
4
5
  /**
5
6
  * Canonical callback type for navigating to a resource.
@@ -44,6 +45,15 @@ const BUILTIN_PLURAL_TO_KIND: Record<string, string> = {
44
45
 
45
46
  const BUILTIN_GROUP_KIND_TO_PLURAL: Record<string, string> = {
46
47
  'scheduling.k8s.io/podgroup': 'podgroups',
48
+ 'metrics.k8s.io/podmetrics': 'pods',
49
+ 'metrics.k8s.io/nodemetrics': 'nodes',
50
+ }
51
+
52
+ const BUILTIN_GROUP_PLURAL_TO_KIND: Record<string, string> = {
53
+ ...Object.fromEntries(CORE_RESOURCES.map(r => [`${r.group}/${r.name}`, r.kind])),
54
+ 'metrics.k8s.io/pods': 'PodMetrics',
55
+ 'metrics.k8s.io/nodes': 'NodeMetrics',
56
+ 'scheduling.k8s.io/podgroups': 'PodGroup',
47
57
  }
48
58
 
49
59
  // Dynamic map built from API discovery — populated by initNavigationMap().
@@ -51,6 +61,7 @@ const BUILTIN_GROUP_KIND_TO_PLURAL: Record<string, string> = {
51
61
  let discoveredPluralToKind: Record<string, string> | null = null
52
62
  let discoveredKindToPlural: Record<string, string> | null = null
53
63
  let discoveredGroupKindToPlural: Record<string, string> | null = null
64
+ let discoveredGroupPluralToKind: Record<string, string> | null = null
54
65
 
55
66
  /**
56
67
  * Initialize navigation maps from discovered API resources.
@@ -61,6 +72,7 @@ export function initNavigationMap(resources: APIResource[]) {
61
72
  const p2k: Record<string, string> = { ...BUILTIN_PLURAL_TO_KIND }
62
73
  const k2p: Record<string, string> = {}
63
74
  const gk2p: Record<string, string> = {}
75
+ const gp2k: Record<string, string> = { ...BUILTIN_GROUP_PLURAL_TO_KIND }
64
76
  for (const r of resources) {
65
77
  const plural = r.name.toLowerCase()
66
78
  // First-wins on plurals: BUILTIN_PLURAL_TO_KIND seeds canonical core mappings
@@ -69,10 +81,13 @@ export function initNavigationMap(resources: APIResource[]) {
69
81
  if (!(plural in p2k)) p2k[plural] = r.kind
70
82
  k2p[r.kind.toLowerCase()] = plural
71
83
  gk2p[`${r.group}/${r.kind.toLowerCase()}`] = plural
84
+ const groupPlural = `${r.group}/${plural}`
85
+ if (!(groupPlural in gp2k)) gp2k[groupPlural] = r.kind
72
86
  }
73
87
  discoveredPluralToKind = p2k
74
88
  discoveredKindToPlural = k2p
75
89
  discoveredGroupKindToPlural = gk2p
90
+ discoveredGroupPluralToKind = gp2k
76
91
  }
77
92
 
78
93
  /** Reset navigation maps to builtin-only state. For testing. */
@@ -80,6 +95,7 @@ export function resetNavigationMap() {
80
95
  discoveredPluralToKind = null
81
96
  discoveredKindToPlural = null
82
97
  discoveredGroupKindToPlural = null
98
+ discoveredGroupPluralToKind = null
83
99
  }
84
100
 
85
101
  function getPluralToKind(): Record<string, string> {
@@ -166,6 +182,15 @@ export function pluralToKind(plural: string): string {
166
182
  return singular.charAt(0).toUpperCase() + singular.slice(1)
167
183
  }
168
184
 
185
+ /** Return an exact Kind only when the input or group-qualified discovery proves it. */
186
+ export function knownKindForPluralWithGroup(plural: string, group: string): string | undefined {
187
+ if (!plural) return undefined
188
+ if (plural[0] !== plural[0].toLowerCase()) return plural
189
+ const groupPlural = `${group}/${plural.toLowerCase()}`
190
+ return discoveredGroupPluralToKind?.[groupPlural]
191
+ ?? BUILTIN_GROUP_PLURAL_TO_KIND[groupPlural]
192
+ }
193
+
169
194
  /**
170
195
  * Convert a ResourceRef (from backend relationships) to a SelectedResource (for navigation).
171
196
  * Handles kind singular→plural conversion.
@@ -0,0 +1,42 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { effectivePolicyTypeNames, effectivePolicyTypes, formatNetworkPolicyPort } from './network-policy'
3
+
4
+ describe('effectivePolicyTypes', () => {
5
+ it('reads an explicit list as authoritative', () => {
6
+ expect(effectivePolicyTypes({ policyTypes: ['Egress'] })).toEqual({ ingress: false, egress: true, explicit: true })
7
+ expect(effectivePolicyTypes({ policyTypes: ['Ingress', 'Egress'], egress: [] })).toEqual({ ingress: true, egress: true, explicit: true })
8
+ })
9
+
10
+ it('defaults an omitted list to Ingress — the classic default-deny', () => {
11
+ expect(effectivePolicyTypes({ podSelector: {} })).toEqual({ ingress: true, egress: false, explicit: false })
12
+ expect(effectivePolicyTypes({ policyTypes: [] })).toEqual({ ingress: true, egress: false, explicit: false })
13
+ expect(effectivePolicyTypes(undefined)).toEqual({ ingress: true, egress: false, explicit: false })
14
+ })
15
+
16
+ it('adds Egress to the default only when egress rules are declared', () => {
17
+ expect(effectivePolicyTypes({ egress: [{}] })).toEqual({ ingress: true, egress: true, explicit: false })
18
+ expect(effectivePolicyTypes({ egress: [] })).toEqual({ ingress: true, egress: false, explicit: false })
19
+ })
20
+
21
+ it('names the effective types as Ingress then Egress', () => {
22
+ expect(effectivePolicyTypeNames({})).toEqual(['Ingress'])
23
+ expect(effectivePolicyTypeNames({ egress: [{}] })).toEqual(['Ingress', 'Egress'])
24
+ expect(effectivePolicyTypeNames({ policyTypes: ['Egress'] })).toEqual(['Egress'])
25
+ expect(effectivePolicyTypeNames({ policyTypes: ['Egress', 'Ingress'] })).toEqual(['Ingress', 'Egress'])
26
+ })
27
+ })
28
+
29
+ describe('formatNetworkPolicyPort', () => {
30
+ it('renders single ports, ranges, named ports and protocol-only entries', () => {
31
+ expect(formatNetworkPolicyPort({ port: 80 })).toBe('TCP/80')
32
+ expect(formatNetworkPolicyPort({ protocol: 'UDP', port: 53 })).toBe('UDP/53')
33
+ expect(formatNetworkPolicyPort({ port: 9000, endPort: 9100 })).toBe('TCP/9000-9100')
34
+ expect(formatNetworkPolicyPort({ port: 'metrics' })).toBe('TCP/metrics')
35
+ expect(formatNetworkPolicyPort({ protocol: 'SCTP' })).toBe('SCTP/*')
36
+ })
37
+
38
+ it('never shows a range off a named port or off no port', () => {
39
+ expect(formatNetworkPolicyPort({ port: 'metrics', endPort: 9100 })).toBe('TCP/metrics')
40
+ expect(formatNetworkPolicyPort({ endPort: 9100 })).toBe('TCP/*')
41
+ })
42
+ })