@skyhook-io/k8s-ui 1.17.0 → 1.17.1

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 (227) hide show
  1. package/README.md +41 -0
  2. package/package.json +3 -3
  3. package/src/components/charts/AreaChart.interaction.test.tsx +118 -0
  4. package/src/components/charts/AreaChart.test.tsx +25 -0
  5. package/src/components/charts/AreaChart.tsx +141 -12
  6. package/src/components/charts/index.ts +1 -0
  7. package/src/components/checks/ChecksView.tsx +18 -1
  8. package/src/components/cnpg/CNPGBackupSummary.tsx +57 -9
  9. package/src/components/cnpg/CNPGClusterHASection.test.tsx +90 -0
  10. package/src/components/cnpg/CNPGClusterHASection.tsx +331 -0
  11. package/src/components/cnpg/CNPGClusterSummary.test.tsx +275 -0
  12. package/src/components/cnpg/CNPGClusterSummary.tsx +310 -96
  13. package/src/components/cnpg/CNPGConnectSection.test.tsx +64 -0
  14. package/src/components/cnpg/CNPGConnectSection.tsx +178 -0
  15. package/src/components/cnpg/CNPGDeclarativeSummary.tsx +157 -26
  16. package/src/components/cnpg/CNPGImageCatalogSummary.tsx +7 -4
  17. package/src/components/cnpg/CNPGLogicalPath.test.tsx +22 -0
  18. package/src/components/cnpg/CNPGLogicalPath.tsx +150 -0
  19. package/src/components/cnpg/CNPGObjectStoreSummary.tsx +9 -6
  20. package/src/components/cnpg/CNPGObjectSummary.test.tsx +193 -2
  21. package/src/components/cnpg/CNPGPoolerScheduling.test.tsx +36 -0
  22. package/src/components/cnpg/CNPGPoolerSummary.tsx +241 -14
  23. package/src/components/cnpg/CNPGSharedSummary.tsx +15 -8
  24. package/src/components/cnpg/CNPGWALArchivingFact.test.tsx +39 -0
  25. package/src/components/cnpg/backupRuns.test.ts +17 -0
  26. package/src/components/cnpg/backupRuns.ts +19 -0
  27. package/src/components/cnpg/connect.test.ts +124 -0
  28. package/src/components/cnpg/connect.ts +155 -0
  29. package/src/components/cnpg/databaseRole.test.ts +38 -0
  30. package/src/components/cnpg/databaseRole.ts +80 -0
  31. package/src/components/cnpg/ha.test.ts +446 -0
  32. package/src/components/cnpg/ha.ts +621 -0
  33. package/src/components/cnpg/index.ts +18 -0
  34. package/src/components/cnpg/logicalReplication.test.ts +121 -0
  35. package/src/components/cnpg/logicalReplication.ts +252 -0
  36. package/src/components/cnpg/pooler.test.ts +84 -0
  37. package/src/components/cnpg/pooler.ts +225 -0
  38. package/src/components/cnpg/primitives.tsx +21 -117
  39. package/src/components/cnpg/problems.ts +12 -0
  40. package/src/components/cnpg/relations.test.ts +63 -13
  41. package/src/components/cnpg/relations.ts +61 -52
  42. package/src/components/cnpg/schedule.ts +35 -0
  43. package/src/components/cnpg/workspace-disk.test.ts +97 -0
  44. package/src/components/cnpg/workspace-fleet-metrics.test.ts +250 -0
  45. package/src/components/cnpg/workspace-problems.test.ts +175 -0
  46. package/src/components/cnpg/workspace.test.ts +380 -9
  47. package/src/components/cnpg/workspace.ts +1137 -136
  48. package/src/components/dock/DockContext.test.tsx +32 -0
  49. package/src/components/dock/DockContext.tsx +18 -4
  50. package/src/components/dock/LocalTerminalTab.test.tsx +40 -0
  51. package/src/components/dock/LocalTerminalTab.tsx +4 -3
  52. package/src/components/dock/TerminalTab.tsx +20 -8
  53. package/src/components/facts/certainty.tsx +36 -0
  54. package/src/components/facts/facts.test.tsx +24 -0
  55. package/src/components/facts/facts.tsx +62 -0
  56. package/src/components/facts/index.ts +6 -0
  57. package/src/components/facts/managed-by.test.tsx +17 -0
  58. package/src/components/facts/managed-by.tsx +36 -0
  59. package/src/components/facts/public-exports.test.tsx +35 -0
  60. package/src/components/gitops/GitOpsGraphFilterRail.tsx +6 -2
  61. package/src/components/gitops/GitOpsHealthSourceNotice.test.tsx +38 -0
  62. package/src/components/gitops/GitOpsHealthSourceNotice.tsx +13 -3
  63. package/src/components/gitops/insights/ArgoResourceDiff.test.tsx +115 -0
  64. package/src/components/gitops/insights/ArgoResourceDiff.tsx +22 -5
  65. package/src/components/gitops/insights/DriftPanel.tsx +62 -0
  66. package/src/components/gitops/insights/GitOpsInsightViews.tsx +24 -71
  67. package/src/components/gitops/tree/GitOpsTreeGraph.test.ts +26 -1
  68. package/src/components/gitops/tree/GitOpsTreeGraph.tsx +38 -55
  69. package/src/components/gitops/tree/layout.test.ts +111 -0
  70. package/src/components/gitops/tree/layout.ts +128 -0
  71. package/src/components/gitops/tree/tree-helpers.test.ts +17 -0
  72. package/src/components/gitops/tree/tree-helpers.ts +7 -1
  73. package/src/components/issues/ConditionalCleanup.test.tsx +82 -0
  74. package/src/components/issues/IssuesView.tsx +40 -5
  75. package/src/components/issues/ResourceIssuesSection.test.tsx +28 -0
  76. package/src/components/issues/ResourceIssuesSection.tsx +3 -0
  77. package/src/components/issues/index.ts +3 -1
  78. package/src/components/issues/issue-timing.ts +12 -1
  79. package/src/components/issues/issues.test.ts +49 -1
  80. package/src/components/issues/severity.ts +21 -0
  81. package/src/components/issues/types.ts +23 -0
  82. package/src/components/logs/LogCore.theme.test.tsx +81 -3
  83. package/src/components/logs/LogCore.tsx +25 -17
  84. package/src/components/logs/LogToolbarSelects.tsx +3 -1
  85. package/src/components/logs/LogsViewer.test.tsx +291 -0
  86. package/src/components/logs/LogsViewer.tsx +72 -11
  87. package/src/components/logs/StructuredLogLine.test.tsx +76 -0
  88. package/src/components/logs/StructuredLogLine.tsx +22 -30
  89. package/src/components/logs/WorkloadLogsViewer.test.tsx +65 -0
  90. package/src/components/logs/WorkloadLogsViewer.tsx +39 -10
  91. package/src/components/logs/useLogBuffer.test.ts +8 -0
  92. package/src/components/logs/useLogStream.ts +17 -1
  93. package/src/components/problems/index.ts +1 -0
  94. package/src/components/problems/problems.test.tsx +63 -0
  95. package/src/components/problems/problems.tsx +165 -0
  96. package/src/components/resources/ActiveFilterBar.test.tsx +78 -0
  97. package/src/components/resources/ActiveFilterBar.tsx +86 -0
  98. package/src/components/resources/ResourcesSidebar.keyboard.test.tsx +42 -0
  99. package/src/components/resources/ResourcesSidebar.test.tsx +36 -2
  100. package/src/components/resources/ResourcesSidebar.tsx +92 -41
  101. package/src/components/resources/ResourcesView.kind-filter-memory.test.ts +52 -0
  102. package/src/components/resources/ResourcesView.kind-view.test.tsx +149 -0
  103. package/src/components/resources/ResourcesView.per-container.test.ts +56 -1
  104. package/src/components/resources/ResourcesView.tsx +302 -109
  105. package/src/components/resources/curated-column-ownership.test.ts +3 -3
  106. package/src/components/resources/renderers/AutoscalingReferences.test.tsx +286 -0
  107. package/src/components/resources/renderers/CAPIClusterRenderer.tsx +3 -2
  108. package/src/components/resources/renderers/CAPIExactReferenceNavigation.test.tsx +27 -0
  109. package/src/components/resources/renderers/CNPGBackupRenderer.test.tsx +23 -0
  110. package/src/components/resources/renderers/CNPGBackupRenderer.tsx +8 -19
  111. package/src/components/resources/renderers/CNPGClusterRenderer.tsx +5 -7
  112. package/src/components/resources/renderers/CNPGDeclarativeRenderer.tsx +25 -0
  113. package/src/components/resources/renderers/CNPGScheduledBackupRenderer.test.tsx +32 -0
  114. package/src/components/resources/renderers/CNPGScheduledBackupRenderer.tsx +7 -15
  115. package/src/components/resources/renderers/CompositeRenderer.navigation.test.tsx +35 -0
  116. package/src/components/resources/renderers/CompositeRenderer.tsx +3 -3
  117. package/src/components/resources/renderers/CompositionRenderer.tsx +3 -3
  118. package/src/components/resources/renderers/ContourExactReferenceNavigation.test.tsx +34 -0
  119. package/src/components/resources/renderers/ContourHTTPProxyRenderer.tsx +11 -10
  120. package/src/components/resources/renderers/EndpointSliceRenderer.test.tsx +39 -1
  121. package/src/components/resources/renderers/EndpointSliceRenderer.tsx +14 -13
  122. package/src/components/resources/renderers/HPARenderer.tsx +56 -16
  123. package/src/components/resources/renderers/IngressRenderer.defaultBackend.test.tsx +33 -0
  124. package/src/components/resources/renderers/IngressRenderer.tsx +30 -3
  125. package/src/components/resources/renderers/IstioDestinationRuleRenderer.tsx +9 -9
  126. package/src/components/resources/renderers/IstioExactReferenceNavigation.test.tsx +34 -0
  127. package/src/components/resources/renderers/IstioVirtualServiceRenderer.tsx +5 -8
  128. package/src/components/resources/renderers/KedaScaledObjectRenderer.tsx +13 -11
  129. package/src/components/resources/renderers/KnativeEventingRenderer.tsx +24 -31
  130. package/src/components/resources/renderers/KnativeExactReferenceNavigation.test.tsx +47 -0
  131. package/src/components/resources/renderers/KnativeFlowRenderer.tsx +6 -7
  132. package/src/components/resources/renderers/KnativeNetworkingRenderer.tsx +7 -9
  133. package/src/components/resources/renderers/KnativeReferenceLink.tsx +14 -0
  134. package/src/components/resources/renderers/KnativeReferenceLinks.test.tsx +60 -0
  135. package/src/components/resources/renderers/KnativeSourceRenderer.tsx +9 -11
  136. package/src/components/resources/renderers/ManagedResourceRenderer.tsx +2 -2
  137. package/src/components/resources/renderers/MetricsUnavailableNotice.tsx +3 -2
  138. package/src/components/resources/renderers/PodRenderer.test.tsx +32 -0
  139. package/src/components/resources/renderers/PodRenderer.tsx +41 -29
  140. package/src/components/resources/renderers/SimpleRouteRenderer.tsx +1 -1
  141. package/src/components/resources/renderers/VPARenderer.tsx +17 -11
  142. package/src/components/resources/resource-utils-capi.ts +1 -1
  143. package/src/components/resources/resource-utils-cnpg.test.ts +35 -10
  144. package/src/components/resources/resource-utils-cnpg.ts +51 -20
  145. package/src/components/resources/resource-utils-contour.test.ts +44 -0
  146. package/src/components/resources/resource-utils-contour.ts +16 -0
  147. package/src/components/resources/resource-utils-istio.ts +12 -8
  148. package/src/components/resources/resource-utils-knative.ts +18 -0
  149. package/src/components/resources/resource-utils.ts +82 -6
  150. package/src/components/resources/rules-column-width.test.ts +1 -1
  151. package/src/components/resources/summarize-scheduler-message.test.ts +30 -0
  152. package/src/components/shared/ActionConfirmDialog.test.tsx +154 -0
  153. package/src/components/shared/ActionConfirmDialog.tsx +246 -0
  154. package/src/components/shared/CreateResourceDialog.test.tsx +330 -0
  155. package/src/components/shared/CreateResourceDialog.tsx +126 -80
  156. package/src/components/shared/DetailShell.test.tsx +80 -0
  157. package/src/components/shared/DetailShell.tsx +85 -10
  158. package/src/components/shared/EditableYamlView.test.tsx +7 -0
  159. package/src/components/shared/EditableYamlView.tsx +1 -1
  160. package/src/components/shared/ResourceActionsBar.tsx +11 -4
  161. package/src/components/shared/ResourceRendererDispatch.tsx +12 -9
  162. package/src/components/shared/index.ts +2 -0
  163. package/src/components/timeline/TimelineSwimlanes.tsx +1 -1
  164. package/src/components/timeline/timeline-filters.ts +1 -0
  165. package/src/components/topology/K8sResourceNode.test.ts +18 -1
  166. package/src/components/topology/K8sResourceNode.tsx +44 -2
  167. package/src/components/topology/TopologyFilterSidebar.tsx +1 -0
  168. package/src/components/topology/TopologyGraph.tsx +3 -71
  169. package/src/components/topology/TopologySearch.tsx +1 -0
  170. package/src/components/topology/internet-groups.test.ts +35 -0
  171. package/src/components/topology/internet-groups.ts +55 -0
  172. package/src/components/topology/layout.ts +1 -1
  173. package/src/components/ui/Badge.tsx +1 -0
  174. package/src/components/ui/FoldSection.tsx +68 -0
  175. package/src/components/ui/ForceDeleteConfirmDialog.test.tsx +62 -0
  176. package/src/components/ui/ForceDeleteConfirmDialog.tsx +24 -12
  177. package/src/components/ui/RefLink.tsx +20 -0
  178. package/src/components/ui/RowActionMenu.tsx +8 -3
  179. package/src/components/ui/crd-definition-navigation.test.tsx +160 -0
  180. package/src/components/ui/drawer-components.test.tsx +110 -1
  181. package/src/components/ui/drawer-components.tsx +62 -9
  182. package/src/components/ui/index.ts +3 -1
  183. package/src/components/ui/monacoEditorFeatures.ts +77 -0
  184. package/src/components/ui/monacoRuntime.ts +14 -19
  185. package/src/components/ui/status-tone.test.ts +10 -1
  186. package/src/components/ui/status-tone.tsx +29 -0
  187. package/src/components/workload/WorkloadView.deleted.test.tsx +43 -0
  188. package/src/components/workload/WorkloadView.relationships.test.tsx +37 -0
  189. package/src/components/workload/WorkloadView.spec.test.tsx +149 -0
  190. package/src/components/workload/WorkloadView.tsx +180 -64
  191. package/src/index.ts +14 -1
  192. package/src/monaco-deep.d.ts +19 -12
  193. package/src/theme/components.css +13 -0
  194. package/src/types/capacity.ts +11 -2
  195. package/src/types/core.ts +29 -2
  196. package/src/types/gitops-flux-status.test.ts +32 -1
  197. package/src/types/gitops-insights.ts +19 -2
  198. package/src/types/gitops-tree.ts +10 -0
  199. package/src/types/gitops.ts +26 -6
  200. package/src/utils/api-resources.ts +7 -0
  201. package/src/utils/application-history.ts +1 -0
  202. package/src/utils/cnpg-backup.test.ts +26 -0
  203. package/src/utils/cnpg-backup.ts +70 -0
  204. package/src/utils/format.test.ts +9 -0
  205. package/src/utils/format.ts +4 -0
  206. package/src/utils/gitops-owner.ts +3 -2
  207. package/src/utils/grant.test.ts +19 -0
  208. package/src/utils/grant.ts +35 -0
  209. package/src/utils/index.ts +4 -0
  210. package/src/utils/k8s-errors.ts +7 -7
  211. package/src/utils/log-format.test.ts +41 -0
  212. package/src/utils/log-format.ts +71 -15
  213. package/src/utils/log-level.ts +12 -8
  214. package/src/utils/navigation.test.ts +57 -0
  215. package/src/utils/navigation.ts +86 -8
  216. package/src/utils/object-reference-navigation.test.ts +43 -0
  217. package/src/utils/resource-hierarchy.test.ts +37 -0
  218. package/src/utils/resource-hierarchy.ts +15 -7
  219. package/src/utils/resource-icons.ts +2 -0
  220. package/src/utils/skeleton-yaml.test.ts +21 -0
  221. package/src/utils/skeleton-yaml.ts +16 -0
  222. package/src/utils/url-path.test.ts +35 -0
  223. package/src/utils/url-path.ts +21 -0
  224. package/src/utils/validators.test.ts +4 -13
  225. package/src/utils/validators.ts +5 -19
  226. package/src/utils/view-transition.test.ts +35 -1
  227. package/src/utils/view-transition.ts +16 -1
