@skyhook-io/k8s-ui 1.18.1 → 1.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (101) hide show
  1. package/README.md +8 -0
  2. package/package.json +1 -1
  3. package/src/components/applications/ApplicationsView.tsx +8 -7
  4. package/src/components/charts/AreaChart.test.tsx +39 -1
  5. package/src/components/charts/AreaChart.tsx +32 -13
  6. package/src/components/charts/axis.test.ts +17 -1
  7. package/src/components/charts/axis.ts +13 -0
  8. package/src/components/charts/format.test.ts +159 -2
  9. package/src/components/charts/format.ts +118 -0
  10. package/src/components/cluster-switcher/ClusterSwitcher.tsx +4 -0
  11. package/src/components/compare/ResourceCompareView.tsx +6 -2
  12. package/src/components/gitops/GitOpsTableView.tsx +13 -8
  13. package/src/components/gitops/insights/ArgoResourceDiff.test.tsx +32 -0
  14. package/src/components/gitops/insights/GitOpsInsightViews.tsx +66 -36
  15. package/src/components/gitops/insights/GitOpsIssuesBand.test.tsx +108 -0
  16. package/src/components/gitops/insights/insights-helpers.test.ts +28 -0
  17. package/src/components/gitops/insights/insights-helpers.ts +5 -3
  18. package/src/components/issues/IssueGuidance.test.tsx +190 -0
  19. package/src/components/issues/IssueGuidance.tsx +48 -0
  20. package/src/components/issues/IssuesView.tsx +17 -5
  21. package/src/components/issues/diagnostic.ts +2 -0
  22. package/src/components/issues/index.ts +1 -0
  23. package/src/components/issues/severity.ts +2 -0
  24. package/src/components/issues/types.ts +10 -0
  25. package/src/components/logs/LogCore.tsx +13 -11
  26. package/src/components/logs/WorkloadLogsViewer.tsx +1 -1
  27. package/src/components/namespace-switcher/NamespacePicker.tsx +2 -0
  28. package/src/components/resources/ResourcesSidebar.keyboard.test.tsx +28 -0
  29. package/src/components/resources/ResourcesSidebar.tsx +26 -5
  30. package/src/components/resources/ResourcesView.crossplane-pause.test.tsx +133 -0
  31. package/src/components/resources/ResourcesView.flagger.test.tsx +31 -0
  32. package/src/components/resources/ResourcesView.kind-view.test.tsx +96 -1
  33. package/src/components/resources/ResourcesView.opentelemetry.test.tsx +29 -0
  34. package/src/components/resources/ResourcesView.tsx +238 -66
  35. package/src/components/resources/curated-column-ownership.test.ts +3 -3
  36. package/src/components/resources/generic-status.ts +15 -0
  37. package/src/components/resources/printer-columns.test.ts +36 -0
  38. package/src/components/resources/printer-columns.ts +12 -1
  39. package/src/components/resources/readiness-gates.test.ts +95 -0
  40. package/src/components/resources/renderers/CompositeRenderer.tsx +8 -12
  41. package/src/components/resources/renderers/CrossplanePauseContext.test.tsx +153 -0
  42. package/src/components/resources/renderers/CrossplanePauseContext.tsx +39 -0
  43. package/src/components/resources/renderers/FlaggerRenderers.tsx +96 -0
  44. package/src/components/resources/renderers/GenericRenderer.tsx +19 -11
  45. package/src/components/resources/renderers/ManagedResourceRenderer.tsx +5 -12
  46. package/src/components/resources/renderers/OpenTelemetryRenderers.tsx +73 -0
  47. package/src/components/resources/renderers/crossplane-cells.tsx +5 -5
  48. package/src/components/resources/renderers/index.ts +3 -0
  49. package/src/components/resources/resource-utils-crossplane.test.ts +45 -0
  50. package/src/components/resources/resource-utils-crossplane.ts +44 -8
  51. package/src/components/resources/resource-utils-flagger.test.tsx +82 -0
  52. package/src/components/resources/resource-utils-flagger.ts +76 -0
  53. package/src/components/resources/resource-utils-opentelemetry.test.tsx +74 -0
  54. package/src/components/resources/resource-utils-opentelemetry.ts +97 -0
  55. package/src/components/resources/resource-utils.ts +80 -2
  56. package/src/components/shared/CreateResourceDialog.test.tsx +14 -0
  57. package/src/components/shared/CreateResourceDialog.tsx +5 -0
  58. package/src/components/shared/DetailShell.phone.test.tsx +54 -0
  59. package/src/components/shared/DetailShell.tsx +14 -3
  60. package/src/components/shared/EditableYamlView.phone.test.tsx +44 -0
  61. package/src/components/shared/EditableYamlView.test.tsx +58 -0
  62. package/src/components/shared/EditableYamlView.tsx +18 -3
  63. package/src/components/shared/FilterRailPanel.test.tsx +39 -0
  64. package/src/components/shared/FilterRailPanel.tsx +62 -0
  65. package/src/components/shared/GitOpsWriteWarning.managers.test.tsx +85 -0
  66. package/src/components/shared/GitOpsWriteWarning.tsx +87 -5
  67. package/src/components/shared/ManagedByChip.manager.test.tsx +115 -0
  68. package/src/components/shared/ManagedByChip.menu.test.tsx +103 -0
  69. package/src/components/shared/ManagedByChip.tsx +135 -2
  70. package/src/components/shared/ResourceActionsBar.tsx +22 -6
  71. package/src/components/shared/ResourceRendererDispatch.tsx +23 -0
  72. package/src/components/shared/SetImageDialog.tsx +3 -0
  73. package/src/components/topology/TopologyControls.tsx +1 -1
  74. package/src/components/ui/CrossplaneOperationNotice.test.tsx +153 -0
  75. package/src/components/ui/CrossplaneOperationNotice.tsx +139 -0
  76. package/src/components/ui/ForceDeleteConfirmDialog.test.tsx +55 -6
  77. package/src/components/ui/ForceDeleteConfirmDialog.tsx +127 -22
  78. package/src/components/ui/PageHeader.tsx +3 -2
  79. package/src/components/ui/SummaryTile.tsx +13 -3
  80. package/src/components/ui/Tooltip.touch.test.tsx +55 -0
  81. package/src/components/ui/Tooltip.tsx +11 -2
  82. package/src/components/ui/YamlReview.test.tsx +52 -0
  83. package/src/components/ui/YamlReview.tsx +7 -0
  84. package/src/components/ui/drawer-components.test.tsx +27 -0
  85. package/src/components/ui/drawer-components.tsx +22 -42
  86. package/src/components/workload/WorkloadView.relationships.test.tsx +40 -0
  87. package/src/components/workload/WorkloadView.tsx +57 -34
  88. package/src/hooks/useKeepInViewport.mounted.test.tsx +72 -0
  89. package/src/hooks/useKeepInViewport.test.ts +27 -0
  90. package/src/hooks/useKeepInViewport.ts +87 -0
  91. package/src/types/core.ts +54 -1
  92. package/src/types/gitops-insights.ts +8 -0
  93. package/src/utils/api-resources.ts +1 -0
  94. package/src/utils/gitops-owner.ts +136 -8
  95. package/src/utils/gitops-write-guard.test.ts +332 -0
  96. package/src/utils/gitops-write-guard.ts +175 -11
  97. package/src/utils/index.ts +1 -0
  98. package/src/utils/read-actions.ts +34 -0
  99. package/src/utils/touch-intent.test.ts +39 -0
  100. package/src/utils/touch-intent.ts +35 -0
  101. package/vitest.config.ts +26 -0
