@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.
- package/README.md +13 -0
- package/package.json +1 -1
- package/src/components/compare/CompareResourcePicker.tsx +18 -4
- package/src/components/gitops/GitOpsDetailLayout.test.tsx +64 -0
- package/src/components/gitops/GitOpsDetailLayout.tsx +22 -15
- package/src/components/gitops/GitOpsTableView.coverage.test.tsx +27 -0
- package/src/components/gitops/GitOpsTableView.tsx +33 -4
- package/src/components/gitops/detail-helpers.test.ts +58 -13
- package/src/components/gitops/detail-helpers.ts +37 -6
- package/src/components/gitops/index.ts +1 -0
- package/src/components/issues/IssuesView.tsx +14 -9
- package/src/components/logs/LogsViewer.test.tsx +104 -59
- package/src/components/logs/LogsViewer.tsx +28 -8
- package/src/components/namespace-switcher/NamespacePicker.test.tsx +236 -2
- package/src/components/namespace-switcher/NamespacePicker.tsx +42 -8
- package/src/components/resources/ResourcesSidebar.test.tsx +49 -0
- package/src/components/resources/ResourcesSidebar.tsx +36 -21
- package/src/components/resources/ResourcesView.selected-kind.test.ts +14 -0
- package/src/components/resources/ResourcesView.trivy-search.test.tsx +59 -0
- package/src/components/resources/ResourcesView.tsx +106 -19
- package/src/components/resources/renderers/PodRenderer.test.tsx +115 -0
- package/src/components/resources/renderers/PodRenderer.tsx +32 -9
- package/src/components/resources/resource-utils-trivy.test.ts +87 -0
- package/src/components/resources/resource-utils-trivy.ts +122 -0
- package/src/components/shared/DetailShell.tsx +9 -7
- package/src/components/shared/ManagedByChip.test.tsx +22 -0
- package/src/components/shared/ManagedByChip.tsx +24 -4
- package/src/components/shared/ResourceActionsBar.identity.test.tsx +64 -0
- package/src/components/shared/ResourceActionsBar.tsx +9 -3
- package/src/components/shared/ResourceRendererDispatch.test.tsx +49 -0
- package/src/components/shared/ResourceRendererDispatch.tsx +46 -31
- package/src/components/timeline/TimelineList.tsx +5 -1
- package/src/components/ui/FetchResult.test.tsx +19 -2
- package/src/components/ui/FetchResult.tsx +13 -1
- package/src/components/ui/MultiSelectPicker.test.tsx +20 -0
- package/src/components/ui/MultiSelectPicker.tsx +20 -2
- package/src/components/ui/PageHeader.tsx +6 -4
- package/src/components/ui/Toast.tsx +33 -26
- package/src/components/ui/drawer-components.test.tsx +253 -0
- package/src/components/ui/drawer-components.tsx +315 -11
- package/src/components/ui/drawer-older-replicasets.test.tsx +45 -0
- package/src/components/workload/WorkloadView.relationships.test.tsx +33 -0
- package/src/components/workload/WorkloadView.timelineFocus.test.tsx +96 -0
- package/src/components/workload/WorkloadView.tsx +54 -6
- package/src/hooks/index.ts +2 -0
- package/src/hooks/useMediaQuery.ts +21 -0
- package/src/hooks/usePhoneLayout.ts +12 -0
- package/src/theme/tailwind-theme.css +5 -0
- package/src/types/core.ts +66 -2
- package/src/types/gitops-flux-inventory.test.ts +24 -0
- package/src/types/gitops.ts +5 -5
- package/src/types/index.ts +3 -0
- package/src/types/read-coverage.ts +65 -0
- package/src/types/resource-coverage.ts +40 -0
- package/src/utils/format.test.ts +7 -0
- package/src/utils/format.ts +6 -1
- package/src/utils/gitops-owner.test.ts +42 -1
- package/src/utils/gitops-owner.ts +22 -0
- package/src/utils/index.ts +2 -0
- package/src/utils/read-actions.test.ts +22 -0
- package/src/utils/read-actions.ts +56 -0
- package/src/utils/resource-coverage.test.ts +121 -0
- 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,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
|
|
150
|
-
?
|
|
151
|
-
:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
338
|
-
|
|
339
|
-
|
|
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
|
-
|
|
354
|
-
<
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
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
|
|
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
|
|
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
|
-
//
|
|
79
|
-
test('Argo
|
|
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
|
-
|
|
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
|
-
|
|
96
|
-
|
|
97
|
-
expect(
|
|
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
|
-
|
|
121
|
-
//
|
|
122
|
-
//
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
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
|
|
@@ -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
|
-
|
|
296
|
-
|
|
297
|
-
|
|
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
|
-
|
|
362
|
-
|
|
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
|
|