@@ -1,23 +1,32 @@
1
- import type { ReactNode } from 'react'
1
+ import { Fragment, useState, type ReactNode } from 'react'
2
2
  import { clsx } from 'clsx'
3
3
  import { Badge } from '../ui/Badge'
4
4
  import { Tooltip } from '../ui/Tooltip'
5
- import { CNPG_BARMAN_OBJECTSTORE_GROUP, CNPG_GROUP } from '../resources/resource-utils-cnpg'
6
- import type { CNPGFleetRow, CNPGInstance } from './workspace'
7
- import {
8
- FactGrid,
9
- FactRow,
10
- FactSource,
11
- FactValue,
12
- ProblemCallout,
13
- RefLink,
14
- SummaryHeading,
15
- ToneDot,
16
- toneTextClass,
17
- CNPG_PRIMARY_BUTTON,
18
- CNPG_SECONDARY_BUTTON,
19
- type CNPGNavigate,
20
- } from './primitives'
5
+ import { Collapse, CollapseChevron, useDisclosure } from '../ui/Collapse'
6
+ import { classifyCNPGClusterPhase, cnpgBlockedPhaseExplanation, CNPG_BARMAN_OBJECTSTORE_GROUP, CNPG_GROUP } from '../resources/resource-utils-cnpg'
7
+ import { cnpgClusterPlugins, cnpgPluginPhase, cnpgReadyInstances, type CNPGFleetRow, type CNPGInstance } from './workspace'
8
+ import type { CNPGDimension } from './ha'
9
+ import { PrimaryConflictNote, ProblemCallout, cnpgProblemForPresentation } from './primitives'
10
+ import { Note } from './CNPGSharedSummary'
11
+ import { type NavigateToRef, RefLink } from '../ui/RefLink'
12
+ import { StatusDot, toneTextClass } from '../ui/status-tone'
13
+ import { FactGrid, FactRow, FactSource, FactValue, ManagedByText, managedByLabel } from '../facts'
14
+ import { ProblemList } from '../problems'
15
+ import { formatAge } from '../resources/resource-utils'
16
+ import { FoldSection, SectionHeading } from '../ui/FoldSection'
17
+
18
+ function ReadyCount({ row }: { row: CNPGFleetRow }) {
19
+ const r = cnpgReadyInstances(row)
20
+ if (row.instances.ready === null) return <>{r.text}{r.podText && <span className="block text-xs text-theme-text-secondary">{r.podText}</span>}</>
21
+ if (!r.note) return <>{r.text} ready</>
22
+ return (
23
+ <Tooltip content={r.note} position="top">
24
+ <span className={clsx('font-medium', toneTextClass(r.tone ?? 'unknown'))}>
25
+ {r.text} Pods ready <Badge severity="warning" size="sm">status says {row.instances.ready}</Badge>
26
+ </span>
27
+ </Tooltip>
28
+ )
29
+ }
21
30
 
