@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
@@ -0,0 +1,63 @@
1
+ import { describe, expect, expectTypeOf, it } from 'vitest'
2
+ import { renderToStaticMarkup } from 'react-dom/server'
3
+ import { ProblemMeta, WorkspaceProblemCallout, ProblemList, problemOriginLabel, type WorkspaceProblem } from './problems'
4
+
5
+ const details = (kind: string) => ({
6
+ id: 'p',
7
+ tone: 'degraded' as const,
8
+ category: 'availability',
9
+ title: 'Something needs a look',
10
+ subject: { kind, group: 'example.io', namespace: 'ns', name: 'child-1' },
11
+ })
12
+ const problem = (kind: string): Extract<WorkspaceProblem, { source: 'issue' }> => ({ ...details(kind), source: 'issue' })
13
+
14
+ describe('ProblemMeta', () => {
15
+ it('names the subject only when it is not the workspace root kind', () => {
16
+ expect(renderToStaticMarkup(<ProblemMeta problem={problem('Backup')} rootKind="Cluster" />)).toContain('Backup')
17
+ expect(renderToStaticMarkup(<ProblemMeta problem={problem('Cluster')} rootKind="Cluster" />)).not.toContain('child-1')
18
+ })
19
+ })
20
+
21
+ describe('problemOriginLabel', () => {
22
+ it('says who measured a measurement, and never calls an issue generic', () => {
23
+ expect(problemOriginLabel({ ...details('Cluster'), source: 'measurement', measuredBy: 'Prometheus' }).label).toBe('Measured by Prometheus')
24
+ expect(problemOriginLabel(problem('Cluster')).label).toBe('Detected by Radar')
25
+ })
26
+ })
27
+
28
+ it('keeps original scheduler evidence behind a closed disclosure in callouts and lists', () => {
29
+ const p = { ...problem('Pod'), detail: 'both nodes have reached their Pod limit', rawDetailLabel: 'Scheduler message', rawDetail: '0/2 nodes are available: 2 Too many pods.' }
30
+ for (const node of [<WorkspaceProblemCallout problem={p} rootKind="Cluster" />, <ProblemList problems={[p]} rootKind="Cluster" />]) {
31
+ const html = renderToStaticMarkup(node)
32
+ expect(html).toContain(p.detail)
33
+ expect(html).toContain(p.rawDetail)
34
+ expect(html).toContain('Scheduler message')
35
+ expect(html).toContain('aria-expanded="false"')
36
+ }
37
+ expect(renderToStaticMarkup(<WorkspaceProblemCallout problem={problem('Pod')} rootKind="Cluster" />)).not.toContain('Scheduler message')
38
+ })
39
+
40
+ it('uses a generic label for raw evidence from another controller', () => {
41
+ const p = { ...problem('Deployment'), rawDetail: 'Progress deadline exceeded.' }
42
+ expect(renderToStaticMarkup(<WorkspaceProblemCallout problem={p} rootKind="Application" />)).toContain('Original evidence')
43
+ })
44
+
45
+ it('keeps source-specific provenance fields exclusive in the public type', () => {
46
+ type Details = ReturnType<typeof details>
47
+ expectTypeOf<Details & { source: 'issue'; measuredBy: string }>().not.toMatchTypeOf<WorkspaceProblem>()
48
+ expectTypeOf<Details & { source: 'measurement'; origin: { label: string } }>().not.toMatchTypeOf<WorkspaceProblem>()
49
+ expectTypeOf<Details & { source: 'audit'; origin: { label: string } }>().not.toMatchTypeOf<WorkspaceProblem>()
50
+ })
51
+
52
+ it('preserves alert and unknown tones in callouts and compact lists', () => {
53
+ for (const tone of ['alert', 'unknown'] as const) {
54
+ const p = { ...problem('Pod'), tone }
55
+ const callout = renderToStaticMarkup(<WorkspaceProblemCallout problem={p} rootKind="Cluster" />)
56
+ expect(callout).toContain(`status-${tone}`)
57
+ const list = renderToStaticMarkup(<ProblemList problems={[p]} rootKind="Cluster" />)
58
+ expect(list).toContain(tone === 'alert' ? 'bg-orange-500' : 'bg-slate-400')
59
+ expect(list).toContain(tone === 'alert' ? 'text-orange-600' : 'text-theme-text-tertiary')
60
+ }
61
+ type HealthyProblem = Omit<ReturnType<typeof details>, 'tone'> & { source: 'issue'; tone: 'healthy' }
62
+ expectTypeOf<HealthyProblem>().not.toMatchTypeOf<WorkspaceProblem>()
63
+ })
@@ -0,0 +1,165 @@
1
+ import { createContext, useContext, type ReactNode } from 'react'
2
+ import { clsx } from 'clsx'
3
+ import type { HealthLevel } from '../resources/resource-utils'
4
+ import type { ResourceRef } from '../../types/core'
5
+ import { healthToSeverity } from '../../utils/badge-colors'
6
+ import { StatusDot, toneTextClass } from '../ui/status-tone'
7
+ import { Tooltip } from '../ui/Tooltip'
8
+ import { AlertBanner } from '../ui/drawer-components'
9
+ import { FoldSection } from '../ui/FoldSection'
10
+ import { RefLink, type NavigateToRef } from '../ui/RefLink'
11
+
12
+ /** Where a problem's evidence comes from, in user terms. */
13
+ export interface ProblemOrigin {
14
+ label: string
15
+ /** The exact field or condition, shown on hover. */
16
+ detail?: string
17
+ }
18
+
19
+ /**
20
+ * Something about a workspace object that needs a look. `C` is the
21
+ * integration's own category set.
22
+ */
23
+ export interface ProblemDetails<C extends string = string> {
24
+ /** Stable identity for keys. */
25
+ id: string
26
+ category: C
27
+ title: string
28
+ detail?: string
29
+ rawDetail?: string
30
+ rawDetailLabel?: string
31
+ /** The object the evidence is about: the workspace's root object or one of its children. */
32
+ subject: ResourceRef & { group: string }
33
+ /** A shorter headline for tight places (a fleet cell); `title` stays the precise one. */
34
+ shortTitle?: string
35
+ /** Other objects the same problem is about, e.g. earlier runs that failed the same way. */
36
+ alsoAbout?: Pick<ResourceRef, 'kind' | 'name'>[]
37
+ }
38
+
39
+ export type ProblemProvenance =
40
+ | { source: 'issue'; origin?: ProblemOrigin; measuredBy?: never; sourceDetail?: never }
41
+ | { source: 'audit'; sourceDetail?: string; origin?: never; measuredBy?: never }
42
+ | { source: 'measurement'; measuredBy?: string; sourceDetail?: string; origin?: never }
43
+
44
+ export type WorkspaceProblem<C extends string = string> = ProblemDetails<C> & ProblemProvenance & { tone: Exclude<HealthLevel, 'healthy'> }
45
+
46
+ /** A problem's provenance label: where its evidence comes from, never a generic "Radar issue". */
47
+ export function problemOriginLabel(problem: WorkspaceProblem): ProblemOrigin {
48
+ switch (problem.source) {
49
+ case 'audit':
50
+ return { label: 'Best-practice check', detail: problem.sourceDetail }
51
+ case 'measurement':
52
+ return { label: problem.measuredBy ? `Measured by ${problem.measuredBy}` : 'Measured', detail: problem.sourceDetail }
53
+ }
54
+ return problem.origin ?? { label: 'Detected by Radar' }
55
+ }
56
+
57
+ /**
58
+ * How a host opens a problem on its Issues page. Supplied by context so every
59
+ * summary and drawer gets the link without threading a prop through each.
60
+ */
61
+ export const OpenIssueContext = createContext<((problem: WorkspaceProblem) => void) | undefined>(undefined)
62
+
63
+ export function ProblemMeta({
64
+ problem,
65
+ rootKind,
66
+ onNavigate,
67
+ subjectIsSelf,
68
+ children,
69
+ }: {
70
+ problem: WorkspaceProblem
71
+ /** The workspace's root kind: a problem about another kind names its subject. */
72
+ rootKind: string
73
+ onNavigate?: NavigateToRef
74
+ subjectIsSelf?: boolean
75
+ children?: ReactNode
76
+ }) {
77
+ const openIssue = useContext(OpenIssueContext)
78
+ const origin = problemOriginLabel(problem)
79
+ const aboutChild = !subjectIsSelf && problem.subject.kind !== rootKind
80
+ return (
81
+ <div className="mt-1.5 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-theme-text-tertiary">
82
+ {aboutChild && (
83
+ <span>
84
+ {problem.subject.kind}{' '}
85
+ <RefLink refTo={problem.subject} onNavigate={onNavigate} mono />
86
+ {problem.alsoAbout && problem.alsoAbout.length > 0 && ' '}
87
+ {problem.alsoAbout && problem.alsoAbout.length > 0 && (
88
+ <Tooltip
89
+ content={
90
+ <ul>
91
+ {problem.alsoAbout.map((o) => (
92
+ <li key={`${o.kind}/${o.name}`} className="font-mono">
93
+ {o.kind} {o.name}
94
+ </li>
95
+ ))}
96
+ </ul>
97
+ }
98
+ >
99
+ <span>and {problem.alsoAbout.length} more</span>
100
+ </Tooltip>
101
+ )}
102
+ </span>
103
+ )}
104
+ <Tooltip content={origin.detail} disabled={!origin.detail}>
105
+ <span>{origin.label}</span>
106
+ </Tooltip>
107
+ {openIssue && problem.source === 'issue' && (
108
+ <button type="button" onClick={() => openIssue(problem)} className="whitespace-nowrap text-accent-text hover:underline">
109
+ See in Issues →
110
+ </button>
111
+ )}
112
+ {children}
113
+ </div>
114
+ )
115
+ }
116
+
117
+ export function WorkspaceProblemCallout({
118
+ problem,
119
+ rootKind,
120
+ more,
121
+ onNavigate,
122
+ action,
123
+ subjectIsSelf,
124
+ }: {
125
+ problem: WorkspaceProblem
126
+ rootKind: string
127
+ more?: ReactNode
128
+ onNavigate?: NavigateToRef
129
+ action?: ReactNode
130
+ /** The callout sits on the subject's own page, so linking to it would loop. */
131
+ subjectIsSelf?: boolean
132
+ }) {
133
+ const severity = healthToSeverity(problem.tone)
134
+ const variant = severity === 'neutral' ? 'unknown' : severity
135
+ return (
136
+ <AlertBanner variant={variant} title={problem.title} message={problem.detail}>
137
+ {problem.rawDetail && <FoldSection title={problem.rawDetailLabel ?? "Original evidence"} summary="" attention={false}><div className="break-words text-xs text-theme-text-secondary">{problem.rawDetail}</div></FoldSection>}
138
+ <ProblemMeta problem={problem} rootKind={rootKind} onNavigate={onNavigate} subjectIsSelf={subjectIsSelf}>
139
+ {action}
140
+ {more}
141
+ </ProblemMeta>
142
+ </AlertBanner>
143
+ )
144
+ }
145
+
146
+ /** The problems a callout does not show, as a compact list with the callout's tone, title and source. */
147
+ export function ProblemList({ problems, rootKind, onNavigate }: { problems: WorkspaceProblem[]; rootKind: string; onNavigate?: NavigateToRef }) {
148
+ return (
149
+ <ul className="space-y-2">
150
+ {problems.map((p) => (
151
+ <li key={p.id} className="flex items-start gap-2 text-sm">
152
+ <span className="mt-1.5 shrink-0">
153
+ <StatusDot tone={p.tone} size="sm" />
154
+ </span>
155
+ <div className="min-w-0">
156
+ <div className={clsx('font-medium break-words', toneTextClass(p.tone))}>{p.title}</div>
157
+ {p.detail && <div className="text-xs text-theme-text-secondary break-words">{p.detail}</div>}
158
+ {p.rawDetail && <FoldSection title={p.rawDetailLabel ?? "Original evidence"} summary="" attention={false}><div className="break-words text-xs text-theme-text-secondary">{p.rawDetail}</div></FoldSection>}
159
+ <ProblemMeta problem={p} rootKind={rootKind} onNavigate={onNavigate} />
160
+ </div>
161
+ </li>
162
+ ))}
163
+ </ul>
164
+ )
165
+ }
@@ -0,0 +1,78 @@
1
+ // @vitest-environment jsdom
2
+ import { act } from 'react'
3
+ import { createRoot, type Root } from 'react-dom/client'
4
+ import { afterEach, describe, expect, it, vi } from 'vitest'
5
+ import { ActiveFilterBar, type ActiveFilterBarProps } from './ActiveFilterBar'
6
+
7
+ vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true)
8
+
9
+ let root: Root | null = null
10
+ let element: HTMLDivElement
11
+
12
+ async function render(overrides: Partial<ActiveFilterBarProps> = {}) {
13
+ const props: ActiveFilterBarProps = {
14
+ columnFilters: {},
15
+ columnFilterExcludes: {},
16
+ problemFilters: [],
17
+ labelSelector: '',
18
+ onClearColumn: vi.fn(),
19
+ onClearProblems: vi.fn(),
20
+ onClearLabels: vi.fn(),
21
+ ...overrides,
22
+ }
23
+ element = document.createElement('div')
24
+ root = createRoot(element)
25
+ await act(async () => root!.render(<ActiveFilterBar {...props} />))
26
+ return props
27
+ }
28
+
29
+ function buttonWithText(text: string): HTMLButtonElement {
30
+ const button = [...element.querySelectorAll('button')].find((b) => b.textContent?.includes(text))
31
+ if (!button) throw new Error(`no button containing "${text}"`)
32
+ return button
33
+ }
34
+
35
+ afterEach(async () => {
36
+ await act(async () => root?.unmount())
37
+ root = null
38
+ })
39
+
40
+ describe('ActiveFilterBar', () => {
41
+ it('renders nothing without active filters or a restore', async () => {
42
+ await render({ columnFilters: { status: [] } })
43
+ expect(element.innerHTML).toBe('')
44
+ })
45
+
46
+ it('renders one chip per active filter, marking excluded columns', async () => {
47
+ await render({
48
+ columnFilters: { status: ['Running', 'Pending'], node: ['n1'], empty: [] },
49
+ columnFilterExcludes: { node: true },
50
+ problemFilters: ['crashloop'],
51
+ labelSelector: 'app=nginx',
52
+ })
53
+ expect(element.querySelectorAll('button')).toHaveLength(4)
54
+ expect(element.textContent).toContain('status: Running, Pending')
55
+ expect(element.textContent).toContain('node: not n1')
56
+ expect(element.textContent).toContain('Problems: crashloop')
57
+ expect(element.textContent).toContain('app=nginx')
58
+ expect(element.textContent).not.toContain('restored')
59
+ })
60
+
61
+ it('calls the matching clear handler when a chip is clicked', async () => {
62
+ const props = await render({ columnFilters: { status: ['Running'] }, problemFilters: ['oom'], labelSelector: 'tier=web' })
63
+ await act(async () => buttonWithText('status:').click())
64
+ await act(async () => buttonWithText('Problems:').click())
65
+ await act(async () => buttonWithText('tier=web').click())
66
+ expect(props.onClearColumn).toHaveBeenCalledWith('status')
67
+ expect(props.onClearProblems).toHaveBeenCalledOnce()
68
+ expect(props.onClearLabels).toHaveBeenCalledOnce()
69
+ })
70
+
71
+ it('shows the restored line only when restored, with Clear calling onClearAll', async () => {
72
+ const onClearAll = vi.fn()
73
+ await render({ restored: true, onClearAll, columnFilters: { status: ['Running'] } })
74
+ expect(element.textContent).toContain('Filters restored from your last visit')
75
+ await act(async () => buttonWithText('Clear').click())
76
+ expect(onClearAll).toHaveBeenCalledOnce()
77
+ })
78
+ })
@@ -0,0 +1,86 @@
1
+ import { clsx } from 'clsx'
2
+ import { AlertTriangle, History, ListFilter, Tag, X } from 'lucide-react'
3
+
4
+ export interface ActiveFilterBarProps {
5
+ columnFilters: Record<string, string[]>
6
+ columnFilterExcludes: Record<string, boolean>
7
+ problemFilters: string[]
8
+ labelSelector: string
9
+ onClearColumn: (key: string) => void
10
+ onClearProblems: () => void
11
+ onClearLabels: () => void
12
+ restored?: boolean
13
+ onClearAll?: () => void
14
+ className?: string
15
+ }
16
+
17
+ export function ActiveFilterBar({
18
+ columnFilters,
19
+ columnFilterExcludes,
20
+ problemFilters,
21
+ labelSelector,
22
+ onClearColumn,
23
+ onClearProblems,
24
+ onClearLabels,
25
+ restored = false,
26
+ onClearAll,
27
+ className,
28
+ }: ActiveFilterBarProps) {
29
+ const activeColEntries = Object.entries(columnFilters).filter(([, vals]) => vals.length > 0)
30
+ const hasChips = activeColEntries.length > 0 || problemFilters.length > 0 || !!labelSelector
31
+ if (!hasChips && !restored) return null
32
+
33
+ return (
34
+ <div className={clsx('flex flex-wrap items-center gap-1.5', className)}>
35
+ {restored && (
36
+ <span className="flex items-center gap-1 text-xs text-theme-text-tertiary">
37
+ <History className="w-3 h-3" />
38
+ <span>Filters restored from your last visit.</span>
39
+ {onClearAll && (
40
+ <button
41
+ type="button"
42
+ onClick={onClearAll}
43
+ className="text-theme-text-secondary hover:text-theme-text-primary underline underline-offset-2"
44
+ >
45
+ Clear
46
+ </button>
47
+ )}
48
+ </span>
49
+ )}
50
+ {activeColEntries.map(([key, vals]) => (
51
+ <button
52
+ type="button"
53
+ key={key}
54
+ onClick={() => onClearColumn(key)}
55
+ className="flex items-center gap-1 px-2 py-1 text-xs selection selection-text rounded-md hover:selection-strong transition-colors"
56
+ >
57
+ <ListFilter className="w-3 h-3" />
58
+ <span>{key}: {columnFilterExcludes[key] ? 'not ' : ''}{vals.join(', ')}</span>
59
+ <X className="w-3 h-3" />
60
+ </button>
61
+ ))}
62
+ {problemFilters.length > 0 && (
63
+ <button
64
+ type="button"
65
+ onClick={onClearProblems}
66
+ className="flex items-center gap-1 px-2 py-1 text-xs bg-red-500/15 text-red-700 dark:text-red-300 rounded-md hover:bg-red-500/25 transition-colors"
67
+ >
68
+ <AlertTriangle className="w-3 h-3" />
69
+ <span>Problems: {problemFilters.join(', ')}</span>
70
+ <X className="w-3 h-3" />
71
+ </button>
72
+ )}
73
+ {labelSelector && (
74
+ <button
75
+ type="button"
76
+ onClick={onClearLabels}
77
+ className="flex items-center gap-1 px-2 py-1 text-xs bg-green-500/15 text-green-700 dark:text-green-300 rounded-md hover:bg-green-500/25 transition-colors"
78
+ >
79
+ <Tag className="w-3 h-3" />
80
+ <span>{labelSelector}</span>
81
+ <X className="w-3 h-3" />
82
+ </button>
83
+ )}
84
+ </div>
85
+ )
86
+ }
@@ -0,0 +1,42 @@
1
+ // @vitest-environment jsdom
2
+ import { act } from 'react'
3
+ import { createRoot } from 'react-dom/client'
4
+ import { afterEach, expect, it, vi } from 'vitest'
5
+ import { ResourcesSidebar } from './ResourcesSidebar'
6
+ ;(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true
7
+ window.matchMedia = vi.fn().mockReturnValue({ matches: false, addEventListener: vi.fn(), removeEventListener: vi.fn() })
8
+ let root: ReturnType<typeof createRoot>
9
+ let host: HTMLDivElement
10
+ afterEach(() => { act(() => root?.unmount()); host?.remove() })
11
+ it('navigates Favorites, Views and grouped kinds in their visible order', () => {
12
+ Element.prototype.scrollIntoView = vi.fn()
13
+ const view = vi.fn(), kind = vi.fn()
14
+ host = document.createElement('div'); document.body.append(host); root = createRoot(host)
15
+ const cluster = { group: 'postgresql.cnpg.io', version: 'v1', kind: 'Cluster', name: 'clusters', namespaced: true, isCrd: true, verbs: ['list'] }
16
+ act(() => root.render(<ResourcesSidebar selectedKind={null} onSelectedKindChange={kind} apiResources={[cluster]} resourceCounts={{ 'postgresql.cnpg.io/Cluster': 1 }} pinned={[{ name: 'pods', kind: 'Pod', group: '' }]} categoryWorkspaces={{ CloudNativePG: { destinations: [{ id: 'overview', label: 'Clusters view', onSelect: view }], defaultKindsCollapsed: true } }} />))
17
+ const category = [...host.querySelectorAll('button')].find((b) => b.textContent?.includes('CloudNativePG'))!
18
+ if (category.getAttribute('aria-expanded') !== 'true') act(() => category.click())
19
+ const input = host.querySelector('input')!
20
+ const key = (name: string) => act(() => input.dispatchEvent(new KeyboardEvent('keydown', { key: name, bubbles: true })))
21
+ key('ArrowDown'); key('ArrowDown'); key('Enter')
22
+ expect(view).toHaveBeenCalledTimes(1)
23
+ expect(kind).not.toHaveBeenCalled()
24
+ act(() => [...host.querySelectorAll('button')].find((b) => b.textContent === 'Resource kinds')!.click())
25
+ key('ArrowDown'); key('ArrowDown'); key('ArrowDown'); key('Enter')
26
+ expect(kind).toHaveBeenCalledWith(expect.objectContaining({ name: 'clusters', group: 'postgresql.cnpg.io' }))
27
+ })
28
+ it('opens a matching View with Enter when search finds no kind', () => {
29
+ Element.prototype.scrollIntoView = vi.fn()
30
+ const view = vi.fn()
31
+ host = document.createElement('div'); document.body.append(host); root = createRoot(host)
32
+ act(() => root.render(<ResourcesSidebar selectedKind={null} onSelectedKindChange={() => {}} apiResources={[{ group: 'postgresql.cnpg.io', version: 'v1', kind: 'Cluster', name: 'clusters', namespaced: true, isCrd: true, verbs: ['list'] }]} resourceCounts={{ 'postgresql.cnpg.io/Cluster': 1 }} categoryWorkspaces={{ CloudNativePG: { destinations: [{ id: 'operator', label: 'Operator', onSelect: view }], defaultKindsCollapsed: true } }} />))
33
+ const input = host.querySelector('input')!
34
+ act(() => {
35
+ Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')!.set!.call(input, 'operator')
36
+ input.dispatchEvent(new Event('input', { bubbles: true }))
37
+ })
38
+ expect(host.textContent).toContain('Operator')
39
+ expect(host.textContent).not.toContain('>Cluster<')
40
+ act(() => input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })))
41
+ expect(view).toHaveBeenCalledTimes(1)
42
+ })
@@ -1,7 +1,7 @@
1
1
  import { describe, expect, it } from 'vitest'
