@skyhook-io/k8s-ui 1.18.0 → 1.18.2

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 (128) hide show
  1. package/README.md +21 -0
  2. package/package.json +1 -1
  3. package/src/components/applications/ApplicationsView.tsx +8 -7
  4. package/src/components/cluster-switcher/ClusterSwitcher.tsx +4 -0
  5. package/src/components/compare/CompareResourcePicker.tsx +18 -4
  6. package/src/components/compare/ResourceCompareView.tsx +6 -2
  7. package/src/components/gitops/GitOpsDetailLayout.test.tsx +64 -0
  8. package/src/components/gitops/GitOpsDetailLayout.tsx +22 -15
  9. package/src/components/gitops/GitOpsTableView.coverage.test.tsx +27 -0
  10. package/src/components/gitops/GitOpsTableView.tsx +46 -12
  11. package/src/components/gitops/detail-helpers.test.ts +58 -13
  12. package/src/components/gitops/detail-helpers.ts +37 -6
  13. package/src/components/gitops/index.ts +1 -0
  14. package/src/components/issues/IssuesView.tsx +16 -11
  15. package/src/components/issues/diagnostic.ts +2 -0
  16. package/src/components/issues/severity.ts +1 -0
  17. package/src/components/logs/LogCore.tsx +13 -11
  18. package/src/components/logs/LogsViewer.test.tsx +104 -59
  19. package/src/components/logs/LogsViewer.tsx +28 -8
  20. package/src/components/logs/WorkloadLogsViewer.tsx +1 -1
  21. package/src/components/namespace-switcher/NamespacePicker.test.tsx +236 -2
  22. package/src/components/namespace-switcher/NamespacePicker.tsx +44 -8
  23. package/src/components/resources/ResourcesSidebar.keyboard.test.tsx +28 -0
  24. package/src/components/resources/ResourcesSidebar.test.tsx +49 -0
  25. package/src/components/resources/ResourcesSidebar.tsx +62 -26
  26. package/src/components/resources/ResourcesView.crossplane-pause.test.tsx +133 -0
  27. package/src/components/resources/ResourcesView.flagger.test.tsx +31 -0
  28. package/src/components/resources/ResourcesView.kind-view.test.tsx +96 -1
  29. package/src/components/resources/ResourcesView.opentelemetry.test.tsx +29 -0
  30. package/src/components/resources/ResourcesView.selected-kind.test.ts +14 -0
  31. package/src/components/resources/ResourcesView.trivy-search.test.tsx +59 -0
  32. package/src/components/resources/ResourcesView.tsx +342 -83
  33. package/src/components/resources/curated-column-ownership.test.ts +3 -3
  34. package/src/components/resources/generic-status.ts +15 -0
  35. package/src/components/resources/printer-columns.test.ts +36 -0
  36. package/src/components/resources/printer-columns.ts +12 -1
  37. package/src/components/resources/readiness-gates.test.ts +95 -0
  38. package/src/components/resources/renderers/CompositeRenderer.tsx +8 -12
  39. package/src/components/resources/renderers/CrossplanePauseContext.test.tsx +153 -0
  40. package/src/components/resources/renderers/CrossplanePauseContext.tsx +39 -0
  41. package/src/components/resources/renderers/FlaggerRenderers.tsx +96 -0
  42. package/src/components/resources/renderers/GenericRenderer.tsx +19 -11
  43. package/src/components/resources/renderers/ManagedResourceRenderer.tsx +5 -12
  44. package/src/components/resources/renderers/OpenTelemetryRenderers.tsx +73 -0
  45. package/src/components/resources/renderers/PodRenderer.test.tsx +115 -0
  46. package/src/components/resources/renderers/PodRenderer.tsx +32 -9
  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-trivy.test.ts +87 -0
  56. package/src/components/resources/resource-utils-trivy.ts +122 -0
  57. package/src/components/resources/resource-utils.ts +80 -2
  58. package/src/components/shared/CreateResourceDialog.test.tsx +14 -0
  59. package/src/components/shared/CreateResourceDialog.tsx +5 -0
  60. package/src/components/shared/DetailShell.phone.test.tsx +54 -0
  61. package/src/components/shared/DetailShell.tsx +21 -8
  62. package/src/components/shared/EditableYamlView.phone.test.tsx +44 -0
  63. package/src/components/shared/EditableYamlView.test.tsx +58 -0
  64. package/src/components/shared/EditableYamlView.tsx +18 -3
  65. package/src/components/shared/FilterRailPanel.test.tsx +39 -0
  66. package/src/components/shared/FilterRailPanel.tsx +62 -0
  67. package/src/components/shared/GitOpsWriteWarning.managers.test.tsx +85 -0
  68. package/src/components/shared/GitOpsWriteWarning.tsx +87 -5
  69. package/src/components/shared/ManagedByChip.manager.test.tsx +115 -0
  70. package/src/components/shared/ManagedByChip.menu.test.tsx +103 -0
  71. package/src/components/shared/ManagedByChip.test.tsx +22 -0
  72. package/src/components/shared/ManagedByChip.tsx +158 -5
  73. package/src/components/shared/ResourceActionsBar.identity.test.tsx +64 -0
  74. package/src/components/shared/ResourceActionsBar.tsx +31 -9
  75. package/src/components/shared/ResourceRendererDispatch.test.tsx +49 -0
  76. package/src/components/shared/ResourceRendererDispatch.tsx +69 -31
  77. package/src/components/shared/SetImageDialog.tsx +3 -0
  78. package/src/components/timeline/TimelineList.tsx +5 -1
  79. package/src/components/topology/TopologyControls.tsx +1 -1
  80. package/src/components/ui/CrossplaneOperationNotice.test.tsx +153 -0
  81. package/src/components/ui/CrossplaneOperationNotice.tsx +139 -0
  82. package/src/components/ui/FetchResult.test.tsx +19 -2
  83. package/src/components/ui/FetchResult.tsx +13 -1
  84. package/src/components/ui/ForceDeleteConfirmDialog.test.tsx +55 -6
  85. package/src/components/ui/ForceDeleteConfirmDialog.tsx +127 -22
  86. package/src/components/ui/MultiSelectPicker.test.tsx +20 -0
  87. package/src/components/ui/MultiSelectPicker.tsx +20 -2
  88. package/src/components/ui/PageHeader.tsx +7 -4
  89. package/src/components/ui/SummaryTile.tsx +13 -3
  90. package/src/components/ui/Toast.tsx +33 -26
  91. package/src/components/ui/Tooltip.touch.test.tsx +55 -0
  92. package/src/components/ui/Tooltip.tsx +11 -2
  93. package/src/components/ui/YamlReview.test.tsx +52 -0
  94. package/src/components/ui/YamlReview.tsx +7 -0
  95. package/src/components/ui/drawer-components.test.tsx +280 -0
  96. package/src/components/ui/drawer-components.tsx +302 -18
  97. package/src/components/ui/drawer-older-replicasets.test.tsx +45 -0
  98. package/src/components/workload/WorkloadView.relationships.test.tsx +73 -0
  99. package/src/components/workload/WorkloadView.timelineFocus.test.tsx +96 -0
  100. package/src/components/workload/WorkloadView.tsx +105 -34
  101. package/src/hooks/index.ts +2 -0
  102. package/src/hooks/useKeepInViewport.mounted.test.tsx +72 -0
  103. package/src/hooks/useKeepInViewport.test.ts +27 -0
  104. package/src/hooks/useKeepInViewport.ts +87 -0
  105. package/src/hooks/useMediaQuery.ts +21 -0
  106. package/src/hooks/usePhoneLayout.ts +12 -0
  107. package/src/theme/tailwind-theme.css +5 -0
  108. package/src/types/core.ts +119 -3
  109. package/src/types/gitops-flux-inventory.test.ts +24 -0
  110. package/src/types/gitops.ts +5 -5
  111. package/src/types/index.ts +3 -0
  112. package/src/types/read-coverage.ts +65 -0
  113. package/src/types/resource-coverage.ts +40 -0
  114. package/src/utils/api-resources.ts +1 -0
  115. package/src/utils/format.test.ts +7 -0
  116. package/src/utils/format.ts +6 -1
  117. package/src/utils/gitops-owner.test.ts +42 -1
  118. package/src/utils/gitops-owner.ts +158 -8
  119. package/src/utils/gitops-write-guard.test.ts +332 -0
  120. package/src/utils/gitops-write-guard.ts +175 -11
  121. package/src/utils/index.ts +3 -0
  122. package/src/utils/read-actions.test.ts +22 -0
  123. package/src/utils/read-actions.ts +90 -0
  124. package/src/utils/resource-coverage.test.ts +121 -0
  125. package/src/utils/resource-coverage.ts +117 -0
  126. package/src/utils/touch-intent.test.ts +39 -0
  127. package/src/utils/touch-intent.ts +35 -0
  128. package/vitest.config.ts +26 -0