22
31
  export interface CNPGSummaryAction {
23
32
  label: string
@@ -25,10 +34,10 @@ export interface CNPGSummaryAction {
25
34
  primary?: boolean
26
35
  }
27
36
 
28
- function InstancePill({ pod, namespace, onNavigate }: { pod: CNPGInstance; namespace: string; onNavigate?: CNPGNavigate }) {
37
+ function InstancePill({ pod, namespace, onNavigate }: { pod: CNPGInstance; namespace: string; onNavigate?: NavigateToRef }) {
29
38
  const tone = pod.ready === true ? 'healthy' : pod.ready === false ? 'unhealthy' : 'unknown'
30
39
  const role = pod.role === 'primary' ? 'Primary' : pod.role === 'replica' ? 'Replica' : 'Role unknown'
31
- const readiness = pod.ready === true ? 'Ready' : pod.ready === false ? 'Not ready' : 'Readiness unknown'
40
+ const readiness = pod.ready === true ? 'Pod ready' : pod.ready === false ? 'Pod not ready' : 'Pod readiness unknown'
32
41
  return (
33
42
  <Tooltip content={`${pod.name} · ${role} · ${readiness}${pod.node ? ` · ${pod.node}` : ''}`} position="top">
34
43
  <button
@@ -39,7 +48,7 @@ function InstancePill({ pod, namespace, onNavigate }: { pod: CNPGInstance; names
39
48
  onNavigate ? 'hover:border-accent' : 'cursor-default',
40
49
  )}
41
50
  >
42
- <ToneDot tone={tone} />
51
+ <StatusDot tone={tone} />
43
52
  <span className="font-mono">{pod.name}</span>
44
53
  <span className="text-theme-text-tertiary">{pod.role === 'primary' ? 'P' : pod.role === 'replica' ? 'R' : '?'}</span>
45
54
  </button>
@@ -47,122 +56,311 @@ function InstancePill({ pod, namespace, onNavigate }: { pod: CNPGInstance; names
47
56
  )
48
57
  }
49
58
 
59
+ /**
60
+ * One dimension's state as a mark beside the tab that explains it: a dot when
61
+ * something needs a look, a hollow ring when it could not be assessed (never a
62
+ * calm colour), nothing when it is fine. The verdict is in the tooltip; the
63
+ * Overview's At a glance has it in words.
64
+ */
65
+ export function CNPGDimensionMark({ dimension }: { dimension: CNPGDimension }) {
66
+ if (!dimensionMarked(dimension)) return null
67
+ const label = `${dimension.label}: ${dimension.text}`
68
+ return (
69
+ <Tooltip content={<><div>{label}</div><div className="text-theme-text-tertiary">{dimension.source}</div></>} position="bottom">
70
+ <span role="img" aria-label={label} className="inline-flex shrink-0">
71
+ <DimensionGlyph tone={dimension.tone} />
72
+ </span>
73
+ </Tooltip>
74
+ )
75
+ }
76
+
77
+ function dimensionMarked(dimension: CNPGDimension): boolean {
78
+ return dimension.tone !== 'healthy' && dimension.tone !== 'neutral'
79
+ }
80
+
81
+ function DimensionGlyph({ tone }: { tone: CNPGDimension['tone'] }) {
82
+ return tone === 'unknown' ? (
83
+ <span aria-hidden className="inline-block h-[7px] w-[7px] shrink-0 rounded-full border border-theme-text-tertiary" />
84
+ ) : (
85
+ <StatusDot tone={tone} />
86
+ )
87
+ }
88
+
89
+ /**
90
+ * The verdict behind a tab's mark, as the tab's first line, so a mark always
91
+ * points at words on the tab it marks. Nothing when the dimension is fine.
92
+ */
93
+ export function CNPGDimensionVerdict({ dimension, className, alwaysShow = false }: { dimension: CNPGDimension; className?: string; alwaysShow?: boolean }) {
94
+ if (!alwaysShow && !dimensionMarked(dimension)) return null
95
+ return (
96
+ <div className={clsx('flex flex-wrap items-baseline gap-x-2 gap-y-0.5 text-sm', className)}>
97
+ <span className="inline-flex self-center">
98
+ <DimensionGlyph tone={dimension.tone} />
99
+ </span>
100
+ <span className="text-theme-text-secondary">{dimension.label}</span>
101
+ <span className={toneTextClass(dimension.tone)}>{dimension.text}</span>
102
+ <span className="text-xs text-theme-text-tertiary">{dimension.source}</span>
103
+ </div>
104
+ )
105
+ }
106
+
107
+ /** Whether the cluster serves writes, on its title line: the headline the tabs do not carry. */
108
+ export function CNPGServingStatus({ dimension, onSelect }: { dimension: CNPGDimension; onSelect?: () => void }) {
109
+ const body = (
110
+ <>
111
+ <StatusDot tone={dimension.tone} />
112
+ <span className="text-theme-text-secondary">{dimension.label}</span>
113
+ <span className={toneTextClass(dimension.tone)}>{dimension.text}</span>
114
+ </>
115
+ )
116
+ const className = 'inline-flex items-center gap-1.5 whitespace-nowrap text-sm'
117
+ return (
118
+ <Tooltip content={dimension.source} position="bottom">
119
+ {onSelect ? (
120
+ <button
121
+ type="button"
122
+ onClick={onSelect}
123
+ aria-label={`${dimension.label}: ${dimension.text}. Open its details`}
124
+ className={clsx(className, 'rounded hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent')}
125
+ >
126
+ {body}
127
+ </button>
128
+ ) : (
129
+ <span className={className}>{body}</span>
130
+ )}
131
+ </Tooltip>
132
+ )
133
+ }
134
+
135
+ /** "+N more" that opens the rest of the problems in place, when the host links nowhere else. */
136
+ function MoreProblems({ count, open, onToggle, panelId }: { count: number; open: boolean; onToggle: () => void; panelId: string }) {
137
+ return (
138
+ <button
139
+ type="button"
140
+ aria-expanded={open}
141
+ aria-controls={panelId}
142
+ onClick={onToggle}
143
+ className="inline-flex items-center gap-1 text-accent-text hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent rounded"
144
+ >
145
+ <CollapseChevron open={open} className="h-3 w-3" />
146
+ {open ? 'Hide' : `+${count} more`}
147
+ </button>
148
+ )
149
+ }
150
+
50
151
  export function CNPGClusterSummary({
51
152
  row,
52
153
  onNavigate,
53
154
  actions,
54
155
  problemsLink,
55
156
  extra,
157
+ lead,
158
+ dimensions,
159
+ onSelectDimension,
160
+ initialProblemsExpanded = false,
161
+ stateFacts,
162
+ operationalFacts,
163
+ dimensionLinkLabel,
164
+ onOpenOperator,
165
+ framed = false,
56
166
  }: {
57
167
  row: CNPGFleetRow
58
- onNavigate?: CNPGNavigate
168
+ onNavigate?: NavigateToRef
59
169
  actions?: CNPGSummaryAction[]
60
170
  /** Link to the complete list of this cluster's findings, shown when more than one exists. */
61
171
  problemsLink?: (count: number) => ReactNode
62
172
  extra?: ReactNode
173
+ /** Rendered first, above the problem callout: standing states such as maintenance mode. */
174
+ lead?: ReactNode
175
+ /** Serving · Replication · Storage · Backups, each from its own source (see cnpgDimensions); the At a glance rows. */
176
+ dimensions?: CNPGDimension[]
177
+ /** Open with every problem listed below the callout (e.g. arriving from the fleet's "+N more"). */
178
+ initialProblemsExpanded?: boolean
179
+ /** Makes each dimension row open where that dimension is explained (its tab). */
180
+ onSelectDimension?: (id: CNPGDimension['id']) => void
181
+ /** The name of the place onSelectDimension opens, for the row's link; the dimension's own label when unset. */
182
+ dimensionLinkLabel?: (id: CNPGDimension['id']) => string
183
+ /** Opens the operator's own diagnosis, offered beside a controller phase that is not healthy. */
184
+ onOpenOperator?: () => void
185
+ /** Extra FactRows appended to About. */
186
+ stateFacts?: ReactNode
187
+ /** Extra FactRows appended to At a glance, e.g. live instance operations. */
188
+ operationalFacts?: ReactNode
189
+ /** Cards for the full page; drawers keep flat sections. */
190
+ framed?: boolean
63
191
  }) {
64
192
  const top = row.problems[0]
65
193
  const rest = row.problems.length - 1
66
- const p = row.protection
67
194
  const ns = row.namespace
68
195
  const radarFindings = row.problems.some((x) => x.severity !== 'posture')
196
+ const phase = typeof row.cluster?.status?.phase === 'string' ? row.cluster.status.phase : ''
197
+ const blocked = classifyCNPGClusterPhase(phase) === 'terminal' ? cnpgBlockedPhaseExplanation(phase, row.cluster?.status?.phaseReason) : null
198
+ const [showRest, setShowRest] = useState(initialProblemsExpanded)
199
+ const restDisclosure = useDisclosure(showRest)
200
+ const Frame = framed ? 'section' : Fragment
201
+ const frameProps = framed ? { className: 'mb-4 last:mb-0 rounded-xl border border-theme-border bg-theme-surface px-4 py-3 shadow-theme-sm' } : {}
69
202
 
70
203
  return (
71
204
  <div className="px-4 py-4">
205
+ {lead}
72
206
  {top && (
73
- <ProblemCallout
207
+ <div className="[&_.mt-5]:mt-2"><ProblemCallout
74
208
  problem={top}
75
209
  onNavigate={onNavigate}
76
- more={rest > 0 ? problemsLink?.(row.problems.length) ?? <span>+{rest} more</span> : null}
77
- />
210
+ more={
211
+ rest > 0
212
+ ? problemsLink?.(row.problems.length) ?? (
213
+ <MoreProblems count={rest} open={showRest} onToggle={() => setShowRest((v) => !v)} panelId={restDisclosure.panelId} />
214
+ )
215
+ : null
216
+ }
217
+ /></div>
218
+ )}
219
+ {top && rest > 0 && !problemsLink && (
220
+ <Collapse open={showRest} id={restDisclosure.panelId}>
221
+ <div className="mb-4 rounded-lg border border-theme-border bg-theme-base p-3">
222
+ <ProblemList rootKind="Cluster" problems={row.problems.slice(1).map(cnpgProblemForPresentation)} onNavigate={onNavigate} />
223
+ </div>
224
+ </Collapse>
78
225
  )}
79
226
 
80
227
  {actions && actions.length > 0 && (
81
228
  <div className="mb-4 flex flex-wrap gap-2">
82
229
  {actions.map((a) => (
83
- <button key={a.label} type="button" onClick={a.onClick} className={a.primary ? CNPG_PRIMARY_BUTTON : CNPG_SECONDARY_BUTTON}>
230
+ <button key={a.label} type="button" onClick={a.onClick} className={clsx(a.primary ? 'btn-brand' : 'btn-secondary', 'inline-flex shrink-0 items-center gap-1.5 whitespace-nowrap px-3 py-1.5 text-sm font-medium')}>
84
231
  {a.label}
85
232
  </button>
86
233
  ))}
87
234
  </div>
88
235
  )}
89
236
 
90
- <SummaryHeading>State</SummaryHeading>
91
- <FactGrid>
92
- <FactRow label="Controller phase">
93
- <span className="inline-flex flex-wrap items-center gap-2">
94
- <Badge severity={row.controllerStatus.level === 'healthy' ? 'success' : row.controllerStatus.level === 'unhealthy' ? 'error' : row.controllerStatus.level === 'degraded' || row.controllerStatus.level === 'alert' ? 'warning' : 'neutral'} size="sm">
95
- {row.controllerStatus.text}
96
- </Badge>
97
- <span className="text-xs text-theme-text-tertiary">
98
- {radarFindings ? 'reported by CNPG · Radar findings above are separate' : 'reported by CNPG'}
99
- </span>
100
- </span>
101
- </FactRow>
102
- <FactRow label="Instances">
103
- <div>
104
- <span>
105
- {row.instances.ready ?? '–'}/{row.instances.desired ?? '–'} ready
106
- {row.cluster?.status?.currentPrimary && (
107
- <span className="text-theme-text-secondary"> · primary <span className="font-mono">{row.cluster.status.currentPrimary}</span></span>
237
+ <Frame {...frameProps}>
238
+ <SectionHeading>At a glance</SectionHeading>
239
+ <FactGrid>
240
+ {dimensions?.map((d) => (
241
+ <FactRow key={d.id} label={d.label}>
242
+ <DimensionValue dimension={d} linkLabel={dimensionLinkLabel?.(d.id) ?? d.label} onOpen={onSelectDimension ? () => onSelectDimension(d.id) : undefined} />
243
+ </FactRow>
244
+ ))}
245
+ <FactRow label="Instances">
246
+ <div>
247
+ <span>
248
+ <ReadyCount row={row} />
249
+ {row.cluster?.status?.currentPrimary && !row.primaryConflict && (
250
+ <span className="text-theme-text-secondary"> · primary <span className="font-mono">{row.cluster.status.currentPrimary}</span></span>
251
+ )}
252
+ </span>
253
+ {row.primaryConflict && <PrimaryConflictNote conflict={row.primaryConflict} />}
254
+ {row.pods.length > 0 && (
255
+ <div className="mt-1.5 flex flex-wrap gap-1.5">
256
+ {row.pods.map((pod) => (
257
+ <InstancePill key={pod.name} pod={pod} namespace={ns} onNavigate={onNavigate} />
258
+ ))}
259
+ </div>
260
+ )}
261
+ </div>
262
+ </FactRow>
263
+ <FactRow label="Controller phase">
264
+ <span className="inline-flex flex-wrap items-center gap-2">
265
+ <Badge severity={'neutral'} size="sm">
266
+ {phase || 'Not reported'}
267
+ </Badge>
268
+ <span className="text-xs text-theme-text-tertiary">
269
+ {radarFindings ? 'reported by CNPG · Radar findings above are separate' : 'reported by CNPG'}
270
+ </span>
271
+ {onOpenOperator && (row.controllerStatus.level === 'unhealthy' || row.controllerStatus.level === 'degraded') && (
272
+ <button type="button" onClick={onOpenOperator} className="text-xs text-accent-text hover:underline">
273
+ Operator and plugins →
274
+ </button>
108
275
  )}
109
276
  </span>
110
- {row.pods.length > 0 && (
111
- <div className="mt-1.5 flex flex-wrap gap-1.5">
112
- {row.pods.map((pod) => (
113
- <InstancePill key={pod.name} pod={pod} namespace={ns} onNavigate={onNavigate} />
114
- ))}
277
+ {blocked && (
278
+ <div className="mt-0.5 text-[11.5px] text-theme-text-secondary">
279
+ {blocked.body}
280
+ {cnpgPluginPhase(row.cluster) &&
281
+ ` Plugins this cluster uses: ${cnpgClusterPlugins(row.cluster).join(', ') || 'none listed'}. The Operator view shows whether each is running and when it last restarted.`}
115
282
  </div>
116
283
  )}
117
- </div>
118
- </FactRow>
119
- <FactRow label="Replication">
120
- <FactValue fact={row.replication} />
121
- <FactSource fact={row.replication} />
122
- </FactRow>
123
- {row.replicaCluster && (
124
- <FactRow label="Replica cluster">
125
- Follows {row.replicaCluster.source ? <span className="font-mono">{row.replicaCluster.source}</span> : 'an external primary'}
126
284
  </FactRow>
127
- )}
128
- <FactRow label="PostgreSQL">
129
- {row.pgVersion ?? 'Unknown'}
130
- {row.catalog && (
131
- <span className="text-theme-text-secondary">
132
- {' · '}
133
- <RefLink
134
- refTo={{ kind: row.catalog.kind, group: CNPG_GROUP, namespace: row.catalog.kind === 'ClusterImageCatalog' ? '' : ns, name: row.catalog.name }}
135
- onNavigate={onNavigate}
136
- >
137
- {row.catalog.name}
138
- </RefLink>
139
- </span>
140
- )}
141
- </FactRow>
142
- <FactRow label="Declarations">
143
- <FactValue fact={row.declarations.summary} />
144
- </FactRow>
145
- <FactRow label="Poolers">
146
- {row.poolers.length === 0 ? (
147
- <span className={row.poolersKnown ? 'text-theme-text-secondary' : 'text-theme-text-tertiary'}>
148
- {row.poolersKnown ? 'None' : 'No access to Poolers'}
149
- </span>
150
- ) : (
151
- <span className="flex flex-wrap gap-x-3">
152
- {row.poolers.map((name) => (
153
- <RefLink key={name} refTo={{ kind: 'Pooler', group: CNPG_GROUP, namespace: ns, name }} onNavigate={onNavigate} mono />
154
- ))}
155
- </span>
285
+ {operationalFacts}
286
+ </FactGrid>
287
+ </Frame>
288
+
289
+ <Frame {...frameProps}>
290
+ <SectionHeading>About</SectionHeading>
291
+ <FactGrid>
292
+ {row.replicaCluster && (
293
+ <FactRow label="Replica cluster">
294
+ Follows {row.replicaCluster.source ? <span className="font-mono">{row.replicaCluster.source}</span> : 'an external primary'}
295
+ </FactRow>
156
296
  )}
157
- </FactRow>
158
- {row.gitops && (
159
- <FactRow label="Declared in">
160
- {row.gitops.tool === 'argocd' ? 'Argo CD' : 'Flux'} <span className="font-mono">{row.gitops.name}</span>
297
+ <FactRow label="PostgreSQL">
298
+ {row.pgVersion ?? 'Unknown'}
299
+ {row.catalog && (
300
+ <span className="text-theme-text-secondary">
301
+ {' · '}
302
+ <RefLink
303
+ refTo={{ kind: row.catalog.kind, group: CNPG_GROUP, namespace: row.catalog.kind === 'ClusterImageCatalog' ? '' : ns, name: row.catalog.name }}
304
+ onNavigate={onNavigate}
305
+ >
306
+ {row.catalog.name}
307
+ </RefLink>
308
+ </span>
309
+ )}
161
310
  </FactRow>
311
+ <FactRow label="Declarations">
312
+ <FactValue fact={row.declarations.summary} />
313
+ </FactRow>
314
+ <FactRow label="Poolers">
315
+ {row.poolers.length === 0 ? (
316
+ <span className={row.poolersKnown ? 'text-theme-text-secondary' : 'text-theme-text-tertiary'}>
317
+ {row.poolersKnown ? 'None' : 'No access to Poolers'}
318
+ </span>
319
+ ) : (
320
+ <span className="flex flex-wrap gap-x-3">
321
+ {row.poolers.map((name) => (
322
+ <RefLink key={name} refTo={{ kind: 'Pooler', group: CNPG_GROUP, namespace: ns, name }} onNavigate={onNavigate} mono />
323
+ ))}
324
+ </span>
325
+ )}
326
+ </FactRow>
327
+ {row.managedBy && managedByLabel(row.managedBy) && (
328
+ <FactRow label="Declared in">
329
+ <ManagedByText refTo={row.managedBy} onNavigate={onNavigate} />
330
+ </FactRow>
331
+ )}
332
+ {stateFacts}
333
+ </FactGrid>
334
+ </Frame>
335
+
336
+ {extra}
337
+ </div>
338
+ )
339
+ }
340
+
341
+ function DimensionValue({ dimension: d, linkLabel, onOpen }: { dimension: CNPGDimension; linkLabel: string; onOpen?: () => void }) {
342
+ return (
343
+ <div>
344
+ <span className="inline-flex flex-wrap items-center gap-x-2">
345
+ <StatusDot tone={d.tone} />
346
+ <span className={toneTextClass(d.tone)}>{d.text}</span>
347
+ {onOpen && (
348
+ <button type="button" onClick={onOpen} className="text-xs text-accent-text hover:underline">
349
+ {linkLabel} →
350
+ </button>
162
351
  )}
163
- </FactGrid>
352
+ </span>
353
+ {d.source && <div className="text-[11.5px] text-theme-text-tertiary">{d.source}</div>}
354
+ </div>
355
+ )
356
+ }
164
357
 
165
- <SummaryHeading>Protection</SummaryHeading>
358
+ /** A cluster's recovery evidence as facts: schedule, destination, newest backup, WAL archiving, recovery window and restore validation. */
359
+ export function CNPGClusterBackupFacts({ row, onNavigate }: { row: CNPGFleetRow; onNavigate?: NavigateToRef }) {
360
+ const p = row.protection
361
+ const ns = row.namespace
362
+ return (
363
+ <>
166
364
  <FactGrid>
167
365
  <FactRow label="Schedule">
168
366
  <FactValue fact={p.schedule} />
@@ -188,7 +386,7 @@ export function CNPGClusterSummary({
188
386
  <FactSource fact={p.lastSuccessfulBackup} />
189
387
  </FactRow>
190
388
  <FactRow label="WAL archiving">
191
- <FactValue fact={p.walArchiving} />
389
+ <CNPGWALArchivingFact fact={p.walArchiving} />
192
390
  </FactRow>
193
391
  <FactRow label="Recovery window">
194
392
  {p.recoveryWindow.from ? (
@@ -216,7 +414,23 @@ export function CNPGClusterSummary({
216
414
  <FactSource fact={p.restoreValidation} />
217
415
  </FactRow>
218
416
  </FactGrid>
219
- {extra}
220
- </div>
417
+
418
+ </>
221
419
  )
222
420
  }
421
+
422
+ export function CNPGWALArchivingFact({ fact, compact = false }: { fact: CNPGFleetRow['protection']['walArchiving']; compact?: boolean }) {
423
+ const c = fact.operatorCondition
424
+ return <div>
425
+ <FactValue fact={fact} className="break-words" />
426
+ <FactSource fact={fact} />
427
+ {fact.detail && <Note>{compact && fact.state === 'no_destination' ? 'No point-in-time recovery' : fact.detail}</Note>}
428
+ {c && <div className="mt-1" onClick={(e) => e.stopPropagation()}>
429
+ <FoldSection title="Operator report" summary="" attention={false}>
430
+ <div className="break-words text-xs text-theme-text-secondary">{c.type}: {c.status}</div>
431
+ <div className="break-words text-xs text-theme-text-secondary">{c.message || 'No message reported'}</div>
432
+ <div className="text-xs text-theme-text-tertiary">Last transition: {c.lastTransitionTime ? <Tooltip content={c.lastTransitionTime}><time dateTime={c.lastTransitionTime}>{formatAge(c.lastTransitionTime)} ago</time></Tooltip> : 'Not reported'}</div>
433
+ </FoldSection>
434
+ </div>}
435
+ </div>
436
+ }
@@ -0,0 +1,64 @@
1
+ // @vitest-environment jsdom
2
+ import { act } from 'react'
3
+ import { createRoot } from 'react-dom/client'
4
+ import { renderToStaticMarkup } from 'react-dom/server'
5
+ import { expect, it, vi } from 'vitest'
6
+ import { CNPGConnectSection } from './CNPGConnectSection'
7
+
8
+ const cluster = { metadata: { name: 'orders', namespace: 'radar-cnpg-prod' }, spec: { bootstrap: { initdb: { database: 'appdb', owner: 'app' } } } }
9
+ ;(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true
10
+
11
+ it('pairs a namespace-qualified port-forward with matching loopback templates and copies each', async () => {
12
+ const writeText = vi.fn().mockResolvedValue(undefined)
13
+ Object.defineProperty(navigator, 'clipboard', { value: { writeText }, configurable: true })
14
+ const host = document.createElement('div'); const root = createRoot(host)
15
+ act(() => root.render(<CNPGConnectSection cluster={cluster} />))
16
+ expect(host.textContent).toContain('Inside Kubernetes')
17
+ expect(host.textContent).toContain('From this computer')
18
+ for (const [label, expected] of [
19
+ ['port-forward command', 'kubectl -n radar-cnpg-prod port-forward service/orders-rw 5432:5432'],
20
+ ['local psql command', 'psql -h 127.0.0.1 -p 5432 -U app -d appdb'],
21
+ ['local connection string', 'postgresql://app:<password>@127.0.0.1:5432/appdb'],
22
+ ]) {
23
+ await act(async () => host.querySelector<HTMLButtonElement>(`[aria-label="Copy ${label}"]`)!.click())
24
+ expect(writeText).toHaveBeenLastCalledWith(expected)
25
+ }
26
+ act(() => root.unmount())
27
+ })
28
+
29
+ it('uses one three-column grid with every explanation under its own host', () => {
30
+ const html = renderToStaticMarkup(<CNPGConnectSection cluster={cluster} poolers={[{ metadata: { name: 'orders-pooler', namespace: 'radar-cnpg-prod' }, spec: { cluster: { name: 'orders' } } }]} onOpenReachability={() => {}} />)
31
+ const host = document.createElement('div'); host.innerHTML = html
32
+ const list = host.querySelector('ul')!
33
+ expect(list.className).toContain('grid-cols-[5rem_minmax(0,1fr)_auto]')
34
+ expect(list.querySelectorAll('li')).toHaveLength(4)
35
+ for (const row of list.querySelectorAll('li')) {
36
+ expect(row.className).toBe('contents')
37
+ expect(row.children).toHaveLength(3)
38
+ expect(row.children[1].className).toContain('[overflow-wrap:anywhere]')
39
+ expect(row.children[1].querySelector('div')).not.toBeNull()
40
+ expect(row.children[2].textContent).toContain('Reachability')
41
+ }
42
+ })
43
+
44
+ it('shows command context certainty and per-service availability beside each endpoint', () => {
45
+ const host = document.createElement('div')
46
+ host.innerHTML = renderToStaticMarkup(<CNPGConnectSection cluster={cluster} kubeconfigContext="kind-orders" kubeconfigSource="orders-config" ha={{ rwEndpoints: { state: 'ok', pods: ['orders-1'] }, pods: { state: 'ok' }, instances: [{ pod: 'orders-1', role: 'primary', ready: true }] } as any} />)
47
+ expect(host.textContent).toContain('kubectl --context kind-orders')
48
+ expect(host.textContent).not.toContain('uses your current kubectl context')
49
+ expect(host.textContent).toContain('Context as named in your kubeconfig (orders-config); kubectl must read the same kubeconfig file.')
50
+ expect([...host.querySelectorAll('li')].map((li) => li.textContent)).toEqual([expect.stringContaining('Ready endpoints'), expect.stringContaining('Unavailable: no ready standby'), expect.stringContaining('Ready instance observed')])
51
+ host.innerHTML = renderToStaticMarkup(<CNPGConnectSection cluster={cluster} />)
52
+ expect(host.textContent).toContain('uses your current kubectl context')
53
+ expect(host.textContent).not.toContain('--context')
54
+ expect([...host.querySelectorAll('li')].every((li) => li.textContent?.includes('Not checked'))).toBe(true)
55
+ })
56
+
57
+ it('keeps the unavailable reason beside each unchecked Service', () => {
58
+ const host = document.createElement('div')
59
+ host.innerHTML = renderToStaticMarkup(<CNPGConnectSection cluster={cluster} haUnavailableReason="Availability could not be read: timeout" />)
60
+ for (const li of host.querySelectorAll('li')) expect(li.textContent).toContain('Not checked · Availability could not be read: timeout')
61
+ host.innerHTML = renderToStaticMarkup(<CNPGConnectSection cluster={cluster} ha={{ rwEndpoints: { state: 'denied', grant: { verb: 'list', resource: 'endpointslices', group: 'discovery.k8s.io', namespace: 'radar-cnpg-prod' } }, pods: { state: 'denied', grant: { verb: 'list', resource: 'pods', namespace: 'radar-cnpg-prod' } }, instances: [] } as any} />)
62
+ expect(host.querySelector('li')!.textContent).toContain('needs list endpointslices (discovery.k8s.io) in namespace radar-cnpg-prod')
63
+ expect(host.querySelectorAll('li')[1].textContent).toContain('needs list pods in namespace radar-cnpg-prod')
64
+ })