@skyhook-io/k8s-ui 1.14.4 → 1.14.6
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/package.json +5 -5
- package/src/components/audit/AuditCard.tsx +18 -7
- package/src/components/audit/AuditCoveragePresentation.test.tsx +45 -0
- package/src/components/charts/AreaChart.test.tsx +173 -0
- package/src/components/charts/AreaChart.tsx +208 -53
- package/src/components/charts/PrometheusChartsView.test.tsx +36 -0
- package/src/components/charts/PrometheusChartsView.tsx +205 -76
- package/src/components/charts/SeriesLegend.tsx +8 -3
- package/src/components/charts/annotations.ts +59 -0
- package/src/components/charts/axis.test.ts +48 -0
- package/src/components/charts/axis.ts +62 -0
- package/src/components/charts/colors.test.ts +27 -0
- package/src/components/charts/colors.ts +23 -0
- package/src/components/charts/format.test.ts +19 -0
- package/src/components/charts/format.ts +21 -1
- package/src/components/charts/index.ts +3 -1
- package/src/components/charts/nearestSample.test.ts +31 -0
- package/src/components/charts/nearestSample.ts +15 -0
- package/src/components/charts/types.ts +12 -0
- package/src/components/checks/ChecksView.tsx +20 -2
- package/src/components/gitops/GitOpsDetailLayout.tsx +13 -0
- package/src/components/gitops/GitOpsHealthSourceNotice.tsx +141 -0
- package/src/components/gitops/health-provenance.test.ts +54 -0
- package/src/components/gitops/health-provenance.ts +24 -0
- package/src/components/gitops/index.ts +2 -0
- package/src/components/gitops/insights/GitOpsInsightViews.tsx +40 -21
- package/src/components/gitops/tree/GitOpsTreeGraph.tsx +59 -8
- package/src/components/gitops/tree/merge.test.ts +34 -0
- package/src/components/gitops/tree/merge.ts +7 -0
- package/src/components/resources/HPADiagnosisSummary.test.tsx +178 -0
- package/src/components/resources/HPADiagnosisSummary.tsx +218 -0
- package/src/components/resources/ResourcesView.tsx +67 -2
- package/src/components/resources/curated-column-ownership.test.ts +3 -3
- package/src/components/resources/index.ts +7 -0
- package/src/components/resources/renderers/AnalysisTemplateRenderer.tsx +150 -0
- package/src/components/resources/renderers/ConfigMapRenderer.tsx +8 -1
- package/src/components/resources/renderers/ExperimentRenderer.tsx +179 -0
- package/src/components/resources/renderers/HPARenderer.tsx +4 -57
- package/src/components/resources/renderers/LimitRangeRenderer.test.tsx +156 -0
- package/src/components/resources/renderers/LimitRangeRenderer.tsx +277 -0
- package/src/components/resources/renderers/NamespaceRenderer.tsx +24 -1
- package/src/components/resources/renderers/NetworkPolicyDiagram.tsx +9 -16
- package/src/components/resources/renderers/NetworkPolicyRenderer.test.tsx +75 -0
- package/src/components/resources/renderers/NetworkPolicyRenderer.tsx +50 -36
- package/src/components/resources/renderers/PodRenderer.tsx +11 -0
- package/src/components/resources/renderers/ReflectorSection.test.tsx +281 -0
- package/src/components/resources/renderers/ReflectorSection.tsx +296 -0
- package/src/components/resources/renderers/RolloutRenderer.test.tsx +117 -0
- package/src/components/resources/renderers/RolloutRenderer.tsx +219 -56
- package/src/components/resources/renderers/SecretRenderer.tsx +10 -3
- package/src/components/resources/renderers/WorkloadRenderer.tsx +15 -21
- package/src/components/resources/renderers/index.ts +3 -0
- package/src/components/resources/renderers/rollout/CanaryStepTimeline.tsx +199 -0
- package/src/components/resources/renderers/rollout/ReplicaSetProgression.tsx +116 -0
- package/src/components/resources/resource-utils.ts +18 -9
- package/src/components/shared/CreateResourceDialog.tsx +231 -11
- package/src/components/shared/DrainPlanDialog.test.tsx +154 -0
- package/src/components/shared/DrainPlanDialog.tsx +297 -0
- package/src/components/shared/ResourceActionsBar.tsx +53 -26
- package/src/components/shared/ResourceRendererDispatch.test.tsx +45 -0
- package/src/components/shared/ResourceRendererDispatch.tsx +27 -6
- package/src/components/shared/index.ts +4 -0
- package/src/components/topology/K8sResourceNode.test.ts +9 -0
- package/src/components/topology/K8sResourceNode.tsx +56 -5
- package/src/components/ui/Badge.tsx +5 -1
- package/src/components/ui/FetchResult.test.tsx +20 -2
- package/src/components/ui/FetchResult.tsx +33 -3
- package/src/components/ui/FreshnessControl.tsx +8 -2
- package/src/components/ui/index.ts +5 -0
- package/src/components/ui/monacoRuntime.ts +14 -0
- package/src/components/workload/WorkloadView.tsx +2 -2
- package/src/monaco-deep.d.ts +10 -0
- package/src/theme/components.css +16 -0
- package/src/types/core.ts +38 -11
- package/src/types/gitops-insights.ts +23 -0
- package/src/types/gitops-tree.ts +29 -0
- package/src/utils/index.ts +1 -0
- package/src/utils/navigation.test.ts +20 -1
- package/src/utils/navigation.ts +25 -0
- package/src/utils/network-policy.test.ts +42 -0
- package/src/utils/network-policy.ts +59 -0
- package/src/utils/reflector.ts +139 -0
- package/src/utils/yaml-file-import.test.ts +189 -0
- package/src/utils/yaml-file-import.ts +95 -0
- package/src/components/logs/JsonLogLine.tsx +0 -93
|
@@ -13,6 +13,7 @@ export interface GitOpsInsight {
|
|
|
13
13
|
}
|
|
14
14
|
|
|
15
15
|
import type { GitOpsTool } from './gitops'
|
|
16
|
+
import type { GitOpsHealthMode, GitOpsHealthSource } from './gitops-tree'
|
|
16
17
|
|
|
17
18
|
// Closed enums mirroring `pkg/gitops/insights/vocab.go`. Keeping the FE
|
|
18
19
|
// vocabulary in lockstep with the Go side means switches over these fields
|
|
@@ -79,6 +80,19 @@ export interface GitOpsInsightSummary {
|
|
|
79
80
|
// (jqPathExpressions / managedFieldsManagers rules) — the
|
|
80
81
|
// drift panel may surface fields Argo's own UI suppresses.
|
|
81
82
|
ignoredDifferences?: GitOpsIgnoredDifferences
|
|
83
|
+
// Argo roots: where Argo keeps per-resource health. 'appTree' means the
|
|
84
|
+
// controller's verdicts are not in the Application object and any
|
|
85
|
+
// per-resource health shown is Radar's own read.
|
|
86
|
+
resourceHealthMode?: GitOpsHealthMode
|
|
87
|
+
// The Application deploys to another cluster; Radar derives nothing about
|
|
88
|
+
// its resources from here.
|
|
89
|
+
remoteDestination?: boolean
|
|
90
|
+
// Per-resource health came from Argo CD's API server: the verdicts shown
|
|
91
|
+
// are Argo's even though the Application object doesn't carry them.
|
|
92
|
+
resourceHealthFromApi?: boolean
|
|
93
|
+
// The controller's API server was asked and didn't answer usefully — why,
|
|
94
|
+
// in the user's words. Only set when the integration is configured.
|
|
95
|
+
resourceHealthApiError?: string
|
|
82
96
|
}
|
|
83
97
|
|
|
84
98
|
export interface GitOpsIgnoredDifferences {
|
|
@@ -116,6 +130,10 @@ export interface GitOpsIssue {
|
|
|
116
130
|
// a contextual action button. Nil when no automated remedy applies — the
|
|
117
131
|
// `action` string still describes the manual path in that case.
|
|
118
132
|
remediation?: GitOpsRemediation
|
|
133
|
+
// Set on resource-scoped issues: 'controller' when the GitOps controller's
|
|
134
|
+
// own per-resource health produced it, 'radar' when Radar's issues engine
|
|
135
|
+
// did because the controller's verdict wasn't available.
|
|
136
|
+
source?: GitOpsHealthSource
|
|
119
137
|
}
|
|
120
138
|
|
|
121
139
|
export type GitOpsRemediationKind = 'create-namespace'
|
|
@@ -131,6 +149,11 @@ export interface GitOpsChange {
|
|
|
131
149
|
category: GitOpsCategory
|
|
132
150
|
sync?: string
|
|
133
151
|
health?: string
|
|
152
|
+
// Provenance of `health`; see GitOpsTreeNode.healthSource. `message` holds
|
|
153
|
+
// the health message from whichever source produced `health`.
|
|
154
|
+
healthSource?: GitOpsHealthSource
|
|
155
|
+
healthReason?: string
|
|
156
|
+
healthSeverity?: string
|
|
134
157
|
message?: string
|
|
135
158
|
// Per-resource sync failure message (Argo's status.resources[].syncResult).
|
|
136
159
|
// Distinct from `message` (live health). Empty when sync succeeded.
|
package/src/types/gitops-tree.ts
CHANGED
|
@@ -24,6 +24,16 @@ export interface GitOpsTreeNode {
|
|
|
24
24
|
tool: GitOpsTreeTool
|
|
25
25
|
sync?: SyncStatus
|
|
26
26
|
health?: GitOpsHealthStatus
|
|
27
|
+
// Who assessed `health`. 'controller' is the GitOps controller's own
|
|
28
|
+
// verdict; 'radar' is Radar's read of the live object (topology status, or
|
|
29
|
+
// the issues engine's finding when the controller's verdict isn't
|
|
30
|
+
// available). The UI labels 'radar' values so they never pass as the
|
|
31
|
+
// controller's. Absent when `health` is absent. Kept open for sources a
|
|
32
|
+
// newer backend may add — unknown values render without a label.
|
|
33
|
+
healthSource?: GitOpsHealthSource
|
|
34
|
+
healthReason?: string
|
|
35
|
+
healthMessage?: string
|
|
36
|
+
healthSeverity?: 'critical' | 'warning' | (string & {})
|
|
27
37
|
topologyStatus?: HealthStatus
|
|
28
38
|
info?: GitOpsTreeInfoItem[]
|
|
29
39
|
resource?: unknown
|
|
@@ -32,6 +42,16 @@ export interface GitOpsTreeNode {
|
|
|
32
42
|
data?: Record<string, unknown>
|
|
33
43
|
}
|
|
34
44
|
|
|
45
|
+
// 'controllerApi' is the controller's own verdict read from its API server
|
|
46
|
+
// (argocd-server) because the CR doesn't carry it; same authority as
|
|
47
|
+
// 'controller'.
|
|
48
|
+
export type GitOpsHealthSource = 'controller' | 'controllerApi' | 'radar' | (string & {})
|
|
49
|
+
|
|
50
|
+
// Where an Argo CD Application keeps per-resource health. 'appTree' is the
|
|
51
|
+
// Argo CD 3 default: the controller's per-resource verdicts are not in the
|
|
52
|
+
// Application object, so any node health present came from Radar.
|
|
53
|
+
export type GitOpsHealthMode = 'inline' | 'appTree' | (string & {})
|
|
54
|
+
|
|
35
55
|
export type GitOpsTreeEdgeType = 'owns' | 'source' | 'dependsOn'
|
|
36
56
|
|
|
37
57
|
export interface GitOpsTreeEdge {
|
|
@@ -54,4 +74,13 @@ export interface GitOpsResourceTree {
|
|
|
54
74
|
edges: GitOpsTreeEdge[]
|
|
55
75
|
warnings?: string[]
|
|
56
76
|
summary?: GitOpsTreeSummary
|
|
77
|
+
healthMode?: GitOpsHealthMode
|
|
78
|
+
// Per-resource health came from the controller's API server, so in
|
|
79
|
+
// appTree mode the verdicts are still the controller's.
|
|
80
|
+
healthFromApi?: boolean
|
|
81
|
+
// The controller's API server was asked and didn't answer usefully.
|
|
82
|
+
healthApiError?: string
|
|
83
|
+
// The Application deploys to another cluster; Radar derives nothing about
|
|
84
|
+
// its resources from here.
|
|
85
|
+
remoteDestination?: boolean
|
|
57
86
|
}
|
package/src/utils/index.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { describe, test, expect, afterEach } from 'vitest'
|
|
2
2
|
import { englishPlural } from './pluralize'
|
|
3
|
-
import { kindToPlural, kindToPluralWithGroup, pluralToKind, refToSelectedResource, initNavigationMap, resetNavigationMap, laneId, laneResourceKey, groupQualifiesLaneId, parseLaneId } from './navigation'
|
|
3
|
+
import { kindToPlural, kindToPluralWithGroup, pluralToKind, knownKindForPluralWithGroup, refToSelectedResource, initNavigationMap, resetNavigationMap, laneId, laneResourceKey, groupQualifiesLaneId, parseLaneId } from './navigation'
|
|
4
4
|
|
|
5
5
|
afterEach(() => {
|
|
6
6
|
resetNavigationMap()
|
|
@@ -223,6 +223,25 @@ describe('initNavigationMap', () => {
|
|
|
223
223
|
{ group: 'metrics.k8s.io', version: 'v1beta1', kind: 'PodMetrics', name: 'pods', namespaced: true, isCrd: false, verbs: ['get'] },
|
|
224
224
|
])
|
|
225
225
|
expect(pluralToKind('pods')).toBe('Pod')
|
|
226
|
+
expect(knownKindForPluralWithGroup('pods', '')).toBe('Pod')
|
|
227
|
+
expect(knownKindForPluralWithGroup('pods', 'metrics.k8s.io')).toBe('PodMetrics')
|
|
228
|
+
expect(kindToPluralWithGroup('PodMetrics', 'metrics.k8s.io')).toBe('pods')
|
|
229
|
+
})
|
|
230
|
+
|
|
231
|
+
test('resolves discovered plural collisions by API group', () => {
|
|
232
|
+
initNavigationMap([
|
|
233
|
+
{ group: 'a.example.io', version: 'v1', kind: 'Widget', name: 'widgets', namespaced: true, isCrd: true, verbs: ['get'] },
|
|
234
|
+
{ group: 'b.example.io', version: 'v1', kind: 'OtherWidget', name: 'widgets', namespaced: true, isCrd: true, verbs: ['get'] },
|
|
235
|
+
])
|
|
236
|
+
expect(knownKindForPluralWithGroup('widgets', 'a.example.io')).toBe('Widget')
|
|
237
|
+
expect(knownKindForPluralWithGroup('widgets', 'b.example.io')).toBe('OtherWidget')
|
|
238
|
+
expect(knownKindForPluralWithGroup('OtherWidget', 'b.example.io')).toBe('OtherWidget')
|
|
239
|
+
})
|
|
240
|
+
|
|
241
|
+
test('does not invent a Kind for an undiscovered irregular CRD', () => {
|
|
242
|
+
expect(knownKindForPluralWithGroup('databases', 'postgresql.cnpg.io')).toBeUndefined()
|
|
243
|
+
expect(kindToPluralWithGroup('Database', 'postgresql.cnpg.io')).toBe('databases')
|
|
244
|
+
expect(kindToPluralWithGroup('databases', 'postgresql.cnpg.io')).toBe('databases')
|
|
226
245
|
})
|
|
227
246
|
|
|
228
247
|
test('keeps the virtual PodGroup alias distinct from scheduling PodGroup', () => {
|
package/src/utils/navigation.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { SelectedResource, ResourceRef, APIResource } from '../types/core'
|
|
2
2
|
import { englishPlural, englishSingular, isEnglishPlural } from './pluralize'
|
|
3
|
+
import { CORE_RESOURCES } from './api-resources'
|
|
3
4
|
|
|
4
5
|
/**
|
|
5
6
|
* Canonical callback type for navigating to a resource.
|
|
@@ -44,6 +45,15 @@ const BUILTIN_PLURAL_TO_KIND: Record<string, string> = {
|
|
|
44
45
|
|
|
45
46
|
const BUILTIN_GROUP_KIND_TO_PLURAL: Record<string, string> = {
|
|
46
47
|
'scheduling.k8s.io/podgroup': 'podgroups',
|
|
48
|
+
'metrics.k8s.io/podmetrics': 'pods',
|
|
49
|
+
'metrics.k8s.io/nodemetrics': 'nodes',
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const BUILTIN_GROUP_PLURAL_TO_KIND: Record<string, string> = {
|
|
53
|
+
...Object.fromEntries(CORE_RESOURCES.map(r => [`${r.group}/${r.name}`, r.kind])),
|
|
54
|
+
'metrics.k8s.io/pods': 'PodMetrics',
|
|
55
|
+
'metrics.k8s.io/nodes': 'NodeMetrics',
|
|
56
|
+
'scheduling.k8s.io/podgroups': 'PodGroup',
|
|
47
57
|
}
|
|
48
58
|
|
|
49
59
|
// Dynamic map built from API discovery — populated by initNavigationMap().
|
|
@@ -51,6 +61,7 @@ const BUILTIN_GROUP_KIND_TO_PLURAL: Record<string, string> = {
|
|
|
51
61
|
let discoveredPluralToKind: Record<string, string> | null = null
|
|
52
62
|
let discoveredKindToPlural: Record<string, string> | null = null
|
|
53
63
|
let discoveredGroupKindToPlural: Record<string, string> | null = null
|
|
64
|
+
let discoveredGroupPluralToKind: Record<string, string> | null = null
|
|
54
65
|
|
|
55
66
|
/**
|
|
56
67
|
* Initialize navigation maps from discovered API resources.
|
|
@@ -61,6 +72,7 @@ export function initNavigationMap(resources: APIResource[]) {
|
|
|
61
72
|
const p2k: Record<string, string> = { ...BUILTIN_PLURAL_TO_KIND }
|
|
62
73
|
const k2p: Record<string, string> = {}
|
|
63
74
|
const gk2p: Record<string, string> = {}
|
|
75
|
+
const gp2k: Record<string, string> = { ...BUILTIN_GROUP_PLURAL_TO_KIND }
|
|
64
76
|
for (const r of resources) {
|
|
65
77
|
const plural = r.name.toLowerCase()
|
|
66
78
|
// First-wins on plurals: BUILTIN_PLURAL_TO_KIND seeds canonical core mappings
|
|
@@ -69,10 +81,13 @@ export function initNavigationMap(resources: APIResource[]) {
|
|
|
69
81
|
if (!(plural in p2k)) p2k[plural] = r.kind
|
|
70
82
|
k2p[r.kind.toLowerCase()] = plural
|
|
71
83
|
gk2p[`${r.group}/${r.kind.toLowerCase()}`] = plural
|
|
84
|
+
const groupPlural = `${r.group}/${plural}`
|
|
85
|
+
if (!(groupPlural in gp2k)) gp2k[groupPlural] = r.kind
|
|
72
86
|
}
|
|
73
87
|
discoveredPluralToKind = p2k
|
|
74
88
|
discoveredKindToPlural = k2p
|
|
75
89
|
discoveredGroupKindToPlural = gk2p
|
|
90
|
+
discoveredGroupPluralToKind = gp2k
|
|
76
91
|
}
|
|
77
92
|
|
|
78
93
|
/** Reset navigation maps to builtin-only state. For testing. */
|
|
@@ -80,6 +95,7 @@ export function resetNavigationMap() {
|
|
|
80
95
|
discoveredPluralToKind = null
|
|
81
96
|
discoveredKindToPlural = null
|
|
82
97
|
discoveredGroupKindToPlural = null
|
|
98
|
+
discoveredGroupPluralToKind = null
|
|
83
99
|
}
|
|
84
100
|
|
|
85
101
|
function getPluralToKind(): Record<string, string> {
|
|
@@ -166,6 +182,15 @@ export function pluralToKind(plural: string): string {
|
|
|
166
182
|
return singular.charAt(0).toUpperCase() + singular.slice(1)
|
|
167
183
|
}
|
|
168
184
|
|
|
185
|
+
/** Return an exact Kind only when the input or group-qualified discovery proves it. */
|
|
186
|
+
export function knownKindForPluralWithGroup(plural: string, group: string): string | undefined {
|
|
187
|
+
if (!plural) return undefined
|
|
188
|
+
if (plural[0] !== plural[0].toLowerCase()) return plural
|
|
189
|
+
const groupPlural = `${group}/${plural.toLowerCase()}`
|
|
190
|
+
return discoveredGroupPluralToKind?.[groupPlural]
|
|
191
|
+
?? BUILTIN_GROUP_PLURAL_TO_KIND[groupPlural]
|
|
192
|
+
}
|
|
193
|
+
|
|
169
194
|
/**
|
|
170
195
|
* Convert a ResourceRef (from backend relationships) to a SelectedResource (for navigation).
|
|
171
196
|
* Handles kind singular→plural conversion.
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { effectivePolicyTypeNames, effectivePolicyTypes, formatNetworkPolicyPort } from './network-policy'
|
|
3
|
+
|
|
4
|
+
describe('effectivePolicyTypes', () => {
|
|
5
|
+
it('reads an explicit list as authoritative', () => {
|
|
6
|
+
expect(effectivePolicyTypes({ policyTypes: ['Egress'] })).toEqual({ ingress: false, egress: true, explicit: true })
|
|
7
|
+
expect(effectivePolicyTypes({ policyTypes: ['Ingress', 'Egress'], egress: [] })).toEqual({ ingress: true, egress: true, explicit: true })
|
|
8
|
+
})
|
|
9
|
+
|
|
10
|
+
it('defaults an omitted list to Ingress — the classic default-deny', () => {
|
|
11
|
+
expect(effectivePolicyTypes({ podSelector: {} })).toEqual({ ingress: true, egress: false, explicit: false })
|
|
12
|
+
expect(effectivePolicyTypes({ policyTypes: [] })).toEqual({ ingress: true, egress: false, explicit: false })
|
|
13
|
+
expect(effectivePolicyTypes(undefined)).toEqual({ ingress: true, egress: false, explicit: false })
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
it('adds Egress to the default only when egress rules are declared', () => {
|
|
17
|
+
expect(effectivePolicyTypes({ egress: [{}] })).toEqual({ ingress: true, egress: true, explicit: false })
|
|
18
|
+
expect(effectivePolicyTypes({ egress: [] })).toEqual({ ingress: true, egress: false, explicit: false })
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
it('names the effective types as Ingress then Egress', () => {
|
|
22
|
+
expect(effectivePolicyTypeNames({})).toEqual(['Ingress'])
|
|
23
|
+
expect(effectivePolicyTypeNames({ egress: [{}] })).toEqual(['Ingress', 'Egress'])
|
|
24
|
+
expect(effectivePolicyTypeNames({ policyTypes: ['Egress'] })).toEqual(['Egress'])
|
|
25
|
+
expect(effectivePolicyTypeNames({ policyTypes: ['Egress', 'Ingress'] })).toEqual(['Ingress', 'Egress'])
|
|
26
|
+
})
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
describe('formatNetworkPolicyPort', () => {
|
|
30
|
+
it('renders single ports, ranges, named ports and protocol-only entries', () => {
|
|
31
|
+
expect(formatNetworkPolicyPort({ port: 80 })).toBe('TCP/80')
|
|
32
|
+
expect(formatNetworkPolicyPort({ protocol: 'UDP', port: 53 })).toBe('UDP/53')
|
|
33
|
+
expect(formatNetworkPolicyPort({ port: 9000, endPort: 9100 })).toBe('TCP/9000-9100')
|
|
34
|
+
expect(formatNetworkPolicyPort({ port: 'metrics' })).toBe('TCP/metrics')
|
|
35
|
+
expect(formatNetworkPolicyPort({ protocol: 'SCTP' })).toBe('SCTP/*')
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
it('never shows a range off a named port or off no port', () => {
|
|
39
|
+
expect(formatNetworkPolicyPort({ port: 'metrics', endPort: 9100 })).toBe('TCP/metrics')
|
|
40
|
+
expect(formatNetworkPolicyPort({ endPort: 9100 })).toBe('TCP/*')
|
|
41
|
+
})
|
|
42
|
+
})
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Core networking.k8s.io/v1 NetworkPolicy presentation helpers, shared by the
|
|
3
|
+
* list column, the detail renderer and the flow diagram so the three cannot
|
|
4
|
+
* disagree about what a policy does.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
export interface EffectivePolicyTypes {
|
|
8
|
+
ingress: boolean
|
|
9
|
+
egress: boolean
|
|
10
|
+
/** True when spec.policyTypes was set; false when Kubernetes' defaulting decided. */
|
|
11
|
+
explicit: boolean
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Kubernetes' policyTypes defaulting, applied exactly: when spec.policyTypes
|
|
16
|
+
* is set it is authoritative; when omitted, a policy always isolates Ingress
|
|
17
|
+
* and additionally isolates Egress iff it declares egress rules. The common
|
|
18
|
+
* default-deny (`podSelector: {}` and nothing else) has no policyTypes and
|
|
19
|
+
* isolates ingress — reading the field raw shows it as isolating nothing.
|
|
20
|
+
*/
|
|
21
|
+
export function effectivePolicyTypes(spec: any): EffectivePolicyTypes {
|
|
22
|
+
const declared: unknown = spec?.policyTypes
|
|
23
|
+
if (Array.isArray(declared) && declared.length > 0) {
|
|
24
|
+
return {
|
|
25
|
+
ingress: declared.includes('Ingress'),
|
|
26
|
+
egress: declared.includes('Egress'),
|
|
27
|
+
explicit: true,
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
const egressRules: unknown = spec?.egress
|
|
31
|
+
return {
|
|
32
|
+
ingress: true,
|
|
33
|
+
egress: Array.isArray(egressRules) && egressRules.length > 0,
|
|
34
|
+
explicit: false,
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** The effective types as the list they would have been declared as. */
|
|
39
|
+
export function effectivePolicyTypeNames(spec: any): string[] {
|
|
40
|
+
const t = effectivePolicyTypes(spec)
|
|
41
|
+
const out: string[] = []
|
|
42
|
+
if (t.ingress) out.push('Ingress')
|
|
43
|
+
if (t.egress) out.push('Egress')
|
|
44
|
+
return out
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* One NetworkPolicyPort as `PROTO/port`, `PROTO/start-end` for an endPort
|
|
49
|
+
* range, or `PROTO/*` when the entry names a protocol but no port.
|
|
50
|
+
*/
|
|
51
|
+
export function formatNetworkPolicyPort(port: any): string {
|
|
52
|
+
const proto = port?.protocol || 'TCP'
|
|
53
|
+
const start = port?.port ?? '*'
|
|
54
|
+
const end = port?.endPort
|
|
55
|
+
// endPort only ever pairs with a numeric port; a range off a named port or
|
|
56
|
+
// off no port is not a range Kubernetes would accept, so it isn't shown as one.
|
|
57
|
+
const isRange = end != null && typeof start === 'number'
|
|
58
|
+
return isRange ? `${proto}/${start}-${end}` : `${proto}/${start}`
|
|
59
|
+
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import type { Relationships } from "../types";
|
|
2
|
+
|
|
3
|
+
const prefix = "reflector.v1.k8s.emberstack.com/";
|
|
4
|
+
const annotationNames = [
|
|
5
|
+
"reflects",
|
|
6
|
+
"auto-reflects",
|
|
7
|
+
"reflected-version",
|
|
8
|
+
"reflected-at",
|
|
9
|
+
"reflection-allowed",
|
|
10
|
+
"reflection-auto-enabled",
|
|
11
|
+
"reflection-allowed-namespaces",
|
|
12
|
+
"reflection-allowed-namespaces-selector",
|
|
13
|
+
"reflection-auto-namespaces",
|
|
14
|
+
"reflection-auto-namespaces-selector",
|
|
15
|
+
];
|
|
16
|
+
|
|
17
|
+
export type Reflection = NonNullable<Relationships["reflection"]>;
|
|
18
|
+
export interface ReflectorResource {
|
|
19
|
+
apiVersion?: string;
|
|
20
|
+
metadata?: {
|
|
21
|
+
name?: string;
|
|
22
|
+
namespace?: string;
|
|
23
|
+
annotations?: Record<string, string>;
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function annotationsFor(data: ReflectorResource): Record<string, string> {
|
|
28
|
+
return data.apiVersion === "v1" ? (data.metadata?.annotations ?? {}) : {};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function hasReflectorDetails(
|
|
32
|
+
data: ReflectorResource,
|
|
33
|
+
reflection?: Reflection,
|
|
34
|
+
): boolean {
|
|
35
|
+
if (data.apiVersion !== "v1") return false;
|
|
36
|
+
const annotations = annotationsFor(data);
|
|
37
|
+
return (
|
|
38
|
+
annotationNames.some((name) => prefix + name in annotations) ||
|
|
39
|
+
!!reflection?.source ||
|
|
40
|
+
!!reflection?.mirrors?.length
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function isReflectorMirror(data: ReflectorResource): boolean {
|
|
45
|
+
return /^[^/\s]+\/[^/\s]+$/.test(
|
|
46
|
+
annotationsFor(data)[prefix + "reflects"] ?? "",
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export const reflectorEditNotice =
|
|
51
|
+
"This object is a Reflector mirror. Edit the source for lasting changes. Local edits may persist until the source changes, then be overwritten.";
|
|
52
|
+
|
|
53
|
+
function booleanValue(value: string | undefined): boolean | undefined {
|
|
54
|
+
if (value === undefined) return false;
|
|
55
|
+
if (value.trim().toLowerCase() === "true") return true;
|
|
56
|
+
if (value.trim().toLowerCase() === "false") return false;
|
|
57
|
+
return undefined;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function parseReflector(
|
|
61
|
+
data: ReflectorResource,
|
|
62
|
+
reflection?: Reflection,
|
|
63
|
+
) {
|
|
64
|
+
const annotations = annotationsFor(data);
|
|
65
|
+
const value = (name: string) => annotations[prefix + name];
|
|
66
|
+
const declaration = value("reflects");
|
|
67
|
+
const hasDeclaration = declaration !== undefined;
|
|
68
|
+
const isMirror = isReflectorMirror(data);
|
|
69
|
+
const allowed = booleanValue(value("reflection-allowed"));
|
|
70
|
+
const autoEnabled = booleanValue(value("reflection-auto-enabled"));
|
|
71
|
+
const automatic = booleanValue(value("auto-reflects"));
|
|
72
|
+
const source =
|
|
73
|
+
reflection?.source &&
|
|
74
|
+
`${reflection.source.namespace}/${reflection.source.name}` === declaration
|
|
75
|
+
? reflection.source
|
|
76
|
+
: undefined;
|
|
77
|
+
const mirrors = reflection?.mirrors ?? [];
|
|
78
|
+
const sourceSettings =
|
|
79
|
+
!isMirror ||
|
|
80
|
+
annotationNames.some(
|
|
81
|
+
(name) => name.startsWith("reflection-") && value(name) !== undefined,
|
|
82
|
+
) ||
|
|
83
|
+
mirrors.length > 0;
|
|
84
|
+
const copiedVersion = value("reflected-version");
|
|
85
|
+
const sourceVersion = source ? reflection?.sourceResourceVersion : undefined;
|
|
86
|
+
const warnings: string[] = [];
|
|
87
|
+
for (const name of [
|
|
88
|
+
"reflection-allowed",
|
|
89
|
+
"reflection-auto-enabled",
|
|
90
|
+
"auto-reflects",
|
|
91
|
+
]) {
|
|
92
|
+
if (booleanValue(value(name)) === undefined)
|
|
93
|
+
warnings.push(
|
|
94
|
+
`${name} must be true or false (currently "${value(name)}").`,
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
if (hasDeclaration && !isMirror)
|
|
98
|
+
warnings.push(
|
|
99
|
+
"The reflects annotation must name a source as namespace/name.",
|
|
100
|
+
);
|
|
101
|
+
if (
|
|
102
|
+
declaration &&
|
|
103
|
+
declaration === `${data.metadata?.namespace}/${data.metadata?.name}`
|
|
104
|
+
)
|
|
105
|
+
warnings.push(
|
|
106
|
+
"The reflects annotation points to this object itself. Choose a different source.",
|
|
107
|
+
);
|
|
108
|
+
if (!isMirror && mirrors.length > 0 && allowed === false)
|
|
109
|
+
warnings.push(
|
|
110
|
+
"Reflection is not enabled on this source, but visible mirrors still reference it.",
|
|
111
|
+
);
|
|
112
|
+
if (!isMirror && autoEnabled === true && allowed === false)
|
|
113
|
+
warnings.push(
|
|
114
|
+
"Automatic mirror creation requires reflection-allowed to be true.",
|
|
115
|
+
);
|
|
116
|
+
if (
|
|
117
|
+
isMirror &&
|
|
118
|
+
(mirrors.length > 0 || allowed === true || autoEnabled === true)
|
|
119
|
+
)
|
|
120
|
+
warnings.push(
|
|
121
|
+
"This object is itself a mirror. Reflector does not propagate its updates as source updates to other mirrors.",
|
|
122
|
+
);
|
|
123
|
+
|
|
124
|
+
return {
|
|
125
|
+
value,
|
|
126
|
+
declaration,
|
|
127
|
+
hasDeclaration,
|
|
128
|
+
isMirror,
|
|
129
|
+
allowed,
|
|
130
|
+
autoEnabled,
|
|
131
|
+
automatic,
|
|
132
|
+
source,
|
|
133
|
+
mirrors,
|
|
134
|
+
sourceSettings,
|
|
135
|
+
copiedVersion,
|
|
136
|
+
sourceVersion,
|
|
137
|
+
warnings,
|
|
138
|
+
};
|
|
139
|
+
}
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import {
|
|
3
|
+
describeFileDrag,
|
|
4
|
+
MAX_YAML_FILE_BYTES,
|
|
5
|
+
needsReplaceConfirmation,
|
|
6
|
+
readYamlFile,
|
|
7
|
+
} from './yaml-file-import'
|
|
8
|
+
|
|
9
|
+
function drag(items: Array<{ kind: string; type: string }>, types: string[] = []): DataTransfer {
|
|
10
|
+
return { items, types } as unknown as DataTransfer
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
// Safari keeps the dragged items protected until the drop, so `items` is empty
|
|
14
|
+
// mid-drag; only `types` reports that files are coming.
|
|
15
|
+
function safariDrag(types: string[]): DataTransfer {
|
|
16
|
+
return { items: [], types } as unknown as DataTransfer
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const MULTI_DOC = `apiVersion: v1
|
|
20
|
+
kind: ConfigMap
|
|
21
|
+
metadata:
|
|
22
|
+
name: first
|
|
23
|
+
---
|
|
24
|
+
apiVersion: v1
|
|
25
|
+
kind: Secret
|
|
26
|
+
metadata:
|
|
27
|
+
name: second
|
|
28
|
+
`
|
|
29
|
+
|
|
30
|
+
function yamlFile(name: string, content: string, type = ''): File {
|
|
31
|
+
return new File([content], name, { type })
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
describe('readYamlFile', () => {
|
|
35
|
+
it('loads a multi-document file as text the editor can hold unchanged', async () => {
|
|
36
|
+
const result = await readYamlFile([yamlFile('bundle.yaml', MULTI_DOC)])
|
|
37
|
+
|
|
38
|
+
expect(result).toEqual({ ok: true, fileName: 'bundle.yaml', yaml: MULTI_DOC })
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
it('accepts .yml as readily as .yaml', async () => {
|
|
42
|
+
const result = await readYamlFile([yamlFile('bundle.yml', MULTI_DOC)])
|
|
43
|
+
|
|
44
|
+
expect(result.ok).toBe(true)
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
// Browsers disagree on the MIME type for YAML — macOS Chrome commonly reports
|
|
48
|
+
// "", Windows reports assorted octet-stream variants — so the extension is the
|
|
49
|
+
// only signal that means the same thing everywhere.
|
|
50
|
+
it('accepts a YAML extension even when the browser reports no usable MIME type', async () => {
|
|
51
|
+
const result = await readYamlFile([
|
|
52
|
+
yamlFile('bundle.yaml', MULTI_DOC, 'application/octet-stream'),
|
|
53
|
+
])
|
|
54
|
+
|
|
55
|
+
expect(result.ok).toBe(true)
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
it('rejects a non-YAML extension even when the browser claims it is YAML', async () => {
|
|
59
|
+
const result = await readYamlFile([yamlFile('notes.txt', MULTI_DOC, 'text/yaml')])
|
|
60
|
+
|
|
61
|
+
expect(result).toMatchObject({ ok: false, reason: 'extension' })
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
it('rejects a file that holds only whitespace', async () => {
|
|
65
|
+
const result = await readYamlFile([yamlFile('empty.yaml', ' \n\n ')])
|
|
66
|
+
|
|
67
|
+
expect(result).toMatchObject({ ok: false, reason: 'empty' })
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
it('rejects a file larger than the cap without reading it', async () => {
|
|
71
|
+
const oversize = yamlFile('huge.yaml', 'x'.repeat(MAX_YAML_FILE_BYTES + 1))
|
|
72
|
+
|
|
73
|
+
expect(await readYamlFile([oversize])).toMatchObject({ ok: false, reason: 'too-large' })
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
it('rejects a drop carrying more than one file', async () => {
|
|
77
|
+
const result = await readYamlFile([
|
|
78
|
+
yamlFile('one.yaml', MULTI_DOC),
|
|
79
|
+
yamlFile('two.yaml', MULTI_DOC),
|
|
80
|
+
])
|
|
81
|
+
|
|
82
|
+
expect(result).toMatchObject({ ok: false, reason: 'multiple-files' })
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
it('rejects an empty selection', async () => {
|
|
86
|
+
expect(await readYamlFile([])).toMatchObject({ ok: false, reason: 'no-file' })
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
it('reports a file the browser cannot read rather than throwing', async () => {
|
|
90
|
+
const unreadable = {
|
|
91
|
+
name: 'gone.yaml',
|
|
92
|
+
size: 128,
|
|
93
|
+
text: () => Promise.reject(new Error('NotReadableError')),
|
|
94
|
+
} as unknown as File
|
|
95
|
+
|
|
96
|
+
expect(await readYamlFile([unreadable])).toMatchObject({ ok: false, reason: 'unreadable' })
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
it('explains the cap in the message it hands the error banner', async () => {
|
|
100
|
+
const result = await readYamlFile([yamlFile('huge.yaml', 'x'.repeat(MAX_YAML_FILE_BYTES + 1))])
|
|
101
|
+
|
|
102
|
+
expect(result.ok).toBe(false)
|
|
103
|
+
if (!result.ok) expect(result.message).toContain('6 MiB')
|
|
104
|
+
})
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
// A drag exposes only `kind` and `type`; the file name is withheld until drop,
|
|
108
|
+
// and YAML has no MIME type worth trusting. So the overlay promises "a file is
|
|
109
|
+
// coming", never "an acceptable YAML file is coming".
|
|
110
|
+
describe('describeFileDrag', () => {
|
|
111
|
+
it('ignores a drag with nothing attached', () => {
|
|
112
|
+
expect(describeFileDrag(null)).toBe('none')
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
it('leaves a text drag alone so the editor keeps its own drop behaviour', () => {
|
|
116
|
+
expect(describeFileDrag(drag([{ kind: 'string', type: 'text/plain' }]))).toBe('none')
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
it('reports a single file even when the browser withholds its type', () => {
|
|
120
|
+
expect(describeFileDrag(drag([{ kind: 'file', type: '' }]))).toBe('file')
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
it('counts only files when a drag carries companion text items', () => {
|
|
124
|
+
const dragged = drag([
|
|
125
|
+
{ kind: 'file', type: '' },
|
|
126
|
+
{ kind: 'string', type: 'text/uri-list' },
|
|
127
|
+
])
|
|
128
|
+
|
|
129
|
+
expect(describeFileDrag(dragged)).toBe('file')
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
it('distinguishes a multi-file drag, which is the one rejection knowable up front', () => {
|
|
133
|
+
const dragged = drag([
|
|
134
|
+
{ kind: 'file', type: '' },
|
|
135
|
+
{ kind: 'file', type: '' },
|
|
136
|
+
])
|
|
137
|
+
|
|
138
|
+
expect(describeFileDrag(dragged)).toBe('multiple-files')
|
|
139
|
+
})
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
// The create dialog opens prefilled — ResourcesView with a kind skeleton,
|
|
143
|
+
// WorkloadView with a duplicate — so a non-empty editor is the normal state,
|
|
144
|
+
// not a sign the user has work worth protecting.
|
|
145
|
+
describe('needsReplaceConfirmation', () => {
|
|
146
|
+
const SKELETON = 'apiVersion: v1\nkind: ConfigMap\n'
|
|
147
|
+
|
|
148
|
+
it('loads straight over an untouched skeleton', () => {
|
|
149
|
+
expect(needsReplaceConfirmation(SKELETON, SKELETON)).toBe(false)
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
it('loads straight into an empty editor', () => {
|
|
153
|
+
expect(needsReplaceConfirmation('', '')).toBe(false)
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
it('asks before discarding edits the user actually made', () => {
|
|
157
|
+
expect(needsReplaceConfirmation(`${SKELETON} name: checkout\n`, SKELETON)).toBe(true)
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
it('asks before discarding a manifest pasted into an editor that opened empty', () => {
|
|
161
|
+
expect(needsReplaceConfirmation(SKELETON, '')).toBe(true)
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
it('treats a whitespace-only difference as nothing worth protecting', () => {
|
|
165
|
+
expect(needsReplaceConfirmation(`${SKELETON}\n \n`, SKELETON)).toBe(false)
|
|
166
|
+
})
|
|
167
|
+
})
|
|
168
|
+
|
|
169
|
+
describe('describeFileDrag across browsers', () => {
|
|
170
|
+
it('sees a file drag in Safari, where items stay empty until the drop', () => {
|
|
171
|
+
expect(describeFileDrag(safariDrag(['Files']))).toBe('file')
|
|
172
|
+
})
|
|
173
|
+
|
|
174
|
+
it('still ignores a Safari text drag, which advertises no Files type', () => {
|
|
175
|
+
expect(describeFileDrag(safariDrag(['text/plain']))).toBe('none')
|
|
176
|
+
})
|
|
177
|
+
|
|
178
|
+
it('counts files when the browser does expose them alongside the Files type', () => {
|
|
179
|
+
const dragged = drag(
|
|
180
|
+
[
|
|
181
|
+
{ kind: 'file', type: '' },
|
|
182
|
+
{ kind: 'file', type: '' },
|
|
183
|
+
],
|
|
184
|
+
['Files'],
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
expect(describeFileDrag(dragged)).toBe('multiple-files')
|
|
188
|
+
})
|
|
189
|
+
})
|