@skyhook-io/k8s-ui 1.18.0 → 1.18.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 (63) hide show
  1. package/README.md +13 -0
  2. package/package.json +1 -1
  3. package/src/components/compare/CompareResourcePicker.tsx +18 -4
  4. package/src/components/gitops/GitOpsDetailLayout.test.tsx +64 -0
  5. package/src/components/gitops/GitOpsDetailLayout.tsx +22 -15
  6. package/src/components/gitops/GitOpsTableView.coverage.test.tsx +27 -0
  7. package/src/components/gitops/GitOpsTableView.tsx +33 -4
  8. package/src/components/gitops/detail-helpers.test.ts +58 -13
  9. package/src/components/gitops/detail-helpers.ts +37 -6
  10. package/src/components/gitops/index.ts +1 -0
  11. package/src/components/issues/IssuesView.tsx +14 -9
  12. package/src/components/logs/LogsViewer.test.tsx +104 -59
  13. package/src/components/logs/LogsViewer.tsx +28 -8
  14. package/src/components/namespace-switcher/NamespacePicker.test.tsx +236 -2
  15. package/src/components/namespace-switcher/NamespacePicker.tsx +42 -8
  16. package/src/components/resources/ResourcesSidebar.test.tsx +49 -0
  17. package/src/components/resources/ResourcesSidebar.tsx +36 -21
  18. package/src/components/resources/ResourcesView.selected-kind.test.ts +14 -0
  19. package/src/components/resources/ResourcesView.trivy-search.test.tsx +59 -0
  20. package/src/components/resources/ResourcesView.tsx +106 -19
  21. package/src/components/resources/renderers/PodRenderer.test.tsx +115 -0
  22. package/src/components/resources/renderers/PodRenderer.tsx +32 -9
  23. package/src/components/resources/resource-utils-trivy.test.ts +87 -0
  24. package/src/components/resources/resource-utils-trivy.ts +122 -0
  25. package/src/components/shared/DetailShell.tsx +9 -7
  26. package/src/components/shared/ManagedByChip.test.tsx +22 -0
  27. package/src/components/shared/ManagedByChip.tsx +24 -4
  28. package/src/components/shared/ResourceActionsBar.identity.test.tsx +64 -0
  29. package/src/components/shared/ResourceActionsBar.tsx +9 -3
  30. package/src/components/shared/ResourceRendererDispatch.test.tsx +49 -0
  31. package/src/components/shared/ResourceRendererDispatch.tsx +46 -31
  32. package/src/components/timeline/TimelineList.tsx +5 -1
  33. package/src/components/ui/FetchResult.test.tsx +19 -2
  34. package/src/components/ui/FetchResult.tsx +13 -1
  35. package/src/components/ui/MultiSelectPicker.test.tsx +20 -0
  36. package/src/components/ui/MultiSelectPicker.tsx +20 -2
  37. package/src/components/ui/PageHeader.tsx +6 -4
  38. package/src/components/ui/Toast.tsx +33 -26
  39. package/src/components/ui/drawer-components.test.tsx +253 -0
  40. package/src/components/ui/drawer-components.tsx +315 -11
  41. package/src/components/ui/drawer-older-replicasets.test.tsx +45 -0
  42. package/src/components/workload/WorkloadView.relationships.test.tsx +33 -0
  43. package/src/components/workload/WorkloadView.timelineFocus.test.tsx +96 -0
  44. package/src/components/workload/WorkloadView.tsx +54 -6
  45. package/src/hooks/index.ts +2 -0
  46. package/src/hooks/useMediaQuery.ts +21 -0
  47. package/src/hooks/usePhoneLayout.ts +12 -0
  48. package/src/theme/tailwind-theme.css +5 -0
  49. package/src/types/core.ts +66 -2
  50. package/src/types/gitops-flux-inventory.test.ts +24 -0
  51. package/src/types/gitops.ts +5 -5
  52. package/src/types/index.ts +3 -0
  53. package/src/types/read-coverage.ts +65 -0
  54. package/src/types/resource-coverage.ts +40 -0
  55. package/src/utils/format.test.ts +7 -0
  56. package/src/utils/format.ts +6 -1
  57. package/src/utils/gitops-owner.test.ts +42 -1
  58. package/src/utils/gitops-owner.ts +22 -0
  59. package/src/utils/index.ts +2 -0
  60. package/src/utils/read-actions.test.ts +22 -0
  61. package/src/utils/read-actions.ts +56 -0
  62. package/src/utils/resource-coverage.test.ts +121 -0
  63. package/src/utils/resource-coverage.ts +117 -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
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.1",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "https://github.com/skyhook-io/radar",
@@ -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 && (
@@ -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
+ })
@@ -202,7 +202,13 @@ export interface GitOpsTableViewProps {
202
202
  // counts keyed "group/Kind" — e.g. "argoproj.io/Application" → 17. Drives
203
203
  // the Scope-section mode tabs and the empty-state check.
204
204
  counts: Record<string, number>
205
+ // Read coverage of `rows`, combined across every source and cluster that
206
+ // contributes to them. `unknown` (the default) keeps the presentation that
207
+ // predates coverage; only `partial` qualifies the view.
208
+ coverageStatus?: 'complete' | 'partial' | 'unknown'
205
209
  countsUnavailable?: string[]
210
+ // Count keys whose counts are lower bounds.
211
+ countsPartial?: string[]
206
212
  // @deprecated Superseded by `freshnessSlot` — kept for source compatibility
207
213
  // with existing consumers; no longer drives any affordance here.
208
214
  onRefresh?: () => void
@@ -305,6 +311,8 @@ export function GitOpsTableView({
305
311
  error,
306
312
  counts,
307
313
  countsUnavailable,
314
+ countsPartial,
315
+ coverageStatus = 'unknown',
308
316
  freshnessSlot,
309
317
  onRowClick,
310
318
  rowHrefFor,
@@ -403,6 +411,13 @@ export function GitOpsTableView({
403
411
  projects: counts['argoproj.io/AppProject'] ?? 0,
404
412
  alerts: counts['notification.toolkit.fluxcd.io/Alert'] ?? 0,
405
413
  }
414
+ const partialCountKeys = new Set(countsPartial ?? [])
415
+ const modeCountsPartial: Record<GitOpsMode, boolean> = {
416
+ applications: coverageStatus === 'partial',
417
+ sources: ['source.toolkit.fluxcd.io/GitRepository', 'source.toolkit.fluxcd.io/OCIRepository', 'source.toolkit.fluxcd.io/HelmRepository'].some(key => partialCountKeys.has(key)),
418
+ projects: partialCountKeys.has('argoproj.io/AppProject'),
419
+ alerts: partialCountKeys.has('notification.toolkit.fluxcd.io/Alert'),
420
+ }
406
421
  const totalGitOps = Object.entries(counts).reduce(
407
422
  (sum, [key, n]) => sum + (isGitOpsCountKey(key) ? n : 0),
408
423
  0,
@@ -548,7 +563,7 @@ export function GitOpsTableView({
548
563
  // Also require zero actual rows: the cold-cache retry can populate `rows`
549
564
  // before the separate counts map catches up, and a populated table must not
550
565
  // be hidden behind a "nothing here" screen.
551
- if (totalGitOps === 0 && allRowsInput.length === 0 && !loading && !hasGlobalNamespaceFilter && !hasUnavailableGitOpsCounts) {
566
+ if (totalGitOps === 0 && allRowsInput.length === 0 && !loading && !hasGlobalNamespaceFilter && !hasUnavailableGitOpsCounts && coverageStatus !== 'partial') {
552
567
  return (
553
568
  <div className="flex h-full min-h-0 flex-1 items-start justify-center bg-theme-base px-4 pb-4 pt-[22vh]">
554
569
  <div className="rounded-lg border border-theme-border bg-theme-surface p-8 text-center">
@@ -580,7 +595,7 @@ export function GitOpsTableView({
580
595
  const summaryTiles: SummaryTileSpec[] = [
581
596
  {
582
597
  key: 'total',
583
- label: 'Total Applications',
598
+ label: coverageStatus === 'partial' ? 'Observed Applications' : 'Total Applications',
584
599
  value: allRows.length,
585
600
  tone: 'neutral',
586
601
  active: noOtherFiltersActive(),
@@ -678,6 +693,7 @@ export function GitOpsTableView({
678
693
  mode={mode}
679
694
  onModeChange={setMode}
680
695
  modeCounts={modeCounts}
696
+ modeCountsPartial={modeCountsPartial}
681
697
  syncCounts={syncCounts}
682
698
  syncFilters={syncFilters}
683
699
  onToggleSync={(value) => filters.toggle('sync', value)}
@@ -790,6 +806,10 @@ export function GitOpsTableView({
790
806
  </div>
791
807
  </div>
792
808
 
809
+ {coverageStatus === 'partial' && (
810
+ <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>
811
+ )}
812
+
793
813
  {/* pb-20 keeps the last row (and its three-dot menu) scrollable clear
794
814
  of the app's fixed bottom-right overlay buttons; without the slack
795
815
  the bottom row's action trigger sits under them and can't be clicked
@@ -806,7 +826,7 @@ export function GitOpsTableView({
806
826
  <div className="p-4 text-sm text-red-500">Failed to load GitOps applications: {error.message}</div>
807
827
  ) : filteredRows.length === 0 ? (
808
828
  <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>
829
+ <p>{noApplicationsMessage(allRows.length > 0, coverageStatus, hasGlobalNamespaceFilter)}</p>
810
830
  {hasGlobalNamespaceFilter && globalNamespaces && (
811
831
  <p className="text-xs text-theme-text-tertiary">
812
832
  Viewing {globalNamespaces.length === 1 ? `namespace: ${globalNamespaces[0]}` : `${globalNamespaces.length} namespaces`}
@@ -857,6 +877,7 @@ function GitOpsFilterSidebar({
857
877
  mode,
858
878
  onModeChange,
859
879
  modeCounts,
880
+ modeCountsPartial,
860
881
  syncCounts,
861
882
  syncFilters,
862
883
  onToggleSync,
@@ -884,6 +905,7 @@ function GitOpsFilterSidebar({
884
905
  mode: GitOpsMode
885
906
  onModeChange: (mode: GitOpsMode) => void
886
907
  modeCounts: Record<GitOpsMode, number>
908
+ modeCountsPartial: Record<GitOpsMode, boolean>
887
909
  syncCounts: Map<string, number>
888
910
  syncFilters: Set<string>
889
911
  onToggleSync: (value: string) => void
@@ -949,7 +971,7 @@ function GitOpsFilterSidebar({
949
971
  }`}
950
972
  >
951
973
  <div className="font-medium">{modeLabel(item)}</div>
952
- <div className={mode === item ? 'text-white/70' : 'text-theme-text-tertiary'}>{modeCounts[item]}</div>
974
+ <div className={mode === item ? 'text-white/70' : 'text-theme-text-tertiary'}>{modeCountsPartial[item] ? '≥' : ''}{modeCounts[item]}</div>
953
975
  </button>
954
976
  ))}
955
977
  </div>
@@ -1882,6 +1904,13 @@ function countMap(values: string[]) {
1882
1904
  return counts
1883
1905
  }
1884
1906
 
1907
+ function noApplicationsMessage(hasRows: boolean, coverageStatus: GitOpsTableViewProps['coverageStatus'], namespaceFiltered: boolean): string {
1908
+ if (hasRows) return 'No applications match the current filters.'
1909
+ if (coverageStatus === 'complete') return 'No applications found in the requested scope.'
1910
+ if (coverageStatus === 'partial') return 'No applications found in the namespaces Radar could read.'
1911
+ return namespaceFiltered ? 'No applications match the current filters.' : 'No applications found.'
1912
+ }
1913
+
1885
1914
  function countLabels(rows: GitOpsRow[]) {
1886
1915
  const counts = new Map<string, number>()
1887
1916
  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
+ })
@@ -1,6 +1,7 @@
1
1
  import { argoApplicationSetConditionsToGitOpsStatus, argoStatusToGitOpsStatus, fluxConditionsToGitOpsStatus, type FluxCondition, type GitOpsStatus } from '../../types/gitops'
2
2
  import type { GitOpsResourceTree } from '../../types/gitops-tree'
3
3
  import { formatCompactAge } from '../../utils/format'
4
+ import { apiVersionToGroup } from '../../utils/navigation'
4
5
 
5
6
  // =============================================================================
6
7
  // Shared helpers used by both per-cluster Radar's GitOps detail view (OSS
@@ -92,12 +93,31 @@ export function describeGitOpsTerminating(summary?: {
92
93
  }
93
94
  }
94
95
 
96
+ const FLUX_KIND_GROUPS: Readonly<Record<string, string | undefined>> = {
97
+ gitrepositories: 'source.toolkit.fluxcd.io',
98
+ ocirepositories: 'source.toolkit.fluxcd.io',
99
+ helmrepositories: 'source.toolkit.fluxcd.io',
100
+ helmcharts: 'source.toolkit.fluxcd.io',
101
+ buckets: 'source.toolkit.fluxcd.io',
102
+ kustomizations: 'kustomize.toolkit.fluxcd.io',
103
+ helmreleases: 'helm.toolkit.fluxcd.io',
104
+ alerts: 'notification.toolkit.fluxcd.io',
105
+ }
106
+ const ARGO_CD_KINDS = new Set(['applications', 'applicationsets', 'appprojects'])
107
+
108
+
95
109
  // getGitOpsResourceStatus dispatches sync/health extraction by kind. Argo
96
110
  // Applications read from .status (rich operationState + sync state); Argo
97
111
  // ApplicationSets read their own generator conditions; Flux kinds read
98
112
  // .status.conditions + .spec.suspend. Returns null if the resource has
99
113
  // no recognizable status (e.g. just-created, status not yet populated).
100
114
  export function getGitOpsResourceStatus(kind: string, resource: any): GitOpsStatus | null {
115
+ // Existing callers also pass status-only objects with a controller-specific
116
+ // kind. A loaded API identity, when present, must agree with that kind.
117
+ const group = resource?.apiVersion ? apiVersionToGroup(resource.apiVersion)
118
+ : ARGO_CD_KINDS.has(kind) ? 'argoproj.io' : FLUX_KIND_GROUPS[kind]
119
+ const tool = getGitOpsTool(kind, group)
120
+ if (!tool) return null
101
121
  if (kind === 'applications') {
102
122
  return argoStatusToGitOpsStatus(resource?.status ?? {})
103
123
  }
@@ -110,6 +130,7 @@ export function getGitOpsResourceStatus(kind: string, resource: any): GitOpsStat
110
130
  (resource?.status?.conditions ?? []) as FluxCondition[]
111
131
  )
112
132
  }
133
+ if (tool === 'argo') return null
113
134
  const conditions = (resource?.status?.conditions ?? []) as FluxCondition[]
114
135
  return fluxConditionsToGitOpsStatus(conditions, resource?.spec?.suspend === true, {
115
136
  generation: resource?.metadata?.generation,
@@ -117,12 +138,22 @@ export function getGitOpsResourceStatus(kind: string, resource: any): GitOpsStat
117
138
  })
118
139
  }
119
140
 
120
- // getGitOpsTool routes a kind+group to 'argo' or 'flux'. Same shape used
121
- // by both detail pages + the fleet list normalizers so a kustomization
122
- // always reads as 'flux' and an Argo Application always as 'argo'.
123
- export function getGitOpsTool(kind: string, group?: string): 'argo' | 'flux' {
124
- if (group === 'argoproj.io' || kind === 'applications' || kind === 'applicationsets' || kind === 'appprojects') return 'argo'
125
- return 'flux'
141
+
142
+ // getGitOpsTool decides the controller family from the API group, the same
143
+ // rule the backend applies (pkg/gitops ToolFor). The kind alone is not
144
+ // identity: SIG Apps (app.k8s.io) defines an Application too, and argoproj.io
145
+ // is shared with Argo Rollouts and Workflows. null means the object is not a
146
+ // supported GitOps resource and has no GitOps view or operations.
147
+ export function getGitOpsTool(kind: string, group: string | undefined): 'argo' | 'flux' | null {
148
+ if (group === 'argoproj.io') return ARGO_CD_KINDS.has(kind) ? 'argo' : null
149
+ if (group && FLUX_KIND_GROUPS[kind] === group) return 'flux'
150
+ return null
151
+ }
152
+
153
+ // gitOpsDetailGroup is the API group a detail page acts on: the fetched
154
+ // object's own apiVersion once it has loaded, the route's group before that.
155
+ export function gitOpsDetailGroup(routeGroup: string, resource: { apiVersion?: string } | null | undefined): string {
156
+ return resource?.apiVersion ? apiVersionToGroup(resource.apiVersion) : routeGroup
126
157
  }
127
158
 
128
159
  // isDestinationRef reports whether a resource named on a remote GitOps
@@ -41,6 +41,7 @@ export {
41
41
  describeGitOpsTerminating,
42
42
  getGitOpsResourceStatus,
43
43
  getGitOpsTool,
44
+ gitOpsDetailGroup,
44
45
  isDestinationRef,
45
46
  } from './detail-helpers'
46
47
  export { SyncOptionsDialog } from './SyncOptionsDialog'
@@ -288,13 +288,16 @@ export function IssueRow({
288
288
  onToggle();
289
289
  }
290
290
  }}
291
- className={`group @container/issue flex cursor-pointer items-center gap-3 border-l-[3px] py-3 pl-3 pr-4 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-radar-accent,var(--accent))]/40 ${open ? ISSUE_SEVERITY_HEADER_BAND_CLASS[severity] : ISSUE_SEVERITY_RAIL_CLASS[severity]}`}
291
+ className={`group @container/issue flex cursor-pointer items-center gap-3 phone:flex-wrap phone:gap-y-2 border-l-[3px] py-3 pl-3 pr-4 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-radar-accent,var(--accent))]/40 ${open ? ISSUE_SEVERITY_HEADER_BAND_CLASS[severity] : ISSUE_SEVERITY_RAIL_CLASS[severity]}`}
292
292
  >
293
293
  <SeverityIcon className={`h-[18px] w-[18px] shrink-0 ${ISSUE_SEVERITY_TEXT_CLASS[severity]}`} aria-hidden />
294
294
 
295
- <div className="flex min-w-0 flex-1 flex-col gap-1">
296
- <div className="flex min-w-0 items-baseline gap-2">
297
- <span className="min-w-0 truncate text-sm font-medium text-theme-text-primary">{issueTitle(issue)}</span>
295
+ {/* Phones: the title takes its own line with the badge and reason
296
+ under it, and the severity, actions and chevron get a last line
297
+ (the right cluster below wraps under the text). */}
298
+ <div className="flex min-w-0 flex-1 flex-col gap-1 phone:basis-[calc(100%-30px)]">
299
+ <div className="flex min-w-0 items-baseline gap-2 phone:flex-wrap phone:gap-y-1">
300
+ <span className="min-w-0 truncate text-sm font-medium text-theme-text-primary phone:basis-full">{issueTitle(issue)}</span>
298
301
  <span className={`shrink-0 self-center ${groupBadgeClass(issue.category_group)}`}>{groupLabel(issue.category_group)}</span>
299
302
  {renderBadges?.(slotCtx)}
300
303
  {/* The detector reason rides the title row while COLLAPSED so the
@@ -351,15 +354,17 @@ export function IssueRow({
351
354
  {/* Narrow container: the meta chips drop to their own line here rather
352
355
  than stealing width from the title on the row above. Hidden once the
353
356
  row is wide enough to hold them inline in the right cluster. */}
354
- {metaChips('flex @2xl/issue:hidden')}
357
+ {metaChips('flex @2xl/issue:hidden phone:hidden')}
355
358
  </div>
356
359
 
357
360
  {/* Right cluster — the always-visible affordances (per-row action +
358
361
  chevron) plus, on a wide container, the inline meta chips. */}
359
- <div className="flex shrink-0 items-center gap-3">
360
- {metaChips('hidden @2xl/issue:flex')}
361
- {renderActions?.(slotCtx)}
362
- <CollapseChevron open={open} className="h-4 w-4" />
362
+ <div className="flex shrink-0 items-center gap-3 phone:basis-full phone:pl-[30px]">
363
+ {metaChips('hidden @2xl/issue:flex phone:flex')}
364
+ <div className="contents phone:ml-auto phone:flex phone:items-center phone:gap-3">
365
+ {renderActions?.(slotCtx)}
366
+ <CollapseChevron open={open} className="h-4 w-4" />
367
+ </div>
363
368
  </div>
364
369
  </div>
365
370