@@ -1,4 +1,4 @@
1
- import { useState, useCallback, useEffect, useRef } from 'react'
1
+ import { useState, useCallback, useEffect, useRef, useMemo } from 'react'
2
2
  import {
3
3
  Copy,
4
4
  CopyPlus,
@@ -12,6 +12,8 @@ import {
12
12
  } from 'lucide-react'
13
13
  import { Download } from 'lucide-react'
14
14
  import { stringify as yamlStringify } from 'yaml'
15
+ import { useDebouncedValue } from '../../hooks/useDebouncedValue'
16
+ import { CrossplaneYamlApplyNotice } from '../ui/CrossplaneOperationNotice'
15
17
  import { CodeViewer } from '../ui/CodeViewer'
16
18
  import { YamlEditor } from '../ui/YamlEditor'
17
19
  import type { YamlSchemaLoader } from '../ui/YamlEditor'
@@ -21,6 +23,7 @@ import type { SelectedResource } from '../../types'
21
23
  import { resourceToYaml } from '../../utils/yaml'
22
24
  import { triggerDownload } from '../../utils/download'
23
25
  import { isCoreBatchJob } from '../../utils/api-resources'
26
+ import { usePhoneLayout } from '../../hooks/usePhoneLayout'
24
27
 
25
28
  // ============================================================================
26
29
  // SUCCESS ANIMATION
@@ -197,6 +200,10 @@ export function EditableYamlView({
197
200
  onDownload,
198
201
  }: EditableYamlViewProps) {
199
202
  const draftKey = `radar_yaml_draft:${resource.kind}/${resource.namespace}/${resource.name}`
203
+ // Phones read YAML but don't start an edit or a duplicate: authoring YAML by
204
+ // touch is error-prone. Not folded into readOnly, which would discard an edit
205
+ // already open when a phone rotates from landscape to portrait.
206
+ const isPhone = usePhoneLayout()
200
207
 
201
208
  // Restore draft from sessionStorage (e.g., after session-expiry redirect).
202
209
  // All sessionStorage calls are wrapped in try-catch — storage can throw
@@ -207,6 +214,8 @@ export function EditableYamlView({
207
214
  const reviewGeneration = useRef(0)
208
215
  const [isEditing, setIsEditing] = useState(savedDraft.current !== null)
209
216
  const [editedYaml, setEditedYaml] = useState(savedDraft.current ?? '')
217
+ const pauseContextYaml = useDebouncedValue(!onPreview && isEditing ? editedYaml : '', 200)
218
+ const pauseBaselineYaml = useMemo(() => !onPreview && isEditing ? resourceToYaml(data) : '', [data, onPreview, isEditing])
210
219
  const [yamlErrors, setYamlErrors] = useState<string[]>([])
211
220
  const [showErrorDetails, setShowErrorDetails] = useState(false)
212
221
  // Default on: the editor resubmits the full live manifest, so an unforced
@@ -216,6 +225,7 @@ export function EditableYamlView({
216
225
  yaml: string
217
226
  force: boolean
218
227
  originalYaml: string
228
+ resourceData: unknown
219
229
  documents: YamlPreviewResult[]
220
230
  nonAtomic: boolean
221
231
  context?: string
@@ -293,6 +303,7 @@ export function EditableYamlView({
293
303
  yaml: editedYaml,
294
304
  force,
295
305
  originalYaml: resourceToYaml(data),
306
+ resourceData: data,
296
307
  documents: result.documents,
297
308
  nonAtomic: result.nonAtomic,
298
309
  context: result.context,
@@ -382,7 +393,9 @@ export function EditableYamlView({
382
393
  return (
383
394
  <YamlReview
384
395
  submittedYaml={preview.yaml}
396
+ mode="update"
385
397
  originalYaml={preview.originalYaml}
398
+ resourceData={preview.resourceData}
386
399
  documents={preview.documents}
387
400
  nonAtomic={preview.nonAtomic}
388
401
  force={preview.force}
@@ -465,6 +478,8 @@ export function EditableYamlView({
465
478
  </div>
466
479
  </div>
467
480
 
481
+ {!onPreview && <div className="px-4 pt-2"><CrossplaneYamlApplyNotice yaml={pauseContextYaml} baselineYaml={pauseBaselineYaml} resourceData={data} /></div>}
482
+
468
483
  {editWarning && (
469
484
  <div className="px-4 py-2.5 bg-amber-500/10 dark:bg-yellow-500/10 border-b border-amber-300 dark:border-yellow-500/30">
470
485
  <div className="flex items-start gap-2">
@@ -549,7 +564,7 @@ export function EditableYamlView({
549
564
  <div className="flex items-center justify-between mb-2">
550
565
  <span className="text-sm font-medium text-theme-text-secondary">YAML</span>
551
566
  <div className="flex items-center gap-2">
552
- {!readOnly && (
567
+ {!readOnly && !isPhone && (
553
568
  <button
554
569
  onClick={handleStartEdit}
555
570
  className="flex items-center gap-1 px-2 py-1 text-xs text-accent-text hover:text-accent-light hover:bg-theme-elevated rounded"
@@ -578,7 +593,7 @@ export function EditableYamlView({
578
593
  Download
579
594
  </button>
580
595
  </Tooltip>
581
- {onDuplicate && (
596
+ {onDuplicate && !isPhone && (
582
597
  <Tooltip content="Duplicate as new resource">
583
598
  <button
584
599
  onClick={() =>
@@ -0,0 +1,39 @@
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 { FilterRailPanel } from './FilterRailPanel'
6
+ Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true })
7
+ afterEach(() => { vi.unstubAllGlobals() })
8
+
9
+ function stubPhone(matches: boolean) {
10
+ vi.stubGlobal('matchMedia', (query: string) => ({ matches, media: query, addEventListener: () => {}, removeEventListener: () => {} }))
11
+ }
12
+
13
+ function render(activeCount: number) {
14
+ const host = document.createElement('div'); const root = createRoot(host)
15
+ act(() => root.render(<FilterRailPanel activeCount={activeCount} clear={<button type="button">Clear</button>}><div>Status facet</div></FilterRailPanel>))
16
+ return host
17
+ }
18
+
19
+ it('keeps the plain header and an open body off a phone', () => {
20
+ stubPhone(false)
21
+ const host = render(2)
22
+ expect(host.querySelector('[aria-expanded]')).toBeNull()
23
+ expect(host.textContent).toContain('Status facet')
24
+ expect(host.textContent).not.toContain('2')
25
+ })
26
+
27
+ it('collapses the facets behind a Filters button with the active count on a phone', () => {
28
+ stubPhone(true)
29
+ const host = render(2)
30
+ const button = host.querySelector<HTMLButtonElement>('button[aria-expanded]')!
31
+ expect(button.textContent).toContain('Filters')
32
+ expect(button.textContent).toContain('2')
33
+ expect(button.getAttribute('aria-expanded')).toBe('false')
34
+ const panel = host.querySelector(`#${button.getAttribute('aria-controls')}`)!
35
+ expect(panel.querySelector('[inert]')).not.toBeNull()
36
+ act(() => button.click())
37
+ expect(button.getAttribute('aria-expanded')).toBe('true')
38
+ expect(panel.querySelector('[inert]')).toBeNull()
39
+ })
@@ -0,0 +1,62 @@
1
+ import { useState, type ReactNode } from 'react'
2
+ import { SlidersHorizontal } from 'lucide-react'
3
+ import { usePhoneLayout } from '../../hooks/usePhoneLayout'
4
+ import { Collapse, CollapseChevron, useDisclosure } from '../ui/Collapse'
5
+
6
+ // The header and scrolling body of a facet rail (Applications, GitOps). Below
7
+ // 900px the rail stacks above its list; on a phone that stack fills the first
8
+ // screen, so there the header becomes a "Filters" button with the active count
9
+ // and the facets open in place under it. Elsewhere it renders the plain
10
+ // "Filters" header over the body, as before.
11
+ export function FilterRailPanel({
12
+ activeCount,
13
+ clear,
14
+ children,
15
+ }: {
16
+ activeCount: number
17
+ /** The Clear control for the header's right side; null hides it. */
18
+ clear: ReactNode
19
+ children: ReactNode
20
+ }) {
21
+ const isPhone = usePhoneLayout()
22
+ const [open, setOpen] = useState(false)
23
+ const { panelId, buttonProps } = useDisclosure(open)
24
+
25
+ if (!isPhone) {
26
+ return (
27
+ <>
28
+ <div className="flex items-center justify-between border-b border-theme-border px-3 py-2">
29
+ <span className="text-sm font-medium text-theme-text-secondary">Filters</span>
30
+ {clear}
31
+ </div>
32
+ <div className="flex-1 overflow-y-auto">{children}</div>
33
+ </>
34
+ )
35
+ }
36
+
37
+ return (
38
+ <>
39
+ <div className="flex items-center justify-between gap-2 px-3 py-1.5">
40
+ <button
41
+ type="button"
42
+ {...buttonProps}
43
+ onClick={() => setOpen((v) => !v)}
44
+ className="-ml-1 flex min-h-9 items-center gap-2 rounded-md px-1 text-sm font-medium text-theme-text-primary"
45
+ >
46
+ <SlidersHorizontal className="h-4 w-4 text-theme-text-secondary" aria-hidden />
47
+ Filters
48
+ {activeCount > 0 && (
49
+ <span className="inline-flex h-4 min-w-4 items-center justify-center rounded-full bg-skyhook-500 px-1 text-[10px] font-semibold leading-none text-white">
50
+ {activeCount}
51
+ </span>
52
+ )}
53
+ <CollapseChevron open={open} />
54
+ </button>
55
+ {clear}
56
+ </div>
57
+ <Collapse open={open} id={panelId}>
58
+ <div className="max-h-56 overflow-y-auto border-t border-theme-border">{children}</div>
59
+ </Collapse>
60
+ </>
61
+ )
62
+ }
@@ -0,0 +1,85 @@
1
+ // @vitest-environment jsdom
2
+ import { act } from 'react'
3
+ import { createRoot } from 'react-dom/client'
4
+ import { expect, it, vi } from 'vitest'
5
+
6
+ import type { ManagerAnswer } from '../../types/core'
7
+ import { evaluateGitOpsWriteGuard, type GitOpsWriteGuard } from '../../utils/gitops-write-guard'
8
+ import type { GitOpsOwnerRef } from '../../utils/gitops-owner'
9
+ import { GitOpsWriteWarning } from './GitOpsWriteWarning'
10
+
11
+ vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true)
12
+
13
+ const a = { kind: 'Application', group: 'argoproj.io', namespace: 'argocd', name: 'a' }
14
+ const b = { ...a, name: 'b' }
15
+ const object = { kind: 'Deployment', group: 'apps', namespace: 'shop', name: 'web' }
16
+
17
+ async function linksOf(manager: ManagerAnswer, onOpenManager?: (owner: GitOpsOwnerRef) => void, override?: (guard: GitOpsWriteGuard) => GitOpsWriteGuard) {
18
+ const evaluated = evaluateGitOpsWriteGuard({ target: object, owner: null, manager, writes: [{ scope: 'spec', paths: ['spec.replicas'] }] })
19
+ const guard = override ? override(evaluated) : evaluated
20
+ const element = document.createElement('div')
21
+ document.body.append(element)
22
+ const root = createRoot(element)
23
+ await act(async () => root.render(<GitOpsWriteWarning guard={guard} acknowledged={false} onAcknowledgedChange={() => {}} onOpenManager={onOpenManager} />))
24
+ const links = [...element.querySelectorAll<HTMLButtonElement>('button')]
25
+ return { links, text: element.textContent ?? '', done: async () => { await act(async () => root.unmount()); element.remove() } }
26
+ }
27
+
28
+ // The most ambiguous answer is the one a reader most needs to inspect: each
29
+ // manager opens from where the reason names it, with no second mention.
30
+ it.each<[string, ManagerAnswer]>([
31
+ ['a disagreement', { state: 'disagree', direct: { object, recorders: [b], claims: [{ manager: a, source: 'argo-tracking-id', check: 'omitted' }] } }],
32
+ ['a shared list with a lead', { state: 'shared', lead: a, direct: { object, recorders: [a, b], claims: [{ manager: a, source: 'argo-tracking-id', check: 'recorded' }] } }],
33
+ ])('%s links every manager it names', async (_, manager) => {
34
+ const opened: string[] = []
35
+ const { links, text, done } = await linksOf(manager, owner => opened.push(`${owner.kind}/${owner.namespace}/${owner.name}`))
36
+ expect(links.map(l => l.textContent)).toEqual(['Application argocd/a', 'Application argocd/b'])
37
+ expect(text).not.toContain('View')
38
+ expect(text.match(/argocd\/b/g)).toHaveLength(1)
39
+ await act(async () => { links[1].click() })
40
+ expect(opened).toEqual(['applications/argocd/b'])
41
+ await done()
42
+ })
43
+
44
+ it("doesn't link a manager Radar didn't find", async () => {
45
+ const manager: ManagerAnswer = { state: 'disagree', direct: { object, recorders: [b], claims: [{ manager: { ...a, namespace: '' }, source: 'argo-instance-label', check: 'unresolved' }] } }
46
+ const { links, done } = await linksOf(manager, () => {})
47
+ expect(links.map(l => l.textContent)).toEqual(['Application argocd/b'])
48
+ await done()
49
+ })
50
+
51
+ it('a host without manager navigation names them without links', async () => {
52
+ const manager: ManagerAnswer = { state: 'disagree', direct: { object, recorders: [b], claims: [{ manager: a, source: 'argo-tracking-id', check: 'omitted' }] } }
53
+ const { links, done } = await linksOf(manager)
54
+ expect(links).toHaveLength(0)
55
+ await done()
56
+ })
57
+
58
+ // Two Applications of one name in different namespaces both list the object,
59
+ // and its label names that name without a namespace: the label's claim is
60
+ // recorded, yet says neither which one, so only the two qualified ones link.
61
+ it('a label naming two same-named Applications links only the qualified ones', async () => {
62
+ const teamA = { ...a, namespace: 'team-a', name: 'web' }
63
+ const teamB = { ...a, namespace: 'team-b', name: 'web' }
64
+ const manager: ManagerAnswer = { state: 'shared', direct: { object, recorders: [teamA, teamB], claims: [{ manager: { ...a, namespace: '', name: 'web' }, source: 'argo-instance-label', check: 'recorded' }] } }
65
+ const { links, done } = await linksOf(manager, () => {})
66
+ expect(links.map(l => l.textContent)).toEqual(['Application team-a/web', 'Application team-b/web'])
67
+ await done()
68
+ })
69
+
70
+ // A name that begins another (argocd/a in argocd/ab) links only where it is
71
+ // the whole name.
72
+ it('links a name only where it is the whole name', async () => {
73
+ const ab = { ...a, name: 'ab' }
74
+ const manager: ManagerAnswer = { state: 'shared', direct: { object, recorders: [ab, a] } }
75
+ const { links, done } = await linksOf(manager, () => {}, guard => ({ ...guard, perWrite: guard.perWrite.map(w => ({ ...w, reason: 'Application argocd/ab and Application argocd/a may overwrite these fields.' })) }))
76
+ expect(links.map(l => l.textContent)).toEqual(['Application argocd/ab', 'Application argocd/a'])
77
+ await done()
78
+ })
79
+
80
+ it('a manager the reason never names keeps a link of its own', async () => {
81
+ const manager: ManagerAnswer = { state: 'shared', direct: { object, recorders: [a, b] } }
82
+ const { links, done } = await linksOf(manager, () => {}, guard => ({ ...guard, perWrite: guard.perWrite.map(w => ({ ...w, reason: 'Application argocd/a may overwrite these fields.' })) }))
83
+ expect(links.map(l => l.textContent)).toEqual(['Application argocd/a', 'View Application argocd/b'])
84
+ await done()
85
+ })
@@ -1,3 +1,4 @@
1
+ import { Fragment, type ReactNode } from 'react'
1
2
  import { ExternalLink, GitBranch, Info, Loader2 } from 'lucide-react'
2
3
  import { clsx } from 'clsx'
3
4
 
@@ -9,6 +10,8 @@ import {
9
10
  type GitOpsWriteAssessment,
10
11
  type GitOpsWriteGuard,
11
12
  } from '../../utils/gitops-write-guard'
13
+ import { gitOpsOwnerFromRef, managerChoices, managerRefLabel, type GitOpsOwnerRef } from '../../utils/gitops-owner'
14
+ import { escapeRegExp } from '../../utils/log-format'
12
15
 
13
16
  export { canConfirmGitOpsWrite }
14
17
 
@@ -18,6 +21,9 @@ export interface GitOpsWriteWarningProps {
18
21
  onAcknowledgedChange: (acknowledged: boolean) => void
19
22
  /** Opens the GitOps owner (or Helm release) the guard names. */
20
23
  onOpenOwner?: () => void
24
+ /** Opens one of several managers the guard names, so a reader can inspect
25
+ * each before deciding. Without it, they are named but not linked. */
26
+ onOpenManager?: (owner: GitOpsOwnerRef) => void
21
27
  disabled?: boolean
22
28
  className?: string
23
29
  }
@@ -28,6 +34,58 @@ const OWNER_LINK_LABEL = {
28
34
  helmreleases: 'View Flux HelmRelease',
29
35
  } as const
30
36
 
37
+ // managerLinks: the managers a guard naming more than one can open, the lead
38
+ // among them, since a lead isn't a winner. One Radar didn't find (missing,
39
+ // withheld) or a label naming no namespace isn't linked: the label may name
40
+ // either of two Applications of that name.
41
+ function managerLinks(guard: GitOpsWriteGuard, onOpenManager: GitOpsWriteWarningProps['onOpenManager']): { label: string; owner: GitOpsOwnerRef }[] {
42
+ if (!onOpenManager || !guard.manager) return []
43
+ const choices = managerChoices(guard.manager)
44
+ if (choices.length < 2) return []
45
+ return choices.flatMap((c) => {
46
+ const owner = c.found && c.ref.namespace ? gitOpsOwnerFromRef(c.ref) : null
47
+ return owner ? [{ label: managerRefLabel(c.ref), owner }] : []
48
+ })
49
+ }
50
+
51
+ type ManagerLink = ReturnType<typeof managerLinks>[number]
52
+
53
+ // linkManagers turns the first mention of each linked manager, across all the
54
+ // lines, into its link, so the reason the reader is already reading is where
55
+ // they open it. A name followed by more of a name (argocd/a in argocd/ab) isn't
56
+ // a mention.
57
+ function linkManagers(lines: string[], links: ManagerLink[], onOpen: (owner: GitOpsOwnerRef) => void): { lines: ReactNode[]; unmentioned: ManagerLink[] } {
58
+ if (!links.length) return { lines, unmentioned: [] }
59
+ const byLabel = new Map(links.map((l) => [l.label, l]))
60
+ const pattern = new RegExp(`(${[...byLabel.keys()].map(escapeRegExp).join('|')})(?![\\w./-])`, 'g')
61
+ const linked = new Set<string>()
62
+ const out = lines.map((line, i) => {
63
+ const parts: ReactNode[] = []
64
+ let last = 0
65
+ for (const match of line.matchAll(pattern)) {
66
+ const link = byLabel.get(match[0])!
67
+ if (linked.has(link.label)) continue
68
+ linked.add(link.label)
69
+ parts.push(line.slice(last, match.index))
70
+ parts.push(
71
+ <button
72
+ key={`${i}:${link.label}`}
73
+ type="button"
74
+ onClick={() => onOpen(link.owner)}
75
+ className="font-medium text-accent underline decoration-dotted underline-offset-2 hover:decoration-solid"
76
+ >
77
+ {link.label}
78
+ </button>,
79
+ )
80
+ last = match.index! + match[0].length
81
+ }
82
+ if (!parts.length) return line
83
+ parts.push(line.slice(last))
84
+ return <Fragment key={i}>{parts}</Fragment>
85
+ })
86
+ return { lines: out, unmentioned: links.filter((l) => !linked.has(l.label)) }
87
+ }
88
+
31
89
  function reasonLines(perWrite: GitOpsWriteAssessment[], levels: GitOpsWriteGuard['level'][]): string[] {
32
90
  const relevant = perWrite.filter((entry) => levels.includes(entry.level) && entry.reason)
33
91
  const distinct = new Set(relevant.map((entry) => entry.reason))
@@ -42,6 +100,7 @@ export function GitOpsWriteWarning({
42
100
  acknowledged,
43
101
  onAcknowledgedChange,
44
102
  onOpenOwner,
103
+ onOpenManager,
45
104
  disabled = false,
46
105
  // Not undefined: AlertBanner would add its own mb-4, and a margin utility
47
106
  // overrides the parent's space-y gap.
@@ -63,11 +122,35 @@ export function GitOpsWriteWarning({
63
122
  if (guard.level === 'none') return null
64
123
 
65
124
  const headline = gitOpsWriteHeadline(guard)
66
- const ownerLink = onOpenOwner ? (
125
+ const links = managerLinks(guard, onOpenManager)
126
+ const { lines, unmentioned } = linkManagers(
127
+ reasonLines(guard.perWrite, guard.level === 'info' ? ['info'] : ['will-revert', 'may-revert']),
128
+ links,
129
+ (owner) => onOpenManager?.(owner),
130
+ )
131
+ // Several managers link where the reason names them; one it doesn't name
132
+ // still gets a link of its own.
133
+ const ownerLink = links.length ? (
134
+ unmentioned.length ? (
135
+ <div className="flex flex-wrap gap-x-3 gap-y-1">
136
+ {unmentioned.map(({ label, owner }) => (
137
+ <button
138
+ key={label}
139
+ type="button"
140
+ onClick={() => onOpenManager!(owner)}
141
+ className="inline-flex items-center gap-1 text-xs font-medium text-accent hover:underline"
142
+ >
143
+ View {label}
144
+ <ExternalLink className="h-3 w-3" />
145
+ </button>
146
+ ))}
147
+ </div>
148
+ ) : null
149
+ ) : onOpenOwner ? (
67
150
  <button
68
151
  type="button"
69
152
  onClick={onOpenOwner}
70
- className="inline-flex items-center gap-1 text-xs font-medium text-skyhook-600 hover:underline dark:text-skyhook-400"
153
+ className="inline-flex items-center gap-1 text-xs font-medium text-accent hover:underline"
71
154
  >
72
155
  {guard.owner ? OWNER_LINK_LABEL[guard.owner.kind] : 'View Helm release'}
73
156
  <ExternalLink className="h-3 w-3" />
@@ -75,7 +158,6 @@ export function GitOpsWriteWarning({
75
158
  ) : null
76
159
 
77
160
  if (guard.level === 'info') {
78
- const lines = reasonLines(guard.perWrite, ['info'])
79
161
  return (
80
162
  <div
81
163
  className={clsx(
@@ -86,7 +168,8 @@ export function GitOpsWriteWarning({
86
168
  <Info className="mt-0.5 h-3.5 w-3.5 shrink-0 text-theme-text-tertiary" />
87
169
  <div className="min-w-0 space-y-1">
88
170
  <p>
89
- <span className="font-medium text-theme-text-primary">{headline}</span> {lines.join(' ')}
171
+ <span className="font-medium text-theme-text-primary">{headline}</span>
172
+ {lines.map((line, i) => <Fragment key={i}> {line}</Fragment>)}
90
173
  </p>
91
174
  {ownerLink}
92
175
  </div>
@@ -95,7 +178,6 @@ export function GitOpsWriteWarning({
95
178
  }
96
179
 
97
180
  const tool = guardToolLabel(guard)
98
- const lines = reasonLines(guard.perWrite, ['will-revert', 'may-revert'])
99
181
  const durableFix = guard.owner
100
182
  ? 'For a lasting change, change it in the GitOps source.'
101
183
  : guard.helmRelease
@@ -0,0 +1,115 @@
1
+ import { describe, expect, it, vi } from 'vitest'
2
+ import { renderToStaticMarkup } from 'react-dom/server'
3
+ import type { ManagerAnswer, ManagerClaim } from '../../types/core'
4
+ import { ManagedByChip } from './ManagedByChip'
5
+
6
+ vi.mock('../ui/Tooltip', () => ({ Tooltip: ({ children, content }: any) => <span>{children}<span>{content}</span></span> }))
7
+ const a = { kind: 'Application', group: 'argoproj.io', namespace: 'argocd', name: 'a' }
8
+ const b = { ...a, name: 'b' }
9
+ const object = { kind: 'Pod', namespace: 'demo', name: 'web-1' }
10
+ const claim: ManagerClaim = { manager: a, source: 'argo-instance-label', check: 'omitted' }
11
+ const render = (manager: ManagerAnswer) => renderToStaticMarkup(<ManagedByChip manager={manager} />).replaceAll('&#x27;', "'")
12
+
13
+ describe('manager answer chip', () => {
14
+ // Only a list and the metadata agreeing say a manager manages it; a list
15
+ // alone can name an object its manager doesn't sync.
16
+ it.each([['agree', 'Managed by'], ['listed', 'Listed by']] as const)('%s says %s even before the status fetch', (state, prefix) => {
17
+ const html = render({ state, lead: a, direct: { object, recorders: [a] } })
18
+ expect(html).toContain(prefix)
19
+ expect(html).toContain('Application argocd/a lists this resource in its status.')
20
+ })
21
+ it('agree says the tracking metadata names the listing manager, once', () => {
22
+ const html = render({ state: 'agree', lead: a, direct: { object, recorders: [a], claims: [{ ...claim, manager: { ...a, namespace: '' }, check: 'recorded' }] } })
23
+ expect(html).toContain("Application argocd/a lists this resource in its status, and this resource's tracking metadata names it.")
24
+ expect(html.match(/Application/g)?.length).toBe(1)
25
+ })
26
+ it.each([
27
+ ['omitted', "This resource's label names Application argocd/a, but its status doesn't list it."],
28
+ ['unreadable', "This resource's label names Application argocd/a, but Radar can't read its status."],
29
+ ['remote', "This resource's label names Application argocd/a, but Radar can't confirm it deploys to this cluster."],
30
+ ['missing', "This resource's label names Application argocd/a, but there's no such Application here."],
31
+ ['withheld', "This resource's label names Application argocd/a, but you can't view it."],
32
+ ['unresolved', "This resource's label names Application argocd/a, but Radar found no Application of that name listing it; it may be managed from another cluster."],
33
+ ['unchecked', "This resource's label names Application argocd/a, but Radar couldn't check its status."],
34
+ ] as const)('claimed explains %s', (check, reason) => {
35
+ const html = render({ state: 'claimed', lead: a, direct: { object, claims: [{ ...claim, check }] } })
36
+ expect(html).toContain('Tracked by')
37
+ expect(html).toContain(reason)
38
+ })
39
+ it('disagree names the claim and recorder with a warning', () => {
40
+ const html = render({ state: 'disagree', direct: { object, claims: [claim], recorders: [b] } })
41
+ expect(html).toContain('Tracked by')
42
+ expect(html).toContain("Application argocd/a: its status doesn't list this resource")
43
+ expect(html).toContain('Application argocd/b lists this resource in its status')
44
+ expect(html.indexOf('argocd/a: its status')).toBeLessThan(html.indexOf('argocd/b lists'))
45
+ expect(html).toMatch(/rounded-full[^\"]*bg-amber/)
46
+ })
47
+ it('shared lists multiple names with the lead first', () => {
48
+ const html = render({ state: 'shared', lead: b, direct: { object, recorders: [a, b] } })
49
+ expect(html).toContain('Listed by')
50
+ expect(html).toContain('argocd/b and argocd/a')
51
+ })
52
+ it('does not present the lead’s sync status as the status of several managers', () => {
53
+ const html = renderToStaticMarkup(<ManagedByChip manager={{ state: 'shared', lead: b, direct: { object, recorders: [a, b] } }} status={{ sync: 'Synced', health: 'Healthy', suspended: false }} />)
54
+ expect(html).toContain('Listed by')
55
+ expect(html).not.toContain('Synced')
56
+ })
57
+ it('several claims without a lead stay plural', () => {
58
+ const html = render({ state: 'claimed', direct: { object, claims: [claim, { ...claim, manager: b }] } })
59
+ expect(html).toContain('Tracked by')
60
+ expect(html).toContain('argocd/a and argocd/b')
61
+ })
62
+ it.each(['none', 'unknown'] as const)('%s has no chip', state => {
63
+ expect(render({ state, direct: { object } })).toBe('')
64
+ })
65
+ it('via composes the wording with the parent and keeps direct and between claims', () => {
66
+ const html = render({ state: 'listed', lead: b, direct: { object, claims: [claim] },
67
+ between: [{ object: { ...object, kind: 'ReplicaSet', name: 'web' }, claims: [{ ...claim, manager: { ...a, name: 'middle' }, copied: true }] }],
68
+ via: { object: { kind: 'Deployment', group: 'apps', namespace: 'demo', name: 'web' }, recorders: [b] } })
69
+ expect(html).toContain('Listed by')
70
+ expect(html).toContain('Through Deployment demo/web')
71
+ expect(html).toContain('its own label names Application argocd/a')
72
+ expect(html).not.toContain('argocd/middle')
73
+ })
74
+ })
75
+
76
+
77
+ it('Listed by names only recorders and explains an outside claim in the tooltip', () => {
78
+ const c = { ...a, name: 'outside' }
79
+ const manager: ManagerAnswer = { state: 'shared', lead: b, direct: { object, recorders: [a, b], claims: [{ ...claim, manager: c }] } }
80
+ const html = render(manager)
81
+ expect(html).toContain('argocd/b and argocd/a')
82
+ expect(html).not.toContain('argocd/b and argocd/a and argocd/outside')
83
+ expect(html).toContain("Application argocd/outside: its status doesn't list this resource")
84
+ })
85
+
86
+ it('says on the chip itself that the answer comes through an ancestor', () => {
87
+ const object = { kind: 'Pod', namespace: 'demo', name: 'web-1' }
88
+ const lead = { kind: 'Application', group: 'argoproj.io', namespace: 'argocd', name: 'a' }
89
+ const html = renderToStaticMarkup(<ManagedByChip manager={{ state: 'listed', lead, direct: { object }, via: { object: { kind: 'Deployment', group: 'apps', namespace: 'demo', name: 'web' }, recorders: [lead] } }} />)
90
+ expect(html).toMatch(/<span class="shrink-0 text-theme-text-tertiary">via Deployment<\/span>/)
91
+ })
92
+
93
+ describe('manager answer chip wording', () => {
94
+ it("says up front, in one sentence, why one claim isn't confirmed", () => {
95
+ const html = render({ state: 'claimed', lead: a, direct: { object, claims: [claim] } })
96
+ expect(html).toContain("This resource's label names Application argocd/a, but its status doesn't list it.")
97
+ expect(html.match(/argocd\/a/g)).toHaveLength(2)
98
+ })
99
+ it("says up front that several claims aren't confirmed, then why each", () => {
100
+ const html = render({ state: 'claimed', direct: { object, claims: [claim, { ...claim, manager: b, source: 'argo-tracking-id' }] } })
101
+ expect(html).toContain("labels or annotations name Application argocd/a and Application argocd/b; Radar hasn't confirmed they manage it")
102
+ expect(html).toContain("Application argocd/b: its status doesn't list this resource.")
103
+ })
104
+ // A claim the walk passed shows even when no ancestor answered.
105
+ it('names claims on the way without via', () => {
106
+ const html = render({ state: 'claimed', lead: a, direct: { object, claims: [claim] },
107
+ between: [{ object: { ...object, kind: 'ReplicaSet', name: 'web' }, claims: [{ ...claim, manager: { ...a, name: 'rs-app' } }] }] })
108
+ expect(html).toContain('ReplicaSet demo/web: its own label names Application argocd/rs-app')
109
+ })
110
+ // The manager's Synced reads as this resource's state beside a list alone.
111
+ it.each([['agree', true], ['listed', false]] as const)('%s shows the manager status: %s', (state, shown) => {
112
+ const html = renderToStaticMarkup(<ManagedByChip manager={{ state, lead: a, direct: { object, recorders: [a] } }} status={{ sync: 'Synced', health: 'Healthy' } as never} />)
113
+ expect(html.includes('Synced')).toBe(shown)
114
+ })
115
+ })
@@ -0,0 +1,103 @@
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 type { ManagerAnswer } from '../../types/core'
7
+ import { ManagedByChip } from './ManagedByChip'
8
+ import { managerChipPresentation } from '../../utils/gitops-owner'
9
+
10
+ vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true)
11
+
12
+ const a = { kind: 'Application', group: 'argoproj.io', namespace: 'argocd', name: 'a' }
13
+ const b = { ...a, name: 'b' }
14
+ const object = { kind: 'Deployment', group: 'apps', namespace: 'shop', name: 'web' }
15
+ const disagree: ManagerAnswer = { state: 'disagree', direct: { object, recorders: [b], claims: [{ manager: a, source: 'argo-tracking-id', check: 'omitted' }] } }
16
+
17
+ // A disagreement has no lead; its chip must still reach both managers.
18
+ it('opens a menu naming every candidate and what names each', async () => {
19
+ const opened: string[] = []
20
+ const element = document.createElement('div')
21
+ document.body.append(element)
22
+ const root = createRoot(element)
23
+ await act(async () => root.render(<ManagedByChip manager={disagree} onOpen={ref => opened.push(ref.name)} />))
24
+ await act(async () => { element.querySelector<HTMLButtonElement>('button[aria-haspopup="menu"]')!.click() })
25
+ const items = [...element.querySelectorAll<HTMLButtonElement>('[role="menuitem"]')]
26
+ expect(items.map(i => i.textContent)).toEqual(['Application argocd/aThis metadata names it', 'Application argocd/bIts status lists this'])
27
+ await act(async () => { items[1].click() })
28
+ expect(opened).toEqual(['b'])
29
+ await act(async () => root.unmount())
30
+ element.remove()
31
+ })
32
+
33
+ it('a chip without a link is still reachable by keyboard', () => {
34
+ expect(renderToStaticMarkup(<ManagedByChip manager={disagree} />)).toContain('tabindex="0"')
35
+ })
36
+
37
+ // A claim whose label names no namespace is still a candidate: named, unlinked.
38
+ it('names an unresolved candidate without linking it', async () => {
39
+ const element = document.createElement('div')
40
+ document.body.append(element)
41
+ const root = createRoot(element)
42
+ const unresolved: ManagerAnswer = { state: 'disagree', direct: { object, recorders: [b], claims: [{ manager: { ...a, namespace: '' }, source: 'argo-instance-label', check: 'unresolved' }] } }
43
+ await act(async () => root.render(<ManagedByChip manager={unresolved} onOpen={() => {}} />))
44
+ await act(async () => { element.querySelector<HTMLButtonElement>('button[aria-haspopup="menu"]')!.click() })
45
+ const items = [...element.querySelectorAll<HTMLButtonElement>('[role="menuitem"]')]
46
+ expect(items).toHaveLength(2)
47
+ const a0 = items.find(i => i.textContent!.startsWith('Application a'))!
48
+ expect(a0.disabled).toBe(true)
49
+ expect(a0.textContent).toContain('Radar found no Application of this name listing it; it may be managed from another cluster')
50
+ await act(async () => root.unmount())
51
+ element.remove()
52
+ })
53
+
54
+ // A claim of a manager Radar didn't find is named but not linked, so the chip
55
+ // never opens a page for an Application that isn't there.
56
+ it('links only managers Radar found', () => {
57
+ const missing: ManagerAnswer = { state: 'claimed', lead: a, direct: { object, claims: [{ manager: a, source: 'argo-tracking-id', check: 'missing' }] } }
58
+ expect(renderToStaticMarkup(<ManagedByChip manager={missing} onOpen={() => {}} />)).not.toContain('<button')
59
+ const found: ManagerAnswer = { state: 'claimed', lead: a, direct: { object, claims: [{ manager: a, source: 'argo-tracking-id', check: 'omitted' }] } }
60
+ expect(renderToStaticMarkup(<ManagedByChip manager={found} onOpen={() => {}} />)).toContain('<button')
61
+ })
62
+
63
+ // A newer Radar may send a check this UI doesn't know: render, don't link.
64
+ it('renders an unfamiliar check without linking it', () => {
65
+ const future: ManagerAnswer = { state: 'claimed', lead: a, direct: { object, claims: [{ manager: a, source: 'argo-tracking-id', check: 'installation-mismatch' as never }] } }
66
+ expect(renderToStaticMarkup(<ManagedByChip manager={future} onOpen={() => {}} />)).not.toContain('<button')
67
+ expect(managerChipPresentation(future)?.tooltip).toContain("couldn't confirm")
68
+ })
69
+
70
+ // One claim that read the manager is enough to link it, whatever another says.
71
+ it('links a manager one claim found though another left it unresolved', () => {
72
+ const both: ManagerAnswer = { state: 'claimed', lead: a, direct: { object, claims: [
73
+ { manager: a, source: 'argo-tracking-id', check: 'omitted' },
74
+ { manager: { ...a, namespace: '' }, source: 'argo-instance-label', check: 'unresolved' },
75
+ ] } }
76
+ expect(renderToStaticMarkup(<ManagedByChip manager={both} onOpen={() => {}} />)).toContain('<button')
77
+ })
78
+
79
+ // A label naming an Application of a name more than one has, without its
80
+ // namespace, isn't unresolved unless its check says so: the menu says it
81
+ // doesn't pick one, not that none exists.
82
+ const teamA = { ...a, namespace: 'team-a', name: 'web' }
83
+ const teamB = { ...a, namespace: 'team-b', name: 'web' }
84
+ const label = { ...a, namespace: '', name: 'web' }
85
+ it.each<[string, ManagerAnswer]>([
86
+ ['shared', { state: 'shared', direct: { object, recorders: [teamA, teamB], claims: [{ manager: label, source: 'argo-instance-label', check: 'recorded' }] } }],
87
+ ['disagree', { state: 'disagree', direct: { object, recorders: [teamA], claims: [
88
+ { manager: teamB, source: 'argo-tracking-id', check: 'omitted' },
89
+ { manager: label, source: 'argo-instance-label', check: 'recorded' },
90
+ ] } }],
91
+ ['an unfamiliar check', { state: 'shared', direct: { object, recorders: [teamA, teamB], claims: [{ manager: label, source: 'argo-instance-label', check: 'installation-mismatch' as never }] } }],
92
+ ])('a label naming no single Application of its name (%s) is not called unresolved', async (_, shared) => {
93
+ const element = document.createElement('div')
94
+ document.body.append(element)
95
+ const root = createRoot(element)
96
+ await act(async () => root.render(<ManagedByChip manager={shared} onOpen={() => {}} />))
97
+ await act(async () => { element.querySelector<HTMLButtonElement>('button[aria-haspopup="menu"]')!.click() })
98
+ const text = element.textContent!
99
+ expect(text).toContain("its label doesn't say which Application of this name")
100
+ expect(text).not.toContain('Radar found no Application')
101
+ await act(async () => root.unmount())
102
+ element.remove()
103
+ })