@skyhook-io/k8s-ui 1.14.5 → 1.14.7
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/applications/ApplicationDetail.tsx +36 -4
- package/src/components/applications/ApplicationsView.tsx +2 -1
- package/src/components/audit/AuditAlerts.tsx +35 -37
- package/src/components/audit/AuditCard.tsx +18 -7
- package/src/components/audit/AuditCoveragePresentation.test.tsx +45 -0
- package/src/components/audit/AuditFindingsTable.tsx +23 -27
- package/src/components/charts/AreaChart.test.tsx +13 -0
- package/src/components/charts/AreaChart.tsx +28 -25
- package/src/components/charts/PrometheusChartsView.tsx +41 -10
- package/src/components/charts/axis.test.ts +16 -0
- package/src/components/charts/axis.ts +3 -1
- package/src/components/charts/format.ts +4 -0
- package/src/components/charts/nearestSample.test.ts +31 -0
- package/src/components/charts/nearestSample.ts +15 -0
- package/src/components/checks/ChecksView.tsx +76 -28
- package/src/components/gitops/GitOpsDetailLayout.tsx +25 -9
- 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/issues/IssuesView.tsx +35 -54
- package/src/components/logs/LogCore.tsx +40 -9
- package/src/components/resources/ResourcesSidebar.tsx +66 -78
- package/src/components/resources/ResourcesView.tsx +67 -2
- package/src/components/resources/curated-column-ownership.test.ts +3 -3
- package/src/components/resources/renderers/AnalysisTemplateRenderer.tsx +150 -0
- package/src/components/resources/renderers/ClusterComplianceReportRenderer.tsx +21 -15
- package/src/components/resources/renderers/CompositionRenderer.tsx +30 -10
- package/src/components/resources/renderers/ConfigAuditReportRenderer.tsx +16 -12
- package/src/components/resources/renderers/ConfigMapRenderer.tsx +8 -1
- package/src/components/resources/renderers/ExperimentRenderer.tsx +179 -0
- package/src/components/resources/renderers/ExposedSecretReportRenderer.tsx +10 -4
- package/src/components/resources/renderers/GenericRenderer.tsx +13 -7
- package/src/components/resources/renderers/KyvernoPolicyReportRenderer.tsx +6 -4
- 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/PolicyCoverageSection.tsx +7 -4
- package/src/components/resources/renderers/PrometheusRuleRenderer.tsx +13 -15
- 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/SbomReportRenderer.tsx +10 -4
- package/src/components/resources/renderers/SecretRenderer.tsx +10 -3
- package/src/components/resources/renderers/VulnerabilityReportRenderer.tsx +10 -4
- package/src/components/resources/renderers/WorkloadRenderer.tsx +11 -1
- 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 +123 -0
- package/src/components/resources/resource-utils.ts +18 -9
- package/src/components/shared/CreateResourceDialog.tsx +237 -14
- 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/timeline/TimelineList.tsx +14 -11
- package/src/components/timeline/TimelineStrip.tsx +26 -4
- package/src/components/timeline/TimelineSwimlanes.tsx +3 -5
- package/src/components/timeline/TimelineToolbar.tsx +43 -10
- package/src/components/topology/TopologyControls.tsx +27 -8
- package/src/components/topology/TopologyGraph.tsx +14 -5
- package/src/components/trace/ReachabilityView.tsx +17 -16
- package/src/components/ui/CardSection.tsx +79 -35
- package/src/components/ui/Collapse.test.tsx +74 -0
- package/src/components/ui/Collapse.tsx +115 -29
- package/src/components/ui/DialogPortal.tsx +7 -2
- package/src/components/ui/FetchResult.test.tsx +20 -2
- package/src/components/ui/FetchResult.tsx +33 -3
- package/src/components/ui/ForceDeleteConfirmDialog.tsx +7 -4
- package/src/components/ui/FreshnessControl.tsx +8 -2
- package/src/components/ui/RestrictedState.tsx +7 -4
- package/src/components/ui/RowActionMenu.tsx +20 -8
- package/src/components/ui/SelectMenu.tsx +18 -6
- package/src/components/ui/YamlEditor.tsx +7 -9
- package/src/components/ui/drawer-components.tsx +14 -21
- package/src/components/ui/index.ts +30 -22
- package/src/components/workload/WorkloadView.tsx +8 -12
- package/src/hooks/useAnimatedUnmount.ts +21 -3
- package/src/theme/components.css +20 -1
- package/src/types/core.ts +15 -1
- package/src/types/gitops-insights.ts +23 -0
- package/src/types/gitops-tree.ts +29 -0
- package/src/utils/animation.ts +93 -13
- 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
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { renderToString } from 'react-dom/server'
|
|
3
|
+
import { NetworkPolicyRenderer } from './NetworkPolicyRenderer'
|
|
4
|
+
import { NetworkPolicyDiagram } from './NetworkPolicyDiagram'
|
|
5
|
+
|
|
6
|
+
const render = (spec: any) => renderToString(<NetworkPolicyRenderer data={{ kind: 'NetworkPolicy', spec }} />)
|
|
7
|
+
const renderDiagram = (spec: any) => renderToString(<NetworkPolicyDiagram spec={spec} />)
|
|
8
|
+
|
|
9
|
+
describe('NetworkPolicyRenderer', () => {
|
|
10
|
+
it('shows a policy without policyTypes as isolating ingress, and says the type was defaulted', () => {
|
|
11
|
+
const html = render({ podSelector: {} })
|
|
12
|
+
expect(html).toContain('Ingress Rules')
|
|
13
|
+
expect(html).toContain('Deny all ingress')
|
|
14
|
+
expect(html).toContain('policyTypes not set')
|
|
15
|
+
expect(html).not.toContain('Egress Rules')
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
it('does not call an explicitly typed policy defaulted', () => {
|
|
19
|
+
const html = render({ podSelector: {}, policyTypes: ['Ingress'] })
|
|
20
|
+
expect(html).toContain('Deny all ingress')
|
|
21
|
+
expect(html).not.toContain('policyTypes not set')
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
it('renders both selectors of a peer that sets podSelector and namespaceSelector', () => {
|
|
25
|
+
const html = render({
|
|
26
|
+
podSelector: { matchLabels: { app: 'prometheus' } },
|
|
27
|
+
policyTypes: ['Ingress'],
|
|
28
|
+
ingress: [{
|
|
29
|
+
from: [{
|
|
30
|
+
podSelector: { matchLabels: { app: 'radar' } },
|
|
31
|
+
namespaceSelector: { matchLabels: { 'kubernetes.io/metadata.name': 'radar' } },
|
|
32
|
+
}],
|
|
33
|
+
}],
|
|
34
|
+
})
|
|
35
|
+
expect(html).toContain('podSelector')
|
|
36
|
+
expect(html).toContain('in namespaceSelector')
|
|
37
|
+
expect(html).toContain('kubernetes.io/metadata.name')
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it('renders matchExpressions rather than calling the selector empty', () => {
|
|
41
|
+
const html = render({
|
|
42
|
+
podSelector: { matchExpressions: [{ key: 'tier', operator: 'In', values: ['web', 'api'] }] },
|
|
43
|
+
policyTypes: ['Ingress'],
|
|
44
|
+
ingress: [{ from: [{ podSelector: { matchExpressions: [{ key: 'role', operator: 'Exists' }] } }] }],
|
|
45
|
+
})
|
|
46
|
+
expect(html).toContain('tier')
|
|
47
|
+
expect(html).toContain('In')
|
|
48
|
+
expect(html).toContain('role')
|
|
49
|
+
expect(html).not.toContain('All pods in namespace')
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
it('renders endPort ranges in the rule port list', () => {
|
|
53
|
+
const html = render({
|
|
54
|
+
podSelector: {},
|
|
55
|
+
policyTypes: ['Ingress'],
|
|
56
|
+
ingress: [{ ports: [{ port: 9000, endPort: 9100 }, { protocol: 'UDP', port: 53 }] }],
|
|
57
|
+
})
|
|
58
|
+
expect(html).toContain('TCP/9000-9100')
|
|
59
|
+
expect(html).toContain('UDP/53')
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it('diagram: reads matchExpressions, shows ranges, and treats an empty selector as all pods', () => {
|
|
63
|
+
const html = renderDiagram({
|
|
64
|
+
podSelector: { matchExpressions: [{ key: 'tier', operator: 'In', values: ['web'] }] },
|
|
65
|
+
ingress: [{ ports: [{ port: 9000, endPort: 9100 }] }],
|
|
66
|
+
})
|
|
67
|
+
expect(html).toContain('tier In (web)')
|
|
68
|
+
expect(html).toContain('TCP/9000-9100')
|
|
69
|
+
expect(html).not.toContain('All pods')
|
|
70
|
+
|
|
71
|
+
const empty = renderDiagram({ podSelector: {} })
|
|
72
|
+
expect(empty).toContain('All pods')
|
|
73
|
+
expect(empty).not.toContain('all workloads')
|
|
74
|
+
})
|
|
75
|
+
})
|
|
@@ -2,6 +2,7 @@ import { Shield, ArrowDownToLine, ArrowUpFromLine, GitFork } from 'lucide-react'
|
|
|
2
2
|
import { clsx } from 'clsx'
|
|
3
3
|
import { Section, LabelSelectorDisplay } from '../../ui/drawer-components'
|
|
4
4
|
import { NetworkPolicyDiagram } from './NetworkPolicyDiagram'
|
|
5
|
+
import { effectivePolicyTypeNames, effectivePolicyTypes, formatNetworkPolicyPort } from '../../../utils/network-policy'
|
|
5
6
|
|
|
6
7
|
interface NetworkPolicyRendererProps {
|
|
7
8
|
data: any
|
|
@@ -11,12 +12,13 @@ interface NetworkPolicyRendererProps {
|
|
|
11
12
|
export function NetworkPolicyRenderer({ data, staged = false }: NetworkPolicyRendererProps) {
|
|
12
13
|
const spec = data.spec || {}
|
|
13
14
|
const podSelector = spec.podSelector || {}
|
|
14
|
-
const policyTypes: string[] = spec.policyTypes || []
|
|
15
15
|
const ingress: any[] | undefined = spec.ingress
|
|
16
16
|
const egress: any[] | undefined = spec.egress
|
|
17
17
|
|
|
18
|
-
const
|
|
19
|
-
const
|
|
18
|
+
const types = effectivePolicyTypes(spec)
|
|
19
|
+
const policyTypes = effectivePolicyTypeNames(spec)
|
|
20
|
+
const hasIngress = types.ingress
|
|
21
|
+
const hasEgress = types.egress
|
|
20
22
|
|
|
21
23
|
const hasDiagramContent = hasIngress || hasEgress
|
|
22
24
|
|
|
@@ -33,26 +35,29 @@ export function NetworkPolicyRenderer({ data, staged = false }: NetworkPolicyRen
|
|
|
33
35
|
<div className="text-xs text-theme-text-tertiary mb-1">Pod Selector</div>
|
|
34
36
|
<LabelSelectorDisplay selector={podSelector} emptyText="All pods in namespace" />
|
|
35
37
|
</div>
|
|
36
|
-
|
|
37
|
-
<div className="
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
38
|
+
<div className="mt-2">
|
|
39
|
+
<div className="text-xs text-theme-text-tertiary mb-1">Policy Types</div>
|
|
40
|
+
<div className="flex flex-wrap items-center gap-1">
|
|
41
|
+
{policyTypes.map((type) => (
|
|
42
|
+
<span
|
|
43
|
+
key={type}
|
|
44
|
+
className={clsx(
|
|
45
|
+
'badge',
|
|
46
|
+
type === 'Ingress'
|
|
47
|
+
? 'status-blue'
|
|
48
|
+
: 'status-purple'
|
|
49
|
+
)}
|
|
50
|
+
>
|
|
51
|
+
{type}
|
|
52
|
+
</span>
|
|
53
|
+
))}
|
|
54
|
+
{!types.explicit && (
|
|
55
|
+
<span className="text-xs text-theme-text-tertiary">
|
|
56
|
+
by default — policyTypes not set
|
|
57
|
+
</span>
|
|
58
|
+
)}
|
|
54
59
|
</div>
|
|
55
|
-
|
|
60
|
+
</div>
|
|
56
61
|
</Section>
|
|
57
62
|
|
|
58
63
|
{hasIngress && (
|
|
@@ -118,7 +123,7 @@ function IngressEgressRuleCard({
|
|
|
118
123
|
key={j}
|
|
119
124
|
className="badge bg-theme-elevated text-theme-text-secondary"
|
|
120
125
|
>
|
|
121
|
-
{port
|
|
126
|
+
{formatNetworkPolicyPort(port)}
|
|
122
127
|
</span>
|
|
123
128
|
))}
|
|
124
129
|
</div>
|
|
@@ -135,20 +140,29 @@ function IngressEgressRuleCard({
|
|
|
135
140
|
}
|
|
136
141
|
|
|
137
142
|
function PeerEntry({ peer }: { peer: any }) {
|
|
138
|
-
|
|
143
|
+
// podSelector and namespaceSelector on one peer are ANDed: the peer is the
|
|
144
|
+
// pods matching the first inside the namespaces matching the second. Both
|
|
145
|
+
// have to be shown, or a narrow cross-namespace grant reads as a broad
|
|
146
|
+
// same-namespace one.
|
|
147
|
+
const hasPod = peer.podSelector !== undefined
|
|
148
|
+
const hasNs = peer.namespaceSelector !== undefined
|
|
149
|
+
if (hasPod || hasNs) {
|
|
139
150
|
return (
|
|
140
|
-
<div className="text-sm">
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
151
|
+
<div className="text-sm space-y-0.5">
|
|
152
|
+
{hasPod && (
|
|
153
|
+
<div>
|
|
154
|
+
<span className="text-theme-text-secondary text-xs">podSelector: </span>
|
|
155
|
+
<LabelSelectorDisplay selector={peer.podSelector} emptyText="all pods" inline />
|
|
156
|
+
</div>
|
|
157
|
+
)}
|
|
158
|
+
{hasNs && (
|
|
159
|
+
<div>
|
|
160
|
+
<span className="text-theme-text-secondary text-xs">
|
|
161
|
+
{hasPod ? 'in namespaceSelector: ' : 'namespaceSelector: '}
|
|
162
|
+
</span>
|
|
163
|
+
<LabelSelectorDisplay selector={peer.namespaceSelector} emptyText="all namespaces" inline />
|
|
164
|
+
</div>
|
|
165
|
+
)}
|
|
152
166
|
</div>
|
|
153
167
|
)
|
|
154
168
|
}
|
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
import { resolvedEnvFromKey } from '../../../utils/env-from'
|
|
15
15
|
import { detectBlastRadius, rulePermissivenessScore } from '../../../utils/rbac-blast-radius'
|
|
16
16
|
import { RBACErrorSection, isRBACUnavailable } from './RBACErrorSection'
|
|
17
|
+
import { NamespaceLimitRangeLink } from './LimitRangeRenderer'
|
|
17
18
|
import type {
|
|
18
19
|
PodEnvironmentResponse,
|
|
19
20
|
PodEnvironmentRevealResponse,
|
|
@@ -114,6 +115,9 @@ interface PodRendererProps {
|
|
|
114
115
|
policyData?: PolicyResourceResponse | null
|
|
115
116
|
policyLoading?: boolean
|
|
116
117
|
policyError?: Error | null
|
|
118
|
+
/** Names of the LimitRanges in this Pod's namespace. Absent while the lookup
|
|
119
|
+
* is pending or unreadable, which leaves the contextual link unrendered. */
|
|
120
|
+
namespaceLimitRangeNames?: string[] | null
|
|
117
121
|
}
|
|
118
122
|
|
|
119
123
|
// ── Env vars section — extracted to use hooks (useState for reveal) ──────────
|
|
@@ -296,6 +300,7 @@ export function PodRenderer({
|
|
|
296
300
|
policyData,
|
|
297
301
|
policyLoading,
|
|
298
302
|
policyError,
|
|
303
|
+
namespaceLimitRangeNames,
|
|
299
304
|
}: PodRendererProps) {
|
|
300
305
|
const containerStatuses = data.status?.containerStatuses || []
|
|
301
306
|
const containers = data.spec?.containers || []
|
|
@@ -763,6 +768,12 @@ export function PodRenderer({
|
|
|
763
768
|
)
|
|
764
769
|
})}
|
|
765
770
|
</div>
|
|
771
|
+
<NamespaceLimitRangeLink
|
|
772
|
+
namespace={namespace || ''}
|
|
773
|
+
names={namespaceLimitRangeNames}
|
|
774
|
+
scope="pod"
|
|
775
|
+
onNavigate={onNavigate}
|
|
776
|
+
/>
|
|
766
777
|
</Section>
|
|
767
778
|
|
|
768
779
|
{/* Environment Variables */}
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { ShieldAlert, ShieldCheck, ShieldQuestion } from 'lucide-react'
|
|
2
2
|
import { clsx } from 'clsx'
|
|
3
3
|
import { useState } from 'react'
|
|
4
|
-
import { ChevronDown, ChevronRight } from 'lucide-react'
|
|
5
4
|
import { Section } from '../../ui/drawer-components'
|
|
5
|
+
import { Collapse, CollapseChevron, useDisclosure } from '../../ui/Collapse'
|
|
6
6
|
import { SEVERITY_BADGE } from '../../../utils/badge-colors'
|
|
7
7
|
import { isForbiddenError } from '../../../types/fetch-error'
|
|
8
8
|
import { LookupFailureNote } from './LookupFailureNote'
|
|
@@ -829,6 +829,7 @@ function PassingGroup({
|
|
|
829
829
|
}) {
|
|
830
830
|
const [open, setOpen] = useState(false)
|
|
831
831
|
const [showAll, setShowAll] = useState(false)
|
|
832
|
+
const { panelId, buttonProps } = useDisclosure(open)
|
|
832
833
|
|
|
833
834
|
// Nothing to open: the count is real but the subjects did not survive the
|
|
834
835
|
// server cap, so the label stands alone rather than offering an empty drawer.
|
|
@@ -843,14 +844,16 @@ function PassingGroup({
|
|
|
843
844
|
return (
|
|
844
845
|
<div className="pt-0.5">
|
|
845
846
|
<button
|
|
847
|
+
{...buttonProps}
|
|
846
848
|
type="button"
|
|
847
849
|
onClick={() => setOpen(!open)}
|
|
848
850
|
className="flex items-center gap-1 text-xs text-theme-text-tertiary hover:text-theme-text-secondary"
|
|
849
851
|
>
|
|
850
|
-
|
|
852
|
+
<CollapseChevron open={open} className="w-3 h-3" />
|
|
851
853
|
{label}
|
|
852
854
|
</button>
|
|
853
|
-
{
|
|
855
|
+
{/* One of these per rule; the subject list only renders once opened. */}
|
|
856
|
+
<Collapse open={open} mountLazily id={panelId}>
|
|
854
857
|
<div className="space-y-1.5 mt-1.5">
|
|
855
858
|
{visible.map((s, i) => (
|
|
856
859
|
<SubjectRow
|
|
@@ -891,7 +894,7 @@ function PassingGroup({
|
|
|
891
894
|
</div>
|
|
892
895
|
)}
|
|
893
896
|
</div>
|
|
894
|
-
|
|
897
|
+
</Collapse>
|
|
895
898
|
</div>
|
|
896
899
|
)
|
|
897
900
|
}
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { useState, useMemo } from 'react'
|
|
2
|
-
import { Bell, Search
|
|
2
|
+
import { Bell, Search } from 'lucide-react'
|
|
3
3
|
import { clsx } from 'clsx'
|
|
4
4
|
import { Section, PropertyList, Property, ConditionsSection } from '../../ui/drawer-components'
|
|
5
|
+
import { Collapse, CollapseChevron, useDisclosure } from '../../ui/Collapse'
|
|
5
6
|
import { Input } from '../../ui/Input'
|
|
6
7
|
import { BADGE_SEVERITY_COLORS } from '../../ui/Badge'
|
|
7
8
|
import {
|
|
@@ -116,6 +117,7 @@ function RuleGroupSection({ group, searchTerm }: { group: PrometheusRuleGroup; s
|
|
|
116
117
|
|
|
117
118
|
// Auto-expand when searching so matched rules are visible
|
|
118
119
|
const expanded = searchTerm ? filteredRules.length > 0 : manualExpanded
|
|
120
|
+
const { panelId, buttonProps } = useDisclosure(expanded)
|
|
119
121
|
|
|
120
122
|
// If searching and no matches, hide the group entirely
|
|
121
123
|
if (searchTerm && filteredRules.length === 0) return null
|
|
@@ -123,11 +125,12 @@ function RuleGroupSection({ group, searchTerm }: { group: PrometheusRuleGroup; s
|
|
|
123
125
|
return (
|
|
124
126
|
<div className="card-inner">
|
|
125
127
|
<button
|
|
128
|
+
{...buttonProps}
|
|
126
129
|
onClick={() => setManualExpanded(!manualExpanded)}
|
|
127
130
|
className="flex items-center justify-between w-full text-left"
|
|
128
131
|
>
|
|
129
132
|
<div className="flex items-center gap-2">
|
|
130
|
-
<
|
|
133
|
+
<CollapseChevron open={expanded} className="w-3.5 h-3.5" />
|
|
131
134
|
<span className="text-sm text-theme-text-primary font-medium">{group.name}</span>
|
|
132
135
|
</div>
|
|
133
136
|
<div className="flex items-center gap-2">
|
|
@@ -147,20 +150,15 @@ function RuleGroupSection({ group, searchTerm }: { group: PrometheusRuleGroup; s
|
|
|
147
150
|
{group.recordCount > 0 && <span>{group.recordCount} recording</span>}
|
|
148
151
|
</div>
|
|
149
152
|
)}
|
|
150
|
-
<
|
|
151
|
-
className="
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
rule.type === 'alert'
|
|
158
|
-
? <AlertRuleCard key={`alert-${rule.alert}-${i}`} rule={rule} />
|
|
159
|
-
: <RecordingRuleCard key={`rec-${rule.record}-${i}`} rule={rule} />
|
|
160
|
-
))}
|
|
161
|
-
</div>
|
|
153
|
+
<Collapse open={expanded} id={panelId}>
|
|
154
|
+
<div className="mt-2 space-y-2">
|
|
155
|
+
{filteredRules.map((rule, i) => (
|
|
156
|
+
rule.type === 'alert'
|
|
157
|
+
? <AlertRuleCard key={`alert-${rule.alert}-${i}`} rule={rule} />
|
|
158
|
+
: <RecordingRuleCard key={`rec-${rule.record}-${i}`} rule={rule} />
|
|
159
|
+
))}
|
|
162
160
|
</div>
|
|
163
|
-
</
|
|
161
|
+
</Collapse>
|
|
164
162
|
</div>
|
|
165
163
|
)
|
|
166
164
|
}
|
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { renderToStaticMarkup } from "react-dom/server";
|
|
3
|
+
import { ReflectorSection, ReflectorSummary } from "./ReflectorSection";
|
|
4
|
+
import { ResourceRendererDispatch } from "../../shared/ResourceRendererDispatch";
|
|
5
|
+
import type { Relationships } from "../../../types";
|
|
6
|
+
|
|
7
|
+
const prefix = "reflector.v1.k8s.emberstack.com/";
|
|
8
|
+
const source = { kind: "ConfigMap", namespace: "source", name: "settings" };
|
|
9
|
+
const mirror = { kind: "ConfigMap", namespace: "app", name: "mirror" };
|
|
10
|
+
function resource(annotations: Record<string, string> = {}) {
|
|
11
|
+
return {
|
|
12
|
+
apiVersion: "v1",
|
|
13
|
+
metadata: {
|
|
14
|
+
namespace: "app",
|
|
15
|
+
name: "mirror",
|
|
16
|
+
annotations: Object.fromEntries(
|
|
17
|
+
Object.entries(annotations).map(([k, v]) => [prefix + k, v]),
|
|
18
|
+
),
|
|
19
|
+
},
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
function render(
|
|
23
|
+
annotations: Record<string, string>,
|
|
24
|
+
reflection?: Relationships["reflection"],
|
|
25
|
+
) {
|
|
26
|
+
return renderToStaticMarkup(
|
|
27
|
+
<>
|
|
28
|
+
<ReflectorSummary
|
|
29
|
+
data={resource(annotations)}
|
|
30
|
+
reflection={reflection}
|
|
31
|
+
onNavigate={() => {}}
|
|
32
|
+
/>
|
|
33
|
+
<ReflectorSection
|
|
34
|
+
data={resource(annotations)}
|
|
35
|
+
reflection={reflection}
|
|
36
|
+
onNavigate={() => {}}
|
|
37
|
+
/>
|
|
38
|
+
</>,
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
describe("Reflector evidence and copy", () => {
|
|
43
|
+
it("leaves ordinary objects and non-core API groups alone", () => {
|
|
44
|
+
expect(render({})).toBe("");
|
|
45
|
+
expect(render({ unrelated: "true" })).toBe("");
|
|
46
|
+
expect(
|
|
47
|
+
renderToStaticMarkup(
|
|
48
|
+
<ReflectorSection
|
|
49
|
+
data={{
|
|
50
|
+
...resource({ "reflection-allowed": "true" }),
|
|
51
|
+
apiVersion: "example.io/v1",
|
|
52
|
+
}}
|
|
53
|
+
/>,
|
|
54
|
+
),
|
|
55
|
+
).toBe("");
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("shows source settings with OR semantics and bounded visible mirrors", () => {
|
|
59
|
+
const mirrors = Array.from({ length: 12 }, (_, i) => ({
|
|
60
|
+
...mirror,
|
|
61
|
+
name: `mirror-${i}`,
|
|
62
|
+
}));
|
|
63
|
+
const html = render(
|
|
64
|
+
{
|
|
65
|
+
"reflection-allowed": "true",
|
|
66
|
+
"reflection-auto-enabled": "true",
|
|
67
|
+
"reflection-allowed-namespaces": "prod-.*",
|
|
68
|
+
"reflection-allowed-namespaces-selector": "team=app",
|
|
69
|
+
},
|
|
70
|
+
{ mirrors },
|
|
71
|
+
);
|
|
72
|
+
expect(html).toContain("Source");
|
|
73
|
+
expect(html).toContain("Name patterns:");
|
|
74
|
+
expect(html).toContain(">or</span>");
|
|
75
|
+
expect(html).toContain("Label selector:");
|
|
76
|
+
expect(html).toContain("Show all 12 visible mirrors");
|
|
77
|
+
expect(html).toContain("app/mirror-4");
|
|
78
|
+
expect(html).not.toContain("app/mirror-5");
|
|
79
|
+
expect(html).toContain("other mirrors may exist");
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it.each(["True", "TRUE", " true "])(
|
|
83
|
+
"accepts controller boolean %s and raw .NET timestamp",
|
|
84
|
+
(automatic) => {
|
|
85
|
+
const html = render(
|
|
86
|
+
{
|
|
87
|
+
reflects: "source/settings",
|
|
88
|
+
"auto-reflects": automatic,
|
|
89
|
+
"reflected-at": "2026-09-13T12:34:56.1234567+00:00",
|
|
90
|
+
},
|
|
91
|
+
{ source },
|
|
92
|
+
);
|
|
93
|
+
expect(html).toContain("Automatic mirror");
|
|
94
|
+
expect(html).toContain("2026-09-13T12:34:56.1234567+00:00");
|
|
95
|
+
expect(html).not.toContain("Check reflection configuration");
|
|
96
|
+
},
|
|
97
|
+
);
|
|
98
|
+
|
|
99
|
+
it("keeps manual pre-copy state neutral and offers only observed navigation", () => {
|
|
100
|
+
const html = render({ reflects: "source/settings" }, { source });
|
|
101
|
+
expect(html).toContain("Manual mirror");
|
|
102
|
+
expect(html).toContain("No version recorded");
|
|
103
|
+
expect(html).toContain("source/settings</button>");
|
|
104
|
+
expect(html).not.toContain("Check reflection configuration");
|
|
105
|
+
expect(html).toContain("Edit the source for lasting changes");
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it("does not turn a hidden source into missing, a link, or version evidence", () => {
|
|
109
|
+
const html = render(
|
|
110
|
+
{ reflects: "hidden/settings", "reflected-version": "41" },
|
|
111
|
+
{ source, sourceResourceVersion: "42" },
|
|
112
|
+
);
|
|
113
|
+
expect(html).toContain("hidden/settings");
|
|
114
|
+
expect(html).toContain("not available in this view");
|
|
115
|
+
expect(html).not.toContain("hidden/settings</button>");
|
|
116
|
+
expect(html).not.toContain("Source version (snapshot)");
|
|
117
|
+
expect(html).not.toContain("missing");
|
|
118
|
+
expect(html).not.toContain("Check reflection configuration");
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it.each([
|
|
122
|
+
["opaque-x", "opaque-x", "matches the source snapshot"],
|
|
123
|
+
["opaque-x", "opaque-y", "differs from the source snapshot"],
|
|
124
|
+
])(
|
|
125
|
+
"reports version equality without contents or health claims",
|
|
126
|
+
(copied, current, message) => {
|
|
127
|
+
const html = render(
|
|
128
|
+
{ reflects: "source/settings", "reflected-version": copied },
|
|
129
|
+
{ source, sourceResourceVersion: current },
|
|
130
|
+
);
|
|
131
|
+
expect(html).toContain(message);
|
|
132
|
+
expect(html).toContain("do not prove matching contents");
|
|
133
|
+
expect(html).not.toContain("Synced");
|
|
134
|
+
expect(html).not.toContain("Check reflection configuration");
|
|
135
|
+
},
|
|
136
|
+
);
|
|
137
|
+
|
|
138
|
+
it.each(["", "missing-namespace", "ns/name/extra"])(
|
|
139
|
+
"flags malformed declarations (%s)",
|
|
140
|
+
(reflects) => {
|
|
141
|
+
expect(render({ reflects })).toContain(
|
|
142
|
+
"must name a source as namespace/name",
|
|
143
|
+
);
|
|
144
|
+
},
|
|
145
|
+
);
|
|
146
|
+
|
|
147
|
+
it("flags self reference and contradictory auto-source settings", () => {
|
|
148
|
+
expect(render({ reflects: "app/mirror" })).toContain(
|
|
149
|
+
"points to this object itself",
|
|
150
|
+
);
|
|
151
|
+
expect(render({ "reflection-auto-enabled": "true" })).toContain(
|
|
152
|
+
"requires reflection-allowed to be true",
|
|
153
|
+
);
|
|
154
|
+
expect(render({ "reflection-allowed": "yes" })).toContain(
|
|
155
|
+
"must be true or false",
|
|
156
|
+
);
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it("explains a declared chain without dropping either direction", () => {
|
|
160
|
+
const html = render(
|
|
161
|
+
{ reflects: "source/settings", "reflection-allowed": "true" },
|
|
162
|
+
{ source, mirrors: [{ ...mirror, namespace: "leaf" }] },
|
|
163
|
+
);
|
|
164
|
+
expect(html).toContain("source/settings</button>");
|
|
165
|
+
expect(html).toContain("leaf/mirror</button>");
|
|
166
|
+
expect(html).toContain("does not propagate its updates");
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it("shows only explicit reflection fields rather than inferring from kind equality", () => {
|
|
170
|
+
const html = renderToStaticMarkup(
|
|
171
|
+
<ResourceRendererDispatch
|
|
172
|
+
resource={{ kind: "configmaps", namespace: "app", name: "mirror" }}
|
|
173
|
+
data={resource()}
|
|
174
|
+
relationships={{ consumers: [mirror] }}
|
|
175
|
+
onCopy={() => {}}
|
|
176
|
+
copied={null}
|
|
177
|
+
showCommonSections={false}
|
|
178
|
+
/>,
|
|
179
|
+
);
|
|
180
|
+
expect(html).not.toContain("Reflector");
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it.each(["configmaps", "secrets"])(
|
|
184
|
+
"wires the %s renderer and avoids duplicate generic mirror links",
|
|
185
|
+
(kind) => {
|
|
186
|
+
const ref = {
|
|
187
|
+
...source,
|
|
188
|
+
kind: kind === "secrets" ? "Secret" : "ConfigMap",
|
|
189
|
+
};
|
|
190
|
+
const html = renderToStaticMarkup(
|
|
191
|
+
<ResourceRendererDispatch
|
|
192
|
+
resource={{ kind, namespace: "app", name: "mirror" }}
|
|
193
|
+
data={{ ...resource({ reflects: "source/settings" }), data: {} }}
|
|
194
|
+
relationships={{ configRefs: [ref], reflection: { source: ref } }}
|
|
195
|
+
onCopy={() => {}}
|
|
196
|
+
copied={null}
|
|
197
|
+
onNavigate={() => {}}
|
|
198
|
+
/>,
|
|
199
|
+
);
|
|
200
|
+
expect(html).toContain("Reflector");
|
|
201
|
+
expect(html).toContain("source/settings</button>");
|
|
202
|
+
expect(html).not.toContain("Related Resources");
|
|
203
|
+
expect(html).not.toContain("Check reflection configuration");
|
|
204
|
+
},
|
|
205
|
+
);
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
describe("Reflector configuration diagnostics", () => {
|
|
209
|
+
it("does not describe a malformed reference as an active mirror chain", () => {
|
|
210
|
+
const html = render({
|
|
211
|
+
reflects: "missing-namespace",
|
|
212
|
+
"reflection-allowed": "true",
|
|
213
|
+
});
|
|
214
|
+
expect(html).toContain("Invalid source reference");
|
|
215
|
+
expect(html).not.toContain("does not propagate");
|
|
216
|
+
expect(html).not.toContain("Edit the source for lasting changes");
|
|
217
|
+
});
|
|
218
|
+
it("warns when visible mirrors reference a source that no longer allows reflection", () => {
|
|
219
|
+
const html = render({}, { mirrors: [mirror] });
|
|
220
|
+
expect(html).toContain("Reflection is not enabled on this source");
|
|
221
|
+
});
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
describe("Reflector resource hierarchy", () => {
|
|
225
|
+
it("keeps ConfigMap data ahead of detailed settings and source mirror list", () => {
|
|
226
|
+
const html = renderToStaticMarkup(
|
|
227
|
+
<ResourceRendererDispatch
|
|
228
|
+
kind="configmaps"
|
|
229
|
+
data={{
|
|
230
|
+
...resource({ "reflection-allowed": "true" }),
|
|
231
|
+
data: { setting: "value" },
|
|
232
|
+
}}
|
|
233
|
+
resource={{ kind: "configmaps", namespace: "app", name: "mirror" }}
|
|
234
|
+
relationships={{ reflection: { mirrors: [mirror] } }}
|
|
235
|
+
/>,
|
|
236
|
+
);
|
|
237
|
+
expect(html.indexOf("Data (1 keys)")).toBeLessThan(
|
|
238
|
+
html.indexOf("Reflector (1 visible mirror)"),
|
|
239
|
+
);
|
|
240
|
+
expect(html).not.toContain("Reflector mirror</");
|
|
241
|
+
});
|
|
242
|
+
it("puts TLS expiry and mirror provenance ahead of data, with details below", () => {
|
|
243
|
+
const html = renderToStaticMarkup(
|
|
244
|
+
<ResourceRendererDispatch
|
|
245
|
+
kind="secrets"
|
|
246
|
+
data={{
|
|
247
|
+
...resource({ reflects: "source/settings" }),
|
|
248
|
+
type: "kubernetes.io/tls",
|
|
249
|
+
data: { "tls.crt": "dGVzdA==" },
|
|
250
|
+
}}
|
|
251
|
+
resource={{ kind: "secrets", namespace: "app", name: "mirror" }}
|
|
252
|
+
relationships={{
|
|
253
|
+
reflection: { source: { ...source, kind: "Secret" } },
|
|
254
|
+
}}
|
|
255
|
+
certificateInfo={
|
|
256
|
+
{
|
|
257
|
+
certificates: [
|
|
258
|
+
{
|
|
259
|
+
serialNumber: "fixture",
|
|
260
|
+
expired: true,
|
|
261
|
+
daysLeft: -1,
|
|
262
|
+
notAfter: "2026-01-01T00:00:00Z",
|
|
263
|
+
notBefore: "2025-01-01T00:00:00Z",
|
|
264
|
+
subject: "test",
|
|
265
|
+
issuer: "test",
|
|
266
|
+
dnsNames: [],
|
|
267
|
+
},
|
|
268
|
+
],
|
|
269
|
+
} as any
|
|
270
|
+
}
|
|
271
|
+
/>,
|
|
272
|
+
);
|
|
273
|
+
expect(html.indexOf("Certificate has expired")).toBeLessThan(
|
|
274
|
+
html.indexOf("Reflector mirror"),
|
|
275
|
+
);
|
|
276
|
+
expect(html.indexOf("Reflector mirror")).toBeLessThan(
|
|
277
|
+
html.indexOf(">Data</"),
|
|
278
|
+
);
|
|
279
|
+
expect(html.indexOf(">Data</")).toBeLessThan(html.indexOf(">Reflector</"));
|
|
280
|
+
});
|
|
281
|
+
});
|