@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
@@ -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
+ }