2
2
  import { renderToString } from 'react-dom/server'
3
3
  import type { APIResource } from '../../types'
4
- import { rawCRDGroupTitle, resourceMatchesSidebarFilter, ResourcesSidebar } from './ResourcesSidebar'
4
+ import { rawCRDGroupTitle, resourceMatchesSidebarFilter, ResourcesSidebar, sidebarDestinationsMatching } from './ResourcesSidebar'
5
5
 
6
6
  const sqlInstance: APIResource = {
7
7
  group: 'sql.cnrm.cloud.google.com',
@@ -144,7 +144,7 @@ describe('ResourcesSidebar category workspaces', () => {
144
144
  }}
145
145
  />
146
146
  )
147
- expect(html).toContain('Workspace')
147
+ expect(html).toContain('Views')
148
148
  expect(html).toContain('Overview')
149
149
  expect(html).toContain('pg-orders')
150
150
  expect(html).toContain('Counts for namespace payments')
@@ -153,6 +153,21 @@ describe('ResourcesSidebar category workspaces', () => {
153
153
  expect(html).not.toContain('selection-strong selection-text">Pod')
154
154
  })
155
155
 
156
+ it('marks a count taken over partly readable data as a lower bound, and never shows its zero as none', () => {
157
+ const render = (count: number) =>
158
+ renderToString(
159
+ <ResourcesSidebar
160
+ selectedKind={null}
161
+ onSelectedKindChange={() => {}}
162
+ apiResources={[cnpgCluster]}
163
+ resourceCounts={{ 'postgresql.cnpg.io/Cluster': 1 }}
164
+ categoryWorkspaces={{ CloudNativePG: { destinations: [{ id: 'overview', label: 'Overview', count, countLowerBound: true, onSelect: () => {} }] } }}
165
+ />,
166
+ )
167
+ expect(render(2)).toMatch(/≥(<!-- -->)?2/)
168
+ expect(render(0)).toContain('–')
169
+ })
170
+
156
171
  it('keeps a workspace category visible when it has no resources', () => {
157
172
  const html = renderToString(
158
173
  <ResourcesSidebar
@@ -181,3 +196,22 @@ describe('ResourcesSidebar category workspaces', () => {
181
196
  expect(html).toContain('barmancloud.cnpg.io')
182
197
  })
183
198
  })
199
+
200
+ describe('sidebarDestinationsMatching', () => {
201
+ const views = ['Clusters', 'Backups', 'Declarations', 'Pooling', 'Operator'].map((label) => ({ id: label, label, onSelect: () => {} }))
202
+ const resources = [{ group: 'postgresql.cnpg.io' }, { group: 'barmancloud.cnpg.io' }]
203
+ const labels = (term: string) => sidebarDestinationsMatching('CloudNativePG', resources, views, term).map((d) => d.label)
204
+
205
+ it('keeps every view when the term names the category or one of its API groups', () => {
206
+ expect(labels('cloud')).toEqual(['Clusters', 'Backups', 'Declarations', 'Pooling', 'Operator'])
207
+ expect(labels('cnpg')).toEqual(['Clusters', 'Backups', 'Declarations', 'Pooling', 'Operator'])
208
+ })
209
+ it('keeps the views whose label matches otherwise', () => {
210
+ expect(labels('backup')).toEqual(['Backups'])
211
+ expect(labels('declar')).toEqual(['Declarations'])
212
+ expect(labels('deployment')).toEqual([])
213
+ })
214
+ it('keeps every view without a term', () => {
215
+ expect(labels(' ')).toHaveLength(5)
216
+ })
217
+ })