@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
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// What resolves each read code, mirroring Radar's pkg/readreason. A test
|
|
2
|
+
// checks this table against pkg/readreason/testdata/actions.json.
|
|
3
|
+
export type ReadAction =
|
|
4
|
+
| 'retry'
|
|
5
|
+
| 'wait'
|
|
6
|
+
| 'check_target'
|
|
7
|
+
| 'change_request'
|
|
8
|
+
| 'user_access'
|
|
9
|
+
| 'radar_config'
|
|
10
|
+
| 'none'
|
|
11
|
+
|
|
12
|
+
export const READ_ACTIONS: Record<string, ReadAction> = {
|
|
13
|
+
cluster_connecting: 'retry',
|
|
14
|
+
cluster_disconnected: 'radar_config',
|
|
15
|
+
connection_changed: 'check_target',
|
|
16
|
+
filter_eval_failed: 'change_request',
|
|
17
|
+
findings_scan_incomplete: 'change_request',
|
|
18
|
+
kind_not_watchable: 'change_request',
|
|
19
|
+
kind_not_searchable: 'change_request',
|
|
20
|
+
kind_sync_pending: 'retry',
|
|
21
|
+
kind_sync_timed_out: 'wait',
|
|
22
|
+
kind_unknown: 'change_request',
|
|
23
|
+
list_too_large: 'change_request',
|
|
24
|
+
namespace_not_found: 'change_request',
|
|
25
|
+
namespace_required: 'change_request',
|
|
26
|
+
object_not_found: 'none',
|
|
27
|
+
outside_namespace_pin: 'radar_config',
|
|
28
|
+
radar_credentials_rejected: 'radar_config',
|
|
29
|
+
radar_forbidden: 'radar_config',
|
|
30
|
+
radar_kind_not_collected: 'radar_config',
|
|
31
|
+
radar_namespace_not_collected: 'radar_config',
|
|
32
|
+
read_error: 'retry',
|
|
33
|
+
user_forbidden: 'user_access',
|
|
34
|
+
user_namespace_not_visible: 'change_request',
|
|
35
|
+
user_no_namespace_access: 'user_access',
|
|
36
|
+
user_permission_check_failed: 'retry',
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Spellings Radar releases sent before the shared vocabulary, for codes that
|
|
40
|
+
// kept their meaning: a newer UI reading an older Radar reads them as the code
|
|
41
|
+
// they became.
|
|
42
|
+
const LEGACY_READ_CODES: Record<string, string> = {
|
|
43
|
+
kind_sync_failed: 'kind_sync_timed_out',
|
|
44
|
+
rbac_denied: 'user_forbidden',
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** The current spelling of a read code, translating one an older Radar sent. */
|
|
48
|
+
export function canonicalReadCode(code: unknown): unknown {
|
|
49
|
+
return typeof code === 'string' ? (LEGACY_READ_CODES[code] ?? code) : code
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** The action for a code; an unknown code (from a newer Radar) is treated as needing a change. */
|
|
53
|
+
export function readActionFor(code: unknown): ReadAction | undefined {
|
|
54
|
+
const current = canonicalReadCode(code)
|
|
55
|
+
return typeof current === 'string' ? (READ_ACTIONS[current] ?? 'change_request') : undefined
|
|
56
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import type { ResourceReadCoverage } from '../types/resource-coverage'
|
|
3
|
+
import { boundedCountLabel, isEmptyCount, isLowerBound, resourceCountHint, resourceCountLabel, resourceCoverageNotice, resourceEmptyMessage, resourceNarrowedNotice } from './resource-coverage'
|
|
4
|
+
|
|
5
|
+
const partial: ResourceReadCoverage = {
|
|
6
|
+
status: 'partial', scope: { type: 'all_namespaces' },
|
|
7
|
+
read: { type: 'namespaces', namespaces: { count: 1000 } },
|
|
8
|
+
gaps: [{ code: 'radar_namespace_not_collected' }],
|
|
9
|
+
}
|
|
10
|
+
const complete: ResourceReadCoverage = { status: 'complete', gaps: [], scope: { type: 'all_namespaces' }, read: { type: 'all_namespaces' } }
|
|
11
|
+
const collected: ResourceReadCoverage = {
|
|
12
|
+
status: 'complete', gaps: [], narrowedBy: ['namespace_collection'],
|
|
13
|
+
scope: { type: 'namespaces', namespaces: { names: ['alpha', 'beta', 'gamma', 'ns-04', 'ns-05', 'ns-06', 'ns-07', 'ns-08', 'ns-09', 'ns-10', 'ns-11', 'ns-12'], count: 12 } },
|
|
14
|
+
read: { type: 'namespaces', namespaces: { names: ['alpha', 'beta', 'gamma', 'ns-04', 'ns-05', 'ns-06', 'ns-07', 'ns-08', 'ns-09', 'ns-10', 'ns-11', 'ns-12'], count: 12 } },
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
describe('partial coverage notice', () => {
|
|
18
|
+
it('counts a namespace set too large to name', () => {
|
|
19
|
+
expect(resourceCoverageNotice(partial)).toContain('Radar read 1,000 namespaces;')
|
|
20
|
+
})
|
|
21
|
+
it('previews a named set', () => {
|
|
22
|
+
expect(resourceCoverageNotice({ ...partial, read: { type: 'namespaces', namespaces: { names: ['alpha', 'beta', 'gamma', 'delta'], count: 4 } } })).toContain('alpha, beta, gamma and 1 more')
|
|
23
|
+
})
|
|
24
|
+
it('explains loading and collector denial without calling either absence', () => {
|
|
25
|
+
expect(resourceCoverageNotice({ ...partial, gaps: [{ code: 'kind_sync_pending' }] })).toContain('still loading')
|
|
26
|
+
expect(resourceCoverageNotice({ ...partial, gaps: [{ code: 'radar_forbidden' }] })).toContain("Radar's collector could not read")
|
|
27
|
+
expect(resourceCoverageNotice(partial)).toContain("Radar doesn't collect this kind")
|
|
28
|
+
expect(resourceCoverageNotice({ ...partial, gaps: [{ code: 'radar_namespace_not_collected' }, { code: 'kind_sync_pending' }] })).toContain('still loading')
|
|
29
|
+
})
|
|
30
|
+
it('names small gaps and reserves "requested" for unnamed explicit scope', () => {
|
|
31
|
+
expect(resourceCoverageNotice(partial)).toBe("Partial results. Radar read 1,000 namespaces; Radar doesn't collect this kind in some namespaces.")
|
|
32
|
+
const named = { ...partial, scope: { type: 'namespaces' as const, namespaces: { names: ['alpha', 'zeta'], count: 2 } }, read: { type: 'namespaces' as const, namespaces: { names: ['alpha'], count: 1 } }, gaps: [{ code: 'radar_namespace_not_collected', namespaces: { names: ['zeta'], count: 1 } }] }
|
|
33
|
+
expect(resourceCoverageNotice(named, 'Pods')).toBe("Partial results. Radar read alpha; Radar doesn't collect Pods in zeta.")
|
|
34
|
+
expect(resourceCoverageNotice({ ...named, gaps: [{ code: 'radar_namespace_not_collected', namespaces: { count: 1 } }] })).toContain('some requested namespaces')
|
|
35
|
+
expect(resourceCoverageNotice({ ...named, narrowedBy: ['user_access'] })).not.toContain('requested')
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
it('does not warn on a complete or unknown answer', () => {
|
|
39
|
+
expect(resourceCoverageNotice(complete)).toBeUndefined()
|
|
40
|
+
expect(resourceCoverageNotice(collected)).toBeUndefined()
|
|
41
|
+
expect(resourceCoverageNotice({ status: 'unknown' })).toBeUndefined()
|
|
42
|
+
expect(resourceCoverageNotice(undefined)).toBeUndefined()
|
|
43
|
+
})
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
describe('narrowed coverage note', () => {
|
|
47
|
+
it('names the namespaces Radar collects, bounded', () => {
|
|
48
|
+
expect(resourceNarrowedNotice(collected)).toBe('Radar collects this kind only in 12 namespaces: alpha, beta, gamma and 9 more.')
|
|
49
|
+
expect(resourceNarrowedNotice({ ...collected, scope: { type: 'namespaces', namespaces: { names: ['alpha'], count: 1 } } })).toBe('Radar collects this kind only in 1 namespace: alpha.')
|
|
50
|
+
})
|
|
51
|
+
it('is shown only for collection, which the namespace picker does not show', () => {
|
|
52
|
+
for (const narrowing of ['user_access', 'namespace_pin']) {
|
|
53
|
+
expect(resourceNarrowedNotice({ ...collected, narrowedBy: [narrowing] })).toBeUndefined()
|
|
54
|
+
}
|
|
55
|
+
expect(resourceNarrowedNotice({ ...collected, narrowedBy: ['user_access', 'namespace_collection'] })).toBe('Radar collects this kind only in 12 of your namespaces: alpha, beta, gamma and 9 more.')
|
|
56
|
+
expect(resourceNarrowedNotice(complete)).toBeUndefined()
|
|
57
|
+
expect(resourceNarrowedNotice({ ...collected, status: 'partial' })).toBeUndefined()
|
|
58
|
+
expect(resourceNarrowedNotice({ status: 'unknown' })).toBeUndefined()
|
|
59
|
+
})
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
describe('empty lists and counts', () => {
|
|
63
|
+
it('distinguishes complete, partial, unknown and filtered-empty results', () => {
|
|
64
|
+
expect(resourceEmptyMessage('Pod', complete, false)).toBe('No Pod found in the requested scope')
|
|
65
|
+
expect(resourceEmptyMessage('Pod', partial, false)).toContain('namespaces Radar could read')
|
|
66
|
+
expect(resourceEmptyMessage('Pod', partial, true)).toContain('match the current filters')
|
|
67
|
+
})
|
|
68
|
+
it('keeps an older Radar\'s empty list as it read before coverage', () => {
|
|
69
|
+
expect(resourceEmptyMessage('Pod', { status: 'unknown' }, false)).toBe('No Pod found')
|
|
70
|
+
expect(resourceEmptyMessage('Pod', undefined, false)).toBe('No Pod found')
|
|
71
|
+
})
|
|
72
|
+
it('bounds partial counts and never treats a partial zero as absence', () => {
|
|
73
|
+
expect(resourceCountLabel(23, partial)).toBe('≥23')
|
|
74
|
+
expect(resourceCountLabel(0, partial)).toBe('–')
|
|
75
|
+
expect(isEmptyCount(0, partial)).toBe(false)
|
|
76
|
+
})
|
|
77
|
+
it('treats a partial or collection-narrowed read as a lower bound for the cluster', () => {
|
|
78
|
+
expect(isLowerBound(partial)).toBe(true)
|
|
79
|
+
expect(isLowerBound(collected)).toBe(true)
|
|
80
|
+
expect(isLowerBound(complete)).toBe(false)
|
|
81
|
+
expect(isLowerBound({ status: 'unknown' })).toBe(false)
|
|
82
|
+
expect(isLowerBound(undefined)).toBe(false)
|
|
83
|
+
})
|
|
84
|
+
it("never hides a kind whose zero covers only the namespaces Radar collects", () => {
|
|
85
|
+
expect(isEmptyCount(0, collected)).toBe(false)
|
|
86
|
+
expect(resourceEmptyMessage('Pod', collected, false)).toBe('No Pod found in the namespaces Radar collects')
|
|
87
|
+
})
|
|
88
|
+
it('bounds a collection-narrowed count: Radar did not count the namespaces it does not collect', () => {
|
|
89
|
+
expect(resourceCountLabel(23, collected)).toBe('≥23')
|
|
90
|
+
expect(resourceCountLabel(0, collected)).toBe('–')
|
|
91
|
+
expect(resourceCountHint(collected)).toBe('Radar collects this kind only in some namespaces; more may exist elsewhere.')
|
|
92
|
+
expect(resourceCountHint(partial)).toBe('Count in the namespaces Radar could read; more may exist outside them.')
|
|
93
|
+
expect(resourceCountHint(complete)).toBeUndefined()
|
|
94
|
+
})
|
|
95
|
+
it('shows complete and unknown counts plainly, and hides their zeros', () => {
|
|
96
|
+
expect(resourceCountLabel(23, complete)).toBe('23')
|
|
97
|
+
expect(resourceCountLabel(23, { status: 'unknown' })).toBe('23')
|
|
98
|
+
expect(resourceCountLabel(0, { status: 'unknown' })).toBe('0')
|
|
99
|
+
expect(resourceCountLabel(23, undefined)).toBe('23')
|
|
100
|
+
expect(isEmptyCount(0, complete)).toBe(true)
|
|
101
|
+
expect(isEmptyCount(0, { status: 'unknown' })).toBe(true)
|
|
102
|
+
expect(isEmptyCount(0, undefined)).toBe(true)
|
|
103
|
+
expect(isEmptyCount(3, complete)).toBe(false)
|
|
104
|
+
expect(isEmptyCount(null, complete)).toBe(false)
|
|
105
|
+
})
|
|
106
|
+
it('formats large counts in either case', () => {
|
|
107
|
+
expect(boundedCountLabel(12345, true)).toBe(`≥${(12345).toLocaleString()}`)
|
|
108
|
+
expect(boundedCountLabel(12345, false)).toBe((12345).toLocaleString())
|
|
109
|
+
})
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
it('names a small excluded namespace set with its kind', () => {
|
|
113
|
+
const coverage: ResourceReadCoverage = { status: 'partial', scope: { type: 'all_namespaces' }, read: { type: 'namespaces', namespaces: { count: 1, names: ['read-a'] } }, gaps: [{ code: 'user_forbidden', namespaces: { count: 1, names: ['read-b'] } }] }
|
|
114
|
+
expect(resourceCoverageNotice(coverage, 'Secrets')).toBe('Partial results. Radar read read-a; your permissions exclude Secrets in read-b.')
|
|
115
|
+
coverage.gaps[0].namespaces = { count: 3, names: ['b', 'c', 'd'] }
|
|
116
|
+
expect(resourceCoverageNotice(coverage, 'Secrets')).toContain('Secrets in b, c, d.')
|
|
117
|
+
coverage.gaps[0].namespaces = { count: 4, names: ['b', 'c', 'd', 'e'] }
|
|
118
|
+
expect(resourceCoverageNotice(coverage, 'Secrets')).toContain('your permissions exclude some namespaces.')
|
|
119
|
+
coverage.gaps[0].namespaces = { count: 2 }
|
|
120
|
+
expect(resourceCoverageNotice(coverage, 'Secrets')).toContain('your permissions exclude some namespaces.')
|
|
121
|
+
})
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import type { ReadNameSet } from '../types/read-coverage'
|
|
2
|
+
import type { ResourceCoverage } from '../types/resource-coverage'
|
|
3
|
+
import { pluralNoun, pluralize } from './pluralize'
|
|
4
|
+
|
|
5
|
+
// Only a partial answer is qualified. Unknown coverage (an older Radar, or a
|
|
6
|
+
// host that passes none) keeps the presentation that predates coverage: plain
|
|
7
|
+
// counts, empty kinds hidden, and no notice.
|
|
8
|
+
|
|
9
|
+
// gapPhrases says why the rest of the scope was not read, most actionable first.
|
|
10
|
+
const gapPhrases: [string, (where: string) => string][] = [
|
|
11
|
+
['kind_sync_pending', where => `some ${where} are still loading`],
|
|
12
|
+
['user_permission_check_failed', where => `access to some ${where} could not be verified`],
|
|
13
|
+
['user_forbidden', where => `your permissions exclude some ${where}`],
|
|
14
|
+
['user_namespace_not_visible', where => `some ${where} aren't visible to you`],
|
|
15
|
+
['outside_namespace_pin', () => 'Radar is pinned to another namespace'],
|
|
16
|
+
['radar_credentials_rejected', where => `Kubernetes rejected Radar's credentials for some ${where}`],
|
|
17
|
+
['radar_forbidden', where => `Radar's collector could not read some ${where}`],
|
|
18
|
+
['kind_sync_timed_out', where => `some ${where} haven't finished loading`],
|
|
19
|
+
['radar_namespace_not_collected', where => `Radar doesn't collect this kind in some ${where}`],
|
|
20
|
+
]
|
|
21
|
+
|
|
22
|
+
/** The first few names of a set and how many more there are; empty when the set is too large to name. */
|
|
23
|
+
function namespacePreview({ names, count }: ReadNameSet): string {
|
|
24
|
+
if (!names?.length) return ''
|
|
25
|
+
const extra = count - Math.min(names.length, 3)
|
|
26
|
+
return `${names.slice(0, 3).join(', ')}${extra > 0 ? ` and ${extra} more` : ''}`
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function resourceCoverageNotice(coverage: ResourceCoverage | undefined, kind = "resources"): string | undefined {
|
|
30
|
+
if (coverage?.status !== 'partial') return undefined
|
|
31
|
+
const read = coverage.read
|
|
32
|
+
if (read?.type !== 'namespaces' || !read.namespaces) return 'Partial results. Radar could not read the whole requested scope.'
|
|
33
|
+
const { count } = read.namespaces
|
|
34
|
+
const scope = namespacePreview(read.namespaces) || `${count.toLocaleString()} ${pluralNoun(count, 'namespace')}`
|
|
35
|
+
// A broad request named no namespaces, so none of them were "requested".
|
|
36
|
+
const where = coverage.scope.type === 'namespaces' && !coverage.narrowedBy?.length ? 'requested namespaces' : 'namespaces'
|
|
37
|
+
const codes = new Set(coverage.gaps.map(gap => gap.code))
|
|
38
|
+
const phrase = gapPhrases.find(([code]) => codes.has(code))
|
|
39
|
+
const affected = coverage.gaps.filter(gap => gap.code === phrase?.[0]).map(gap => gap.namespaces)
|
|
40
|
+
const names = [...new Set(affected.flatMap(set => set?.names ?? []))]
|
|
41
|
+
const canName = affected.length > 0 && names.length > 0 && names.length <= 3 && affected.every(set => set && !set.extent && set.names?.length === set.count)
|
|
42
|
+
const namedScope = `${kind} in ${names.join(', ')}`
|
|
43
|
+
const namedPhrases: Record<string, string> = {
|
|
44
|
+
kind_sync_pending: `${namedScope} are still loading`,
|
|
45
|
+
user_permission_check_failed: `access to ${namedScope} could not be verified`,
|
|
46
|
+
user_forbidden: `your permissions exclude ${namedScope}`,
|
|
47
|
+
user_namespace_not_visible: `${names.join(', ')} ${names.length === 1 ? "isn't" : "aren't"} visible to you`,
|
|
48
|
+
radar_credentials_rejected: `Kubernetes rejected Radar's credentials for ${namedScope}`,
|
|
49
|
+
radar_forbidden: `Radar's collector could not read ${namedScope}`,
|
|
50
|
+
kind_sync_timed_out: `${namedScope} haven't finished loading`,
|
|
51
|
+
radar_namespace_not_collected: `Radar doesn't collect ${namedScope}`,
|
|
52
|
+
}
|
|
53
|
+
const gap = (canName && phrase && namedPhrases[phrase[0]]) || phrase?.[1](where) || `other ${where} were not read`
|
|
54
|
+
return `Partial results. Radar read ${scope}; ${gap}.`
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* A complete answer whose scope Radar's own collection narrowed. The other
|
|
59
|
+
* narrowings (the caller's access and Radar's pin) already show
|
|
60
|
+
* in the namespace picker, so they get no note.
|
|
61
|
+
*/
|
|
62
|
+
export function resourceNarrowedNotice(coverage: ResourceCoverage | undefined): string | undefined {
|
|
63
|
+
if (coverage?.status !== 'complete' || !coverage.narrowedBy?.includes('namespace_collection')) return undefined
|
|
64
|
+
const namespaces = coverage.scope.namespaces
|
|
65
|
+
if (!namespaces) return 'Radar collects this kind only in some namespaces.'
|
|
66
|
+
const preview = namespacePreview(namespaces)
|
|
67
|
+
// With the caller's access also narrowing it, the scope is the part of their
|
|
68
|
+
// namespaces Radar collects, not everything Radar collects.
|
|
69
|
+
const where = coverage.narrowedBy.includes('user_access')
|
|
70
|
+
? `${namespaces.count.toLocaleString()} of your namespaces`
|
|
71
|
+
: pluralize(namespaces.count, 'namespace')
|
|
72
|
+
return `Radar collects this kind only in ${where}${preview ? `: ${preview}` : ''}.`
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function resourceEmptyMessage(kind: string, coverage: ResourceCoverage | undefined, hasRows: boolean): string {
|
|
76
|
+
if (hasRows) return `No ${kind} match the current filters`
|
|
77
|
+
if (coverage?.status === 'complete' && coverage.narrowedBy?.includes('namespace_collection')) return `No ${kind} found in the namespaces Radar collects`
|
|
78
|
+
if (coverage?.status === 'complete') return `No ${kind} found in the requested scope`
|
|
79
|
+
if (coverage?.status === 'partial') return `No ${kind} found in the namespaces Radar could read`
|
|
80
|
+
return `No ${kind} found`
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Whether a read's numbers are a lower bound for the cluster: it was partial,
|
|
85
|
+
* or Radar's collection narrowed it to the namespaces it collects. Views show
|
|
86
|
+
* cluster totals, which a read of only the collected namespaces can't
|
|
87
|
+
* establish.
|
|
88
|
+
*/
|
|
89
|
+
export function isLowerBound(coverage: ResourceCoverage | undefined): boolean {
|
|
90
|
+
return coverage?.status === 'partial' || (coverage?.status === 'complete' && coverage.narrowedBy?.includes('namespace_collection') === true)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* A zero that hides its kind as empty. A lower-bound zero proves nothing
|
|
95
|
+
* about the namespaces Radar couldn't read or doesn't collect.
|
|
96
|
+
*/
|
|
97
|
+
export function isEmptyCount(count: number | null | undefined, coverage: ResourceCoverage | undefined): boolean {
|
|
98
|
+
return count === 0 && !isLowerBound(coverage)
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** A lower-bound count reads "≥N", and a lower-bound zero is no count at all. */
|
|
102
|
+
export function boundedCountLabel(count: number, lowerBound: boolean): string {
|
|
103
|
+
if (!lowerBound) return count.toLocaleString()
|
|
104
|
+
return count === 0 ? '–' : `≥${count.toLocaleString()}`
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** One kind's count: "≥N" when the read is a lower bound for the cluster (see isLowerBound). */
|
|
108
|
+
export function resourceCountLabel(count: number, coverage: ResourceCoverage | undefined): string {
|
|
109
|
+
return boundedCountLabel(count, isLowerBound(coverage))
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Why a kind's count is a lower bound, or undefined when it is exact. */
|
|
113
|
+
export function resourceCountHint(coverage: ResourceCoverage | undefined): string | undefined {
|
|
114
|
+
if (coverage?.status === 'partial') return 'Count in the namespaces Radar could read; more may exist outside them.'
|
|
115
|
+
if (isLowerBound(coverage)) return 'Radar collects this kind only in some namespaces; more may exist elsewhere.'
|
|
116
|
+
return undefined
|
|
117
|
+
}
|