package/README.md CHANGED
@@ -2,6 +2,13 @@
2
2
 
3
3
  Shared, source-distributed Kubernetes UI components used by Radar.
4
4
 
5
+ ## Toast updates
6
+
7
+ `useToast().showToast(message, { id })` updates an existing toast with that ID
8
+ in place and restarts its dismiss timer. Omit `id` for independent toasts.
9
+ Set `duration: 0` to keep a progress toast visible until its result replaces it;
10
+ omit `duration` on the result to use the normal dismiss time.
11
+
5
12
  ## Local terminal metadata
6
13
 
7
14
  `LocalTerminalTab` accepts optional `onSessionInfo` and `toolbarExtra` props.
@@ -42,6 +49,12 @@ resolve: {
42
49
 
43
50
  webpack 5 hosts need the same mapping in `resolve.alias`. Without it the build fails to resolve the worker import.
44
51
 
52
+ ## Resource read coverage
53
+
54
+ `ResourceReadCoverage` describes the scope an answer covers (and `narrowedBy`, when the caller's access, Radar's pin or what Radar collects narrowed it), what Radar read of it, and coded gaps naming what it did not read and why. `READ_ACTIONS` / `readActionFor` say what resolves each code. `ResourceCoverage` additionally permits `unknown` in UI adapters for older Radar instances. It does not describe object freshness or count truncation.
55
+
56
+ Pass list coverage through `ResourceQueryResult.coverage`, and per-kind count coverage through `ResourcesView.resourceCoverage` / `ResourcesSidebar.resourceCoverage`. Counts stay useful for sizing; a partial count displays as a lower bound and a partial zero does not hide a kind as empty; a category total is a lower bound when any of its kinds is partial or narrowed by what Radar collects. A complete list whose scope Radar's collection narrowed (`narrowedBy: ["namespace_collection"]`) gets a neutral note naming the collected namespaces. Unknown or absent coverage keeps the presentation that predates coverage. Partial list notices name the affected namespaces when the gap names at most three; larger or unnamed sets keep the compact wording. Loaded list counts retain their coverage when cached. `GitOpsTableView` accepts aggregate `coverageStatus` and `countsPartial` keys; hosts must combine coverage across every cluster/source contributing to the view, and a host that passes no `coverageStatus` gets the unqualified view. Keep source-specific metadata in the host rather than repeating it on every row.
57
+
45
58
  ## Resource kind views and certainty
46
59
 
47
60
  `ResourcesView.renderKindView(kind)` optionally replaces a kind's table while
@@ -70,3 +83,11 @@ tooltip. `certaintyGlyph` and `certaintyValueLabel` expose the same vocabulary.
70
83
  `ActionConfirmDialog` adds context, reviewed writes and optional typed acknowledgement to the simpler `ConfirmDialog`. Hosts own loading, permission checks, stale-fact validation and result classification. Its `result` binds the state (`error`, `unknown` or `partial`), message and optional heading; unknown and partial results lock another submit. Retain that result outside the dialog across dismissal and remount, and clear it only after explicitly reviewing current state. The component does not infer outcomes from HTTP status. Remount it when changing the reviewed target or action, so acknowledgement and disclosure state start fresh. The default error heading makes no claim about whether a write occurred; use a stronger result title only when the producer proves it.
71
84
 
72
85
  Existing CNPG primitive exports remain available and delegate to these shared implementations.
86
+
87
+ `PrinterColumnDef.jsonPath` optionally carries source provenance from newer Radar servers. The UI does not evaluate it: values still come from the server's table converter. Without it, printer-cell tones stay generic. Crossplane pause qualification only recognizes exact Ready/Synced condition paths and corroborated annotation/condition evidence; same-named unrelated columns retain their normal tones. `ConditionTone` also supports opt-in `neutral`; `defaultConditionTone` retains its original return vocabulary. `ComposedRefStatus.pauseState` optionally supplies recorded Crossplane pause context for a composed-resource row.
88
+
89
+ `ForceDeleteConfirmDialog.resourceData` optionally supplies the current object for Crossplane pause context. Paused MR/XR/Claim shapes holding a known Crossplane finalizer receive a cleanup warning; the prop does not change confirmation or force-delete behavior. YAML operation context uses the existing baseline and predicted manifests; removing a pause annotation requests resumption rather than proving it.
90
+
91
+ `YamlReview.mode` distinguishes Apply, strict Create and Update (default: `apply`). Hosts using strict creation should pass `create` so an unpreviewable new document does not imply unknown live pause state. Crossplane operation context reports what the server preview actually keeps or clears; unsupported dry-run documents retain conditional uncertainty rather than treating submitted YAML as a live baseline.
92
+
93
+ For a single editor target, `YamlReview.resourceData` may retain its raw condition evidence when the review YAML intentionally omits status. `EditableYamlView` binds that snapshot to its preview. It is not used to classify unrelated documents in a batch. Generic Apply with neither a known Crossplane shape nor raw acknowledged pause does not infer a resource’s controller from the annotation alone.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@skyhook-io/k8s-ui",
3
- "version": "1.18.0",
3
+ "version": "1.18.2",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "https://github.com/skyhook-io/radar",
@@ -46,6 +46,7 @@ import {
46
46
  import { ReadyBar } from './ReadyBar'
47
47
  import { ProvenanceBadge, ClassBadge, CategoryChip, VersionInfo, BatchSignalChip } from './AppChips'
48
48
  import { AppIdentityTooltip, EnvHint, RolloutSummaryTooltip } from './AppTooltips'
49
+ import { FilterRailPanel } from '../shared/FilterRailPanel'
49
50
 
50
51
  function ApplicationRuntimeCell({ apps, workloadClass, ready, desired }: { apps: AppRow[]; workloadClass: AppWorkloadClass; ready: number; desired: number }) {
51
52
  if (workloadClass !== 'job') {
@@ -337,6 +338,7 @@ export function ApplicationsView({ entries: allEntries, variant, onSelect, title
337
338
  // showSystem lives in the Filters rail, so Clear resets it too (and its
338
339
  // non-default ON state counts as an active filter that surfaces the button).
339
340
  const anyFilterActive = filters.isActive
341
+ const activeFilterCount = fHealth.size + fClass.size + fType.size + fEnv.size + fSource.size + (systemCount > 0 && showSystem ? 1 : 0)
340
342
  const clearAllFilters = () => {
341
343
  filters.clearAll()
342
344
  }
@@ -355,7 +357,7 @@ export function ApplicationsView({ entries: allEntries, variant, onSelect, title
355
357
  actions={
356
358
  <>
357
359
  {headerActions}
358
- <SummaryTile label={total === 1 ? 'application' : 'applications'} value={total} loading={initialLoading} />
360
+ <SummaryTile label={total === 1 ? 'application' : 'applications'} phoneLabel={total === 1 ? 'app' : 'apps'} value={total} loading={initialLoading} />
359
361
  {healthTile('unhealthy', 'error')}
360
362
  {healthTile('degraded', 'warning')}
361
363
  {healthTile('healthy', 'success')}
@@ -381,13 +383,12 @@ export function ApplicationsView({ entries: allEntries, variant, onSelect, title
381
383
  Arbitrary 900px breakpoint (not `sm`) so the collapse is independent of
382
384
  the consuming app's Tailwind breakpoint config (Hub uses defaults). */}
383
385
  <aside className="flex w-52 shrink-0 flex-col overflow-hidden border-r border-theme-border bg-theme-surface/90 max-[899px]:max-h-72 max-[899px]:w-full max-[899px]:border-b max-[899px]:border-r-0">
384
- <div className="flex items-center justify-between border-b border-theme-border px-3 py-2">
385
- <span className="text-sm font-medium text-theme-text-secondary">Filters</span>
386
- {anyFilterActive && (
386
+ <FilterRailPanel
387
+ activeCount={activeFilterCount}
388
+ clear={anyFilterActive && (
387
389
  <button type="button" onClick={clearAllFilters} className="text-[10px] font-medium text-blue-500 hover:text-blue-400">Clear</button>
388
390
  )}
389
- </div>
390
- <div className="flex-1 overflow-y-auto">
391
+ >
391
392
  {initialLoading ? (
392
393
  <BoardRailSkeleton
393
394
  sections={[
@@ -414,7 +415,7 @@ export function ApplicationsView({ entries: allEntries, variant, onSelect, title
414
415
  )}
415
416
  </>
416
417
  )}
417
- </div>
418
+ </FilterRailPanel>
418
419
  </aside>
419
420
 
420
421
  {/* Content: toolbar (search + sort) over the scrollable table. */}
@@ -12,6 +12,7 @@ import { ClusterName } from '../ui/ClusterName'
12
12
  import { Input } from '../ui/Input'
13
13
  import { MiddleEllipsis } from '../ui/MiddleEllipsis'
14
14
  import { StatusDot, type StatusTone } from '../ui/status-tone'
15
+ import { useKeepInViewport } from '../../hooks/useKeepInViewport'
15
16
 
16
17
  export interface ClusterSwitcherItem {
17
18
  id: string
@@ -107,6 +108,8 @@ export const ClusterSwitcher = forwardRef<ClusterSwitcherHandle, ClusterSwitcher
107
108
  const [highlightedIndex, setHighlightedIndex] = useState(-1)
108
109
  const rootRef = useRef<HTMLDivElement>(null)
109
110
  const searchInputRef = useRef<HTMLInputElement>(null)
111
+ const menuRef = useRef<HTMLDivElement>(null)
112
+ useKeepInViewport(menuRef, isOpen)
110
113
 
111
114
  useImperativeHandle(ref, () => ({
112
115
  open: () => {
@@ -284,6 +287,7 @@ export const ClusterSwitcher = forwardRef<ClusterSwitcherHandle, ClusterSwitcher
284
287
 
285
288
  {isOpen && (
286
289
  <div
290
+ ref={menuRef}
287
291
  className={`absolute top-full ${positionClass} mt-1 z-50 min-w-[280px] max-w-[420px] bg-theme-surface border border-theme-border-light rounded-lg shadow-xl overflow-hidden`}
288
292
  >
289
293
  {searchable && (
@@ -1,4 +1,4 @@
1
- import { useEffect, useMemo, useRef, useState, useId } from 'react'
1
+ import { useEffect, useMemo, useRef, useState, useId, type ReactNode } from 'react'
2
2
  import { clsx } from 'clsx'
3
3
  import { GitCompare, Search, X } from 'lucide-react'
4
4
  import { DialogPortal } from '../ui/DialogPortal'
@@ -18,6 +18,11 @@ export interface CompareResourcePickerProps {
18
18
  sourceSide?: CompareSide
19
19
  /** Candidate resources — same kind as source. Source is filtered out automatically. */
20
20
  candidates: CompareResourceRef[]
21
+ /** Says the candidate list may be incomplete; shown as a warning above it. */
22
+ coverageNote?: ReactNode
23
+ /** Says the list covers only the namespaces Radar collects; shown neutrally.
24
+ * Ignored when coverageNote is set. */
25
+ scopeNote?: ReactNode
21
26
  loading?: boolean
22
27
  error?: unknown
23
28
  onPick: (r: CompareResourceRef) => void
@@ -30,6 +35,8 @@ export function CompareResourcePicker({
30
35
  sourceSide = 'a',
31
36
  candidates,
32
37
  loading,
38
+ coverageNote,
39
+ scopeNote,
33
40
  error,
34
41
  onPick,
35
42
  }: CompareResourcePickerProps) {
@@ -133,6 +140,9 @@ export function CompareResourcePicker({
133
140
  </div>
134
141
  </div>
135
142
 
143
+ {coverageNote
144
+ ? <div role="status" className="px-4 py-2 text-xs text-warning-text border-b border-theme-border">{coverageNote}</div>
145
+ : scopeNote && <div role="status" className="px-4 py-2 text-xs text-theme-text-tertiary border-b border-theme-border">{scopeNote}</div>}
136
146
  <div className="flex-1 min-h-0 overflow-y-auto">
137
147
  {loading && (
138
148
  <div className="flex items-center justify-center py-8 text-theme-text-secondary text-sm">
@@ -146,9 +156,13 @@ export function CompareResourcePicker({
146
156
  )}
147
157
  {!loading && error == null && filtered.length === 0 && (
148
158
  <div className="flex flex-col items-center justify-center py-12 text-theme-text-tertiary text-sm gap-1">
149
- {candidates.length <= 1
150
- ? `No other ${source.kind} available to compare against.`
151
- : 'No matches.'}
159
+ {candidates.length > 1
160
+ ? 'No matches.'
161
+ : coverageNote
162
+ ? `No other ${source.kind} found in what Radar could read.`
163
+ : scopeNote
164
+ ? `No other ${source.kind} found in the collected namespaces.`
165
+ : `No other ${source.kind} available to compare against.`}
152
166
  </div>
153
167
  )}
154
168
  {!loading && error == null && filtered.length > 0 && (
@@ -5,6 +5,7 @@ import { YamlDiffEditor } from '../ui/YamlEditor'
5
5
  import { PaneLoader } from '../ui/PaneLoader'
6
6
  import { Tooltip } from '../ui/Tooltip'
7
7
  import { pluralToKind } from '../../utils/navigation'
8
+ import { usePhoneLayout } from '../../hooks/usePhoneLayout'
8
9
  import { toComparableYaml } from './normalize'
9
10
  import { SIDE_TONES, type CompareSide } from './types'
10
11
 
@@ -120,7 +121,10 @@ export function ResourceCompareView({
120
121
  bleed = false,
121
122
  }: ResourceCompareViewProps) {
122
123
  const [specOnly, setSpecOnly] = useState(false)
123
- const [unified, setUnified] = useState(false)
124
+ const [unifiedPicked, setUnified] = useState(false)
125
+ // Two side-by-side panes don't fit a phone, so phones always get one column.
126
+ const isPhone = usePhoneLayout()
127
+ const unified = unifiedPicked || isPhone
124
128
  const [hideUnchanged, setHideUnchanged] = useState(true)
125
129
  const [rawMetadata, setRawMetadata] = useState(false)
126
130
 
@@ -180,7 +184,7 @@ export function ResourceCompareView({
180
184
  <ToggleButton active={rawMetadata} onClick={() => setRawMetadata(v => !v)} icon={<Sparkles className="w-3.5 h-3.5" />} label="Raw metadata" tooltip="Keep server-assigned noise (uid, resourceVersion, managedFields, last-applied)" />
181
185
  <ToggleButton active={specOnly} onClick={() => setSpecOnly(v => !v)} icon={<FileCode2 className="w-3.5 h-3.5" />} label="Spec only" tooltip="Drop status fields from both sides" />
182
186
  <ToggleButton active={hideUnchanged} onClick={() => setHideUnchanged(v => !v)} icon={<FileText className="w-3.5 h-3.5" />} label="Diff only" tooltip="Collapse unchanged regions" />
183
- <ToggleButton active={unified} onClick={() => setUnified(v => !v)} icon={<Rows className="w-3.5 h-3.5" />} label="Unified" tooltip="Switch between side-by-side and single-column" />
187
+ {!isPhone && <ToggleButton active={unified} onClick={() => setUnified(v => !v)} icon={<Rows className="w-3.5 h-3.5" />} label="Unified" tooltip="Switch between side-by-side and single-column" />}
184
188
  </div>
185
189
 
186
190
  <div className="w-px h-5 bg-theme-border shrink-0" aria-hidden />
@@ -0,0 +1,64 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { renderToString } from 'react-dom/server'
3
+ import { GitOpsDetailLayout, type GitOpsDetailLayoutProps } from './GitOpsDetailLayout'
4
+
5
+ const noop = () => {}
6
+
7
+ const shell: Omit<GitOpsDetailLayoutProps, 'identity' | 'isArgoApp'> = {
8
+ status: null,
9
+ terminating: false,
10
+ terminatingChipTooltip: '',
11
+ terminatingActionTooltip: '',
12
+ insight: null,
13
+ insightLoading: false,
14
+ isFlux: false,
15
+ isFluxWorkload: false,
16
+ activeTab: 'topology',
17
+ onTabChange: noop,
18
+ fullscreen: false,
19
+ onToggleFullscreen: noop,
20
+ renderTabBody: () => null,
21
+ onNavigateRoot: noop,
22
+ }
23
+
24
+ describe('GitOpsDetailLayout', () => {
25
+ it('shows the tool chip, facts and Argo operations for an Argo CD Application', () => {
26
+ const html = renderToString(
27
+ <GitOpsDetailLayout
28
+ {...shell}
29
+ identity={{ kind: 'applications', group: 'argoproj.io', namespace: 'shop', name: 'wordpress', toolLabel: 'ArgoCD', kindLabel: 'Application' }}
30
+ detail={{ project: 'default', destination: 'in-cluster' }}
31
+ isArgoApp
32
+ argo={{
33
+ onSyncRequested: noop, onRefresh: noop, onTerminate: noop, onSuspend: noop, onResume: noop,
34
+ syncing: false, refreshing: false, refreshingKind: 'normal', terminating: false, suspending: false, resuming: false,
35
+ isRunning: false, autoSyncEnabled: false,
36
+ }}
37
+ />,
38
+ )
39
+ expect(html).toContain('ArgoCD')
40
+ expect(html).toContain('Project')
41
+ expect(html).toContain('Sync…')
42
+ })
43
+
44
+ // A root that is not a GitOps resource gets one state: its name and why
45
+ // there is no GitOps view, with no tool label, facts or operations.
46
+ it('shows only the refusal for a root that is not a GitOps resource', () => {
47
+ const message = 'Application.app.k8s.io shop/wordpress is not a supported Argo CD or Flux resource, so it has no GitOps view'
48
+ const html = renderToString(
49
+ <GitOpsDetailLayout
50
+ {...shell}
51
+ identity={{ kind: 'applications', group: 'app.k8s.io', namespace: 'shop', name: 'wordpress', kindLabel: 'Application' }}
52
+ isArgoApp={false}
53
+ resourceError={new Error(message)}
54
+ />,
55
+ )
56
+ expect(html).toContain('wordpress')
57
+ expect(html).toContain(message)
58
+ expect(html).not.toContain('ArgoCD')
59
+ expect(html).not.toContain('FluxCD')
60
+ expect(html).not.toContain('Project')
61
+ expect(html).not.toContain('Sync…')
62
+ expect(html).not.toContain('Reconcile')
63
+ })
64
+ })
@@ -20,8 +20,9 @@ export interface GitOpsDetailIdentity {
20
20
  name: string
21
21
  // toolLabel: "ArgoCD" | "FluxCD" — passed through as a chip; kept as a
22
22
  // free string so future tools (Komodor, Argo Workflows, etc.) don't need
23
- // an enum bump here.
24
- toolLabel: string
23
+ // an enum bump here. Omitted for a root that is not a GitOps resource,
24
+ // which hides the tool/kind chip.
25
+ toolLabel?: string
25
26
  // kindLabel: human-facing kind (e.g. "Application"). Caller does the
26
27
  // lookup against its API resource catalog.
27
28
  kindLabel: string
@@ -117,7 +118,9 @@ export interface GitOpsDetailLayoutProps {
117
118
  terminatingActionTooltip: string
118
119
 
119
120
  // Spec/config row
120
- detail: GitOpsDetailMetadata
121
+ // Omitted for a root that is not a GitOps resource, which hides the
122
+ // spec/config facts row.
123
+ detail?: GitOpsDetailMetadata
121
124
 
122
125
  // Insights — drives StatusStrip + IssuesBand
123
126
  insight: GitOpsInsight | null
@@ -334,9 +337,11 @@ export function GitOpsDetailLayout(props: GitOpsDetailLayoutProps) {
334
337
  </span>
335
338
  </Tooltip>
336
339
  )}
337
- <span className="inline-flex shrink-0 items-center rounded border border-theme-border bg-theme-hover/50 px-1.5 py-0.5 text-[11px] font-medium text-theme-text-secondary">
338
- {identity.toolLabel} · {identity.kindLabel}
339
- </span>
340
+ {identity.toolLabel && (
341
+ <span className="inline-flex shrink-0 items-center rounded border border-theme-border bg-theme-hover/50 px-1.5 py-0.5 text-[11px] font-medium text-theme-text-secondary">
342
+ {identity.toolLabel} · {identity.kindLabel}
343
+ </span>
344
+ )}
340
345
  {isFleetContext && destinationCluster && (
341
346
  // Cross-cluster lineage chip — fleet-only. Renders the
342
347
  // destination cluster alongside the tool/kind chip so
@@ -350,14 +355,16 @@ export function GitOpsDetailLayout(props: GitOpsDetailLayoutProps) {
350
355
  )}
351
356
  </div>
352
357
  {/* Spec/config row */}
353
- <div className="mt-2 flex flex-wrap gap-x-5 gap-y-0.5 text-[11px] text-theme-text-tertiary">
354
- <AppFact label="Project" value={detail.project || '-'} />
355
- {detail.repository && <AppFact label="Source" value={detail.repository} />}
356
- {detail.path && <AppFact label="Path" value={detail.path} />}
357
- {detail.chart && <AppFact label="Chart" value={detail.chart} />}
358
- <AppFact label="Destination" value={detail.destination || '-'} />
359
- {detail.autoSyncMode && <AppFact label="Sync mode" value={detail.autoSyncMode} />}
360
- </div>
358
+ {detail && (
359
+ <div className="mt-2 flex flex-wrap gap-x-5 gap-y-0.5 text-[11px] text-theme-text-tertiary">
360
+ <AppFact label="Project" value={detail.project || '-'} />
361
+ {detail.repository && <AppFact label="Source" value={detail.repository} />}
362
+ {detail.path && <AppFact label="Path" value={detail.path} />}
363
+ {detail.chart && <AppFact label="Chart" value={detail.chart} />}
364
+ <AppFact label="Destination" value={detail.destination || '-'} />
365
+ {detail.autoSyncMode && <AppFact label="Sync mode" value={detail.autoSyncMode} />}
366
+ </div>
367
+ )}
361
368
  </div>
362
369
  {/* Action buttons */}
363
370
  <div className="flex flex-wrap items-center gap-2">
@@ -518,7 +525,7 @@ export function GitOpsDetailLayout(props: GitOpsDetailLayoutProps) {
518
525
  {resourceLoading ? (
519
526
  <PaneLoader label="Loading GitOps resource…" className="flex-1" />
520
527
  ) : resourceError ? (
521
- <div className="p-4 text-sm text-red-500">Failed to load resource: {resourceError.message}</div>
528
+ <div className="p-4 text-sm text-red-500">Failed to load GitOps view: {resourceError.message}</div>
522
529
  ) : (
523
530
  <div className={bodyShellClass}>
524
531
  <div className="flex shrink-0 items-center justify-between gap-3 border-b border-theme-border bg-theme-base px-4 py-2">
@@ -0,0 +1,27 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { renderToString } from 'react-dom/server'
3
+ import { GitOpsTableView } from './GitOpsTableView'
4
+
5
+ const render = (props: Partial<Parameters<typeof GitOpsTableView>[0]> = {}) =>
6
+ renderToString(<GitOpsTableView rows={[]} counts={{}} onRowClick={() => {}} {...props} />)
7
+
8
+ describe('GitOpsTableView coverage', () => {
9
+ it('shows the onboarding state to a host that passes no coverage, as before coverage', () => {
10
+ expect(render()).toContain('No GitOps resources detected')
11
+ expect(render({ coverageStatus: 'complete' })).toContain('No GitOps resources detected')
12
+ })
13
+ it('keeps the unqualified labels and empty text for unknown coverage', () => {
14
+ const html = render({ coverageStatus: 'unknown', counts: { 'argoproj.io/Application': 2 } })
15
+ expect(html).toContain('Total Applications')
16
+ expect(html).toContain('No applications found.')
17
+ expect(html).not.toContain('Partial results')
18
+ })
19
+ it('says what a complete empty answer covers', () => {
20
+ expect(render({ coverageStatus: 'complete', counts: { 'argoproj.io/Application': 2 } })).toContain('No applications found in the requested scope.')
21
+ })
22
+ it('does not call a partial answer empty', () => {
23
+ const html = render({ coverageStatus: 'partial' })
24
+ expect(html).not.toContain('No GitOps resources detected')
25
+ expect(html).toContain('Partial results')
26
+ })
27
+ })
@@ -41,6 +41,7 @@ import { toggleSet } from './GitOpsGraphFilterRail'
41
41
  import { useFilterState, defineFilterSchema } from '../../filter-state'
42
42
  import { parseContextName } from '../../utils/context-name'
43
43
  import { formatCompactAge, formatRelativeAgeTime } from '../../utils/format'
44
+ import { FilterRailPanel } from '../shared/FilterRailPanel'
44
45
 
45
46
  // =============================================================================
46
47
  // GitOpsTableView — the canonical GitOps fleet list (Argo + Flux).
@@ -202,7 +203,13 @@ export interface GitOpsTableViewProps {
202
203
  // counts keyed "group/Kind" — e.g. "argoproj.io/Application" → 17. Drives
203
204
  // the Scope-section mode tabs and the empty-state check.
204
205
  counts: Record<string, number>
206
+ // Read coverage of `rows`, combined across every source and cluster that
207
+ // contributes to them. `unknown` (the default) keeps the presentation that
208
+ // predates coverage; only `partial` qualifies the view.
209
+ coverageStatus?: 'complete' | 'partial' | 'unknown'
205
210
  countsUnavailable?: string[]
211
+ // Count keys whose counts are lower bounds.
212
+ countsPartial?: string[]
206
213
  // @deprecated Superseded by `freshnessSlot` — kept for source compatibility
207
214
  // with existing consumers; no longer drives any affordance here.
208
215
  onRefresh?: () => void
@@ -305,6 +312,8 @@ export function GitOpsTableView({
305
312
  error,
306
313
  counts,
307
314
  countsUnavailable,
315
+ countsPartial,
316
+ coverageStatus = 'unknown',
308
317
  freshnessSlot,
309
318
  onRowClick,
310
319
  rowHrefFor,
@@ -403,6 +412,13 @@ export function GitOpsTableView({
403
412
  projects: counts['argoproj.io/AppProject'] ?? 0,
404
413
  alerts: counts['notification.toolkit.fluxcd.io/Alert'] ?? 0,
405
414
  }
415
+ const partialCountKeys = new Set(countsPartial ?? [])
416
+ const modeCountsPartial: Record<GitOpsMode, boolean> = {
417
+ applications: coverageStatus === 'partial',
418
+ sources: ['source.toolkit.fluxcd.io/GitRepository', 'source.toolkit.fluxcd.io/OCIRepository', 'source.toolkit.fluxcd.io/HelmRepository'].some(key => partialCountKeys.has(key)),
419
+ projects: partialCountKeys.has('argoproj.io/AppProject'),
420
+ alerts: partialCountKeys.has('notification.toolkit.fluxcd.io/Alert'),
421
+ }
406
422
  const totalGitOps = Object.entries(counts).reduce(
407
423
  (sum, [key, n]) => sum + (isGitOpsCountKey(key) ? n : 0),
408
424
  0,
@@ -548,7 +564,7 @@ export function GitOpsTableView({
548
564
  // Also require zero actual rows: the cold-cache retry can populate `rows`
549
565
  // before the separate counts map catches up, and a populated table must not
550
566
  // be hidden behind a "nothing here" screen.
551
- if (totalGitOps === 0 && allRowsInput.length === 0 && !loading && !hasGlobalNamespaceFilter && !hasUnavailableGitOpsCounts) {
567
+ if (totalGitOps === 0 && allRowsInput.length === 0 && !loading && !hasGlobalNamespaceFilter && !hasUnavailableGitOpsCounts && coverageStatus !== 'partial') {
552
568
  return (
553
569
  <div className="flex h-full min-h-0 flex-1 items-start justify-center bg-theme-base px-4 pb-4 pt-[22vh]">
554
570
  <div className="rounded-lg border border-theme-border bg-theme-surface p-8 text-center">
@@ -580,7 +596,7 @@ export function GitOpsTableView({
580
596
  const summaryTiles: SummaryTileSpec[] = [
581
597
  {
582
598
  key: 'total',
583
- label: 'Total Applications',
599
+ label: coverageStatus === 'partial' ? 'Observed Applications' : 'Total Applications',
584
600
  value: allRows.length,
585
601
  tone: 'neutral',
586
602
  active: noOtherFiltersActive(),
@@ -678,6 +694,7 @@ export function GitOpsTableView({
678
694
  mode={mode}
679
695
  onModeChange={setMode}
680
696
  modeCounts={modeCounts}
697
+ modeCountsPartial={modeCountsPartial}
681
698
  syncCounts={syncCounts}
682
699
  syncFilters={syncFilters}
683
700
  onToggleSync={(value) => filters.toggle('sync', value)}
@@ -790,6 +807,10 @@ export function GitOpsTableView({
790
807
  </div>
791
808
  </div>
792
809
 
810
+ {coverageStatus === 'partial' && (
811
+ <p role="status" className="px-4 py-2 text-xs text-warning-text border-b border-theme-border">Partial results. Radar couldn't read every namespace for some GitOps resources or sources, so counts are lower bounds.</p>
812
+ )}
813
+
793
814
  {/* pb-20 keeps the last row (and its three-dot menu) scrollable clear
794
815
  of the app's fixed bottom-right overlay buttons; without the slack
795
816
  the bottom row's action trigger sits under them and can't be clicked
@@ -806,7 +827,7 @@ export function GitOpsTableView({
806
827
  <div className="p-4 text-sm text-red-500">Failed to load GitOps applications: {error.message}</div>
807
828
  ) : filteredRows.length === 0 ? (
808
829
  <div className="flex h-full flex-col items-center justify-center gap-3 text-sm text-theme-text-secondary">
809
- <p>{allRows.length === 0 && !hasGlobalNamespaceFilter ? 'No applications found.' : 'No applications match the current filters.'}</p>
830
+ <p>{noApplicationsMessage(allRows.length > 0, coverageStatus, hasGlobalNamespaceFilter)}</p>
810
831
  {hasGlobalNamespaceFilter && globalNamespaces && (
811
832
  <p className="text-xs text-theme-text-tertiary">
812
833
  Viewing {globalNamespaces.length === 1 ? `namespace: ${globalNamespaces[0]}` : `${globalNamespaces.length} namespaces`}
@@ -857,6 +878,7 @@ function GitOpsFilterSidebar({
857
878
  mode,
858
879
  onModeChange,
859
880
  modeCounts,
881
+ modeCountsPartial,
860
882
  syncCounts,
861
883
  syncFilters,
862
884
  onToggleSync,
@@ -884,6 +906,7 @@ function GitOpsFilterSidebar({
884
906
  mode: GitOpsMode
885
907
  onModeChange: (mode: GitOpsMode) => void
886
908
  modeCounts: Record<GitOpsMode, number>
909
+ modeCountsPartial: Record<GitOpsMode, boolean>
887
910
  syncCounts: Map<string, number>
888
911
  syncFilters: Set<string>
889
912
  onToggleSync: (value: string) => void
@@ -915,13 +938,17 @@ function GitOpsFilterSidebar({
915
938
  side === 'right' ? 'border-l max-[899px]:border-l-0' : 'border-r max-[899px]:border-r-0'
916
939
  }`}
917
940
  >
918
- <div className="flex items-center justify-between border-b border-theme-border px-3 py-2">
919
- <span className="text-sm font-medium text-theme-text-secondary">Filters</span>
920
- <button type="button" onClick={onClear} className="text-[10px] font-medium text-blue-500 hover:text-blue-400">
921
- Clear
922
- </button>
923
- </div>
924
- <div className="flex-1 overflow-y-auto">
941
+ <FilterRailPanel
942
+ activeCount={
943
+ syncFilters.size + healthFilters.size + automationFilters.size + kindFilters.size +
944
+ projectFilters.size + namespaceFilters.size + (lifecycleFilter === 'all' ? 0 : 1)
945
+ }
946
+ clear={
947
+ <button type="button" onClick={onClear} className="text-[10px] font-medium text-blue-500 hover:text-blue-400">
948
+ Clear
949
+ </button>
950
+ }
951
+ >
925
952
  {loading ? (
926
953
  <BoardRailSkeleton
927
954
  sections={[
@@ -949,7 +976,7 @@ function GitOpsFilterSidebar({
949
976
  }`}
950
977
  >
951
978
  <div className="font-medium">{modeLabel(item)}</div>
952
- <div className={mode === item ? 'text-white/70' : 'text-theme-text-tertiary'}>{modeCounts[item]}</div>
979
+ <div className={mode === item ? 'text-white/70' : 'text-theme-text-tertiary'}>{modeCountsPartial[item] ? '≥' : ''}{modeCounts[item]}</div>
953
980
  </button>
954
981
  ))}
955
982
  </div>
@@ -1040,7 +1067,7 @@ function GitOpsFilterSidebar({
1040
1067
  </GitOpsFilterSection>
1041
1068
  </>
1042
1069
  )}
1043
- </div>
1070
+ </FilterRailPanel>
1044
1071
  </aside>
1045
1072
  )
1046
1073
  }
@@ -1882,6 +1909,13 @@ function countMap(values: string[]) {
1882
1909
  return counts
1883
1910
  }
1884
1911
 
1912
+ function noApplicationsMessage(hasRows: boolean, coverageStatus: GitOpsTableViewProps['coverageStatus'], namespaceFiltered: boolean): string {
1913
+ if (hasRows) return 'No applications match the current filters.'
1914
+ if (coverageStatus === 'complete') return 'No applications found in the requested scope.'
1915
+ if (coverageStatus === 'partial') return 'No applications found in the namespaces Radar could read.'
1916
+ return namespaceFiltered ? 'No applications match the current filters.' : 'No applications found.'
1917
+ }
1918
+
1885
1919
  function countLabels(rows: GitOpsRow[]) {
1886
1920
  const counts = new Map<string, number>()
1887
1921
  for (const row of rows) {
@@ -5,6 +5,7 @@ import {
5
5
  formatGitOpsSourceUrl,
6
6
  getGitOpsResourceStatus,
7
7
  getGitOpsTool,
8
+ gitOpsDetailGroup,
8
9
  isDestinationRef,
9
10
  parseArgoRollbackID,
10
11
  } from './detail-helpers'
@@ -75,26 +76,47 @@ describe('formatGitOpsSourceUrl', () => {
75
76
  })
76
77
 
77
78
  describe('getGitOpsTool', () => {
78
- // Group is the canonical discriminator.
79
- test('Argo via group', () => {
79
+ // The API group decides, the same rule as the backend (pkg/gitops ToolFor).
80
+ test('Argo CD kinds in argoproj.io', () => {
80
81
  expect(getGitOpsTool('applications', 'argoproj.io')).toBe('argo')
81
82
  expect(getGitOpsTool('applicationsets', 'argoproj.io')).toBe('argo')
82
83
  expect(getGitOpsTool('appprojects', 'argoproj.io')).toBe('argo')
83
84
  })
84
- // Argo kinds without an explicit group still route to 'argo'.
85
- // Defends against a normalizer that drops `group` from the frontend payload.
86
- test('Argo kinds without group still route to argo', () => {
87
- expect(getGitOpsTool('applications', undefined)).toBe('argo')
88
- expect(getGitOpsTool('applicationsets', '')).toBe('argo')
89
- })
90
- // Flux kinds with their canonical groups.
91
- test('Flux via group', () => {
85
+ test('Flux groups', () => {
92
86
  expect(getGitOpsTool('kustomizations', 'kustomize.toolkit.fluxcd.io')).toBe('flux')
93
87
  expect(getGitOpsTool('helmreleases', 'helm.toolkit.fluxcd.io')).toBe('flux')
88
+ expect(getGitOpsTool('gitrepositories', 'source.toolkit.fluxcd.io')).toBe('flux')
89
+ })
90
+ // SIG Apps defines its own Application kind; offering Argo Sync on it
91
+ // would sync a same-named argoproj.io Application instead.
92
+ test('an Application outside argoproj.io is not Argo CD', () => {
93
+ expect(getGitOpsTool('applications', 'app.k8s.io')).toBeNull()
94
+ expect(getGitOpsTool('applications', 'source.toolkit.fluxcd.io')).toBeNull()
95
+ expect(getGitOpsTool('kustomizations', 'source.toolkit.fluxcd.io')).toBeNull()
96
+ expect(getGitOpsTool('helmreleases', 'kustomize.toolkit.fluxcd.io')).toBeNull()
97
+ })
98
+ // argoproj.io is shared with Argo Rollouts and Workflows.
99
+ test('other argoproj.io kinds are not Argo CD', () => {
100
+ expect(getGitOpsTool('rollouts', 'argoproj.io')).toBeNull()
101
+ expect(getGitOpsTool('workflows', 'argoproj.io')).toBeNull()
102
+ })
103
+ // The kind alone is not identity, and nothing defaults to Flux.
104
+ test('no group or an unknown group is not GitOps', () => {
105
+ expect(getGitOpsTool('applications', undefined)).toBeNull()
106
+ expect(getGitOpsTool('applicationsets', '')).toBeNull()
107
+ expect(getGitOpsTool('somecustomresource', 'unknown.io')).toBeNull()
108
+ expect(getGitOpsTool('applications', 'notargoproj.io')).toBeNull()
109
+ expect(getGitOpsTool('kustomizations', 'fake.kustomize.toolkit.fluxcd.io')).toBeNull()
110
+ })
111
+ })
112
+
113
+ describe('gitOpsDetailGroup', () => {
114
+ test('the loaded object\'s apiVersion wins over the route group', () => {
115
+ expect(gitOpsDetailGroup('argoproj.io', { apiVersion: 'app.k8s.io/v1beta1' })).toBe('app.k8s.io')
94
116
  })
95
- // Unknown kinds default to flux (matches the OSS fall-through).
96
- test('unknown kind defaults to flux', () => {
97
- expect(getGitOpsTool('somecustomresource', 'unknown.io')).toBe('flux')
117
+ test('the route group stands in until the object loads', () => {
118
+ expect(gitOpsDetailGroup('app.k8s.io', undefined)).toBe('app.k8s.io')
119
+ expect(gitOpsDetailGroup('argoproj.io', {})).toBe('argoproj.io')
98
120
  })
99
121
  })
100
122
 
@@ -181,3 +203,26 @@ describe('isDestinationRef', () => {
181
203
  expect(isDestinationRef(null, true, { group: 'apps', kind: 'Deployment', namespace: 'prod', name: 'billing' })).toBe(true)
182
204
  })
183
205
  })
206
+
207
+ describe('GitOps status identity', () => {
208
+ test.each([
209
+ ['applications', 'app.k8s.io/v1beta1'],
210
+ ['applicationsets', 'vendor.io/v1'],
211
+ ['kustomizations', 'source.toolkit.fluxcd.io/v1'],
212
+ ['helmreleases', 'vendor.helm.toolkit.fluxcd.io/v1'],
213
+ ])('does not interpret foreign %s status from %s', (kind, apiVersion) => {
214
+ expect(getGitOpsResourceStatus(kind, {
215
+ apiVersion,
216
+ status: { sync: { status: 'Synced' }, health: { status: 'Healthy' }, conditions: [{ type: 'Ready', status: 'True' }] },
217
+ })).toBeNull()
218
+ })
219
+
220
+ test('preserves existing controller-specific status-only callers', () => {
221
+ expect(getGitOpsResourceStatus('applications', { status: { sync: { status: 'Synced' }, health: { status: 'Healthy' } } })?.sync).toBe('Synced')
222
+ expect(getGitOpsResourceStatus('kustomizations', { status: { conditions: [{ type: 'Ready', status: 'True' }] } })?.health).toBe('Healthy')
223
+ })
224
+
225
+ test('does not interpret an AppProject as a Flux resource', () => {
226
+ expect(getGitOpsResourceStatus('appprojects', { apiVersion: 'argoproj.io/v1alpha1', status: { conditions: [{ type: 'Ready', status: 'True' }] } })).toBeNull()
227
+ })
228
+ })