@skyhook-io/k8s-ui 1.14.5 → 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 +13 -0
- package/src/components/charts/AreaChart.tsx +28 -25
- package/src/components/charts/PrometheusChartsView.tsx +12 -0
- 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 +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/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/ConfigMapRenderer.tsx +8 -1
- package/src/components/resources/renderers/ExperimentRenderer.tsx +179 -0
- 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 +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 +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/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/workload/WorkloadView.tsx +2 -2
- package/src/theme/components.css +16 -0
- 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/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
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { hasReflectorDetails } from '../resources/renderers/ReflectorSection'
|
|
1
2
|
import { clsx } from 'clsx'
|
|
2
3
|
import { SEVERITY_BADGE, HEALTH_BADGE_COLORS } from '../../utils/badge-colors'
|
|
3
4
|
import { isArgoRolloutResource } from '../../utils/workload-rollout'
|
|
@@ -120,6 +121,8 @@ import {
|
|
|
120
121
|
PVCRenderer,
|
|
121
122
|
RolloutRenderer,
|
|
122
123
|
AnalysisRunRenderer,
|
|
124
|
+
AnalysisTemplateRenderer,
|
|
125
|
+
ExperimentRenderer,
|
|
123
126
|
CertificateRenderer,
|
|
124
127
|
WorkflowRenderer,
|
|
125
128
|
PersistentVolumeRenderer,
|
|
@@ -231,6 +234,7 @@ import {
|
|
|
231
234
|
PriorityClassRenderer,
|
|
232
235
|
RuntimeClassRenderer,
|
|
233
236
|
LeaseRenderer,
|
|
237
|
+
LimitRangeRenderer,
|
|
234
238
|
TraefikIngressRouteRenderer,
|
|
235
239
|
TraefikMiddlewareRenderer,
|
|
236
240
|
TraefikServiceRenderer,
|
|
@@ -426,7 +430,7 @@ const KNOWN_KINDS = new Set([
|
|
|
426
430
|
'pods', 'deployments', 'statefulsets', 'daemonsets', 'replicasets',
|
|
427
431
|
'services', 'endpointslices', 'ingresses', 'configmaps', 'secrets', 'jobs', 'cronjobs', 'cronworkflows',
|
|
428
432
|
'hpas', 'horizontalpodautoscalers', 'nodes', 'persistentvolumeclaims',
|
|
429
|
-
'rollouts', 'analysisruns', 'certificates', 'workflows', 'persistentvolumes',
|
|
433
|
+
'rollouts', 'analysisruns', 'analysistemplates', 'clusteranalysistemplates', 'experiments', 'certificates', 'workflows', 'persistentvolumes',
|
|
430
434
|
'storageclasses', 'certificaterequests', 'clusterissuers', 'issuers',
|
|
431
435
|
'orders', 'challenges',
|
|
432
436
|
'gateways', 'gatewayclasses', 'httproutes', 'grpcroutes', 'tcproutes', 'tlsroutes', 'sealedsecrets', 'workflowtemplates', 'clusterworkflowtemplates',
|
|
@@ -473,7 +477,7 @@ const KNOWN_KINDS = new Set([
|
|
|
473
477
|
'virtualservices', 'destinationrules', 'serviceentries',
|
|
474
478
|
'peerauthentications', 'authorizationpolicies',
|
|
475
479
|
'mutatingwebhookconfigurations', 'validatingwebhookconfigurations',
|
|
476
|
-
'ingressclasses', 'priorityclasses', 'runtimeclasses', 'leases',
|
|
480
|
+
'ingressclasses', 'priorityclasses', 'runtimeclasses', 'leases', 'limitranges',
|
|
477
481
|
'knativeservices', 'knativeconfigurations', 'knativerevisions', 'knativeroutes',
|
|
478
482
|
'brokers', 'triggers', 'eventtypes', 'pingsources', 'apiserversources', 'containersources', 'sinkbindings',
|
|
479
483
|
'channels', 'inmemorychannels', 'subscriptions', 'sequences', 'parallels',
|
|
@@ -678,7 +682,8 @@ export function ResourceRendererDispatch({
|
|
|
678
682
|
kind === 'clusters' || kind === 'backups' || kind === 'scheduledbackups' || kind === 'poolers'
|
|
679
683
|
|| kind === 'objectstores' || kind === 'databases' || kind === 'publications'
|
|
680
684
|
|| kind === 'subscriptions' || kind === 'imagecatalogs' || kind === 'clusterimagecatalogs'
|
|
681
|
-
|| kind === 'policies' || kind === 'rollouts' || kind === '
|
|
685
|
+
|| kind === 'policies' || kind === 'rollouts' || kind === 'experiments'
|
|
686
|
+
|| kind === 'machines' || kind === 'machinesets'
|
|
682
687
|
const isCNPGApiVersion = isApiGroup(data?.apiVersion, CNPG_GROUP)
|
|
683
688
|
const groupGatedMatched =
|
|
684
689
|
(kind === 'clusters' && (isCNPGApiVersion || isApiGroup(data?.apiVersion, 'cluster.x-k8s.io')))
|
|
@@ -691,6 +696,11 @@ export function ResourceRendererDispatch({
|
|
|
691
696
|
&& (isCNPGApiVersion || isApiGroup(data?.apiVersion, 'messaging.knative.dev')))
|
|
692
697
|
|| (kind === 'policies' && isApiGroup(data?.apiVersion, 'kyverno.io'))
|
|
693
698
|
|| (kind === 'rollouts' && isArgoRolloutResource(data))
|
|
699
|
+
// Katib (kubeflow.org) ships its own, unrelated Experiment CRD sharing
|
|
700
|
+
// this plural — without this gate it got the Argo Rollouts Experiment
|
|
701
|
+
// renderer's mostly-empty status view instead of its actual resource
|
|
702
|
+
// details, the same collision shape as every other check in this block.
|
|
703
|
+
|| (kind === 'experiments' && isApiGroup(data?.apiVersion, 'argoproj.io'))
|
|
694
704
|
|| ((kind === 'machines' || kind === 'machinesets')
|
|
695
705
|
&& isApiGroup(data?.apiVersion, 'cluster.x-k8s.io'))
|
|
696
706
|
const groupGatedFallthrough = isGroupGatedKind && !groupGatedMatched
|
|
@@ -741,11 +751,15 @@ export function ResourceRendererDispatch({
|
|
|
741
751
|
const HPAComp = rendererOverrides?.HPARenderer ?? HPARenderer
|
|
742
752
|
const PVCComp = rendererOverrides?.PVCRenderer ?? PVCRenderer
|
|
743
753
|
const RolloutComp = rendererOverrides?.RolloutRenderer ?? RolloutRenderer
|
|
754
|
+
const showsReflection = (kind === 'configmaps' || kind === 'secrets') && hasReflectorDetails(data, relationships?.reflection)
|
|
755
|
+
const reflectionRefs = showsReflection ? [relationships?.reflection?.source, ...(relationships?.reflection?.mirrors ?? [])].filter((ref): ref is ResourceRef => !!ref) : []
|
|
756
|
+
const withoutReflection = (refs: ResourceRef[] | undefined) => refs?.filter(ref => !reflectionRefs.some(mirror => mirror.kind === ref.kind && mirror.namespace === ref.namespace && mirror.name === ref.name && (mirror.group ?? '') === (ref.group ?? '')))
|
|
757
|
+
const sidebarRelationships = showsReflection && relationships ? { ...relationships, configRefs: withoutReflection(relationships.configRefs), consumers: withoutReflection(relationships.consumers) } : relationships
|
|
744
758
|
const scaleBlockedBy = replicaScalers(relationships?.scalers)
|
|
745
759
|
|
|
746
760
|
const sidebarContent = showCommonSections && (
|
|
747
761
|
<>
|
|
748
|
-
<RelatedResourcesSection relationships={
|
|
762
|
+
<RelatedResourcesSection relationships={sidebarRelationships} onNavigate={onNavigate} />
|
|
749
763
|
{kind !== 'events' && <EventsSection events={events || []} updates={updates || []} isLoading={eventsLoading ?? false} eventsError={eventsError ?? null} updatesError={updatesError ?? null} hint={eventsHint} />}
|
|
750
764
|
<LabelsSection data={data} />
|
|
751
765
|
<AnnotationsSection data={data} />
|
|
@@ -773,8 +787,8 @@ export function ResourceRendererDispatch({
|
|
|
773
787
|
{kind === 'services' && !data?.apiVersion?.includes('serving.knative.dev') && <ServiceComp data={data} onCopy={onCopy} copied={copied} onNavigate={onNavigate} />}
|
|
774
788
|
{kind === 'endpointslices' && <EndpointSliceRenderer data={data} onNavigate={onNavigate} />}
|
|
775
789
|
{kind === 'ingresses' && !data?.apiVersion?.includes('networking.internal.knative.dev') && <IngressRenderer data={data} onNavigate={onNavigate} />}
|
|
776
|
-
{kind === 'configmaps' && <ConfigMapRenderer data={data} />}
|
|
777
|
-
{kind === 'secrets' && <SecretRenderer data={data} certificateInfo={certificateInfo} resourceData={data} onSaveSecretValue={onSaveSecretValue} isSaving={isSavingSecret} />}
|
|
790
|
+
{kind === 'configmaps' && <ConfigMapRenderer data={data} relationships={relationships} onNavigate={onNavigate} />}
|
|
791
|
+
{kind === 'secrets' && <SecretRenderer data={data} relationships={relationships} onNavigate={onNavigate} certificateInfo={certificateInfo} resourceData={data} onSaveSecretValue={onSaveSecretValue} isSaving={isSavingSecret} />}
|
|
778
792
|
{kind === 'jobs' && !nonCoreJobFallthrough && <JobRenderer data={data} />}
|
|
779
793
|
{kind === 'cronjobs' && <CronJobRenderer data={data} onNavigate={onNavigate} />}
|
|
780
794
|
{kind === 'cronworkflows' && <CronWorkflowRenderer data={data} onNavigate={onNavigate} />}
|
|
@@ -783,6 +797,8 @@ export function ResourceRendererDispatch({
|
|
|
783
797
|
{kind === 'persistentvolumeclaims' && <PVCComp data={data} onNavigate={onNavigate} />}
|
|
784
798
|
{kind === 'rollouts' && isArgoRolloutResource(data) && <RolloutComp data={data} onNavigate={onNavigate} />}
|
|
785
799
|
{kind === 'analysisruns' && <AnalysisRunRenderer data={data} onNavigate={onNavigate} />}
|
|
800
|
+
{(kind === 'analysistemplates' || kind === 'clusteranalysistemplates') && <AnalysisTemplateRenderer data={data} />}
|
|
801
|
+
{kind === 'experiments' && isApiGroup(data?.apiVersion, 'argoproj.io') && <ExperimentRenderer data={data} onNavigate={onNavigate} />}
|
|
786
802
|
{kind === 'certificates' && !data?.apiVersion?.includes('networking.internal.knative.dev') && <CertificateRenderer data={data} />}
|
|
787
803
|
{kind === 'workflows' && <WorkflowRenderer data={data} onNavigate={onNavigate} />}
|
|
788
804
|
{kind === 'persistentvolumes' && <PersistentVolumeRenderer data={data} onNavigate={onNavigate} />}
|
|
@@ -920,6 +936,7 @@ export function ResourceRendererDispatch({
|
|
|
920
936
|
{kind === 'priorityclasses' && <PriorityClassRenderer data={data} />}
|
|
921
937
|
{kind === 'runtimeclasses' && <RuntimeClassRenderer data={data} />}
|
|
922
938
|
{kind === 'leases' && <LeaseRenderer data={data} />}
|
|
939
|
+
{kind === 'limitranges' && <LimitRangeRenderer data={data} />}
|
|
923
940
|
{/* Knative Serving */}
|
|
924
941
|
{(kind === 'services' && data?.apiVersion?.includes('serving.knative.dev')) && <KnativeServiceRenderer data={data} onNavigate={onNavigate} />}
|
|
925
942
|
{kind === 'knativeservices' && <KnativeServiceRenderer data={data} onNavigate={onNavigate} />}
|
|
@@ -1103,6 +1120,10 @@ export function getResourceStatus(kind: string, data: any): { text: string; colo
|
|
|
1103
1120
|
if (k === 'nodes') return getNodeStatus(data)
|
|
1104
1121
|
if (k === 'persistentvolumeclaims') return getPVCStatus(data)
|
|
1105
1122
|
if (k === 'analysisruns') return getAnalysisRunStatus(data)
|
|
1123
|
+
// Same collision guard as the render branch above — Katib's unrelated
|
|
1124
|
+
// Experiment CRD (kubeflow.org) shares this plural and doesn't report the
|
|
1125
|
+
// AnalysisPhase vocabulary getAnalysisRunStatus expects.
|
|
1126
|
+
if (k === 'experiments' && isApiGroup(data?.apiVersion, 'argoproj.io')) return getAnalysisRunStatus(data)
|
|
1106
1127
|
if (k === 'workflows') return getWorkflowStatus(data)
|
|
1107
1128
|
if (k === 'cronworkflows') return getCronWorkflowStatus(data)
|
|
1108
1129
|
if (k === 'certificates') {
|
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
export { ResourceRendererDispatch, getResourceStatus, type RendererOverrides } from './ResourceRendererDispatch'
|
|
2
2
|
export { EditableYamlView, SaveSuccessAnimation } from './EditableYamlView'
|
|
3
3
|
export { ResourceActionsBar, RevisionHistoryDialog, isRolloutKind } from './ResourceActionsBar'
|
|
4
|
+
export {
|
|
5
|
+
DrainPlanDialog, DrainPlanContent, canConfirmDrain, planMatches, emptyDirPodsAtRisk, DEFAULT_DRAIN_DIALOG_OPTIONS,
|
|
6
|
+
type DrainPlan, type DrainPlanPod, type DrainOutcome, type DrainDialogOptions,
|
|
7
|
+
} from './DrainPlanDialog'
|
|
4
8
|
export { SetImageDialog, type ManagedImageSource, type SetImageDialogProps } from './SetImageDialog'
|
|
5
9
|
export { CreateResourceDialog, type CreateResourceDialogProps, type ApplyResult } from './CreateResourceDialog'
|
|
6
10
|
export { HelmManagedByChip, ManagedByChip, type HelmOwnerRef } from './ManagedByChip'
|
|
@@ -2,8 +2,8 @@ import { describe, expect, it } from 'vitest'
|
|
|
2
2
|
import { renderToString } from 'react-dom/server'
|
|
3
3
|
import { FetchResult } from './FetchResult'
|
|
4
4
|
|
|
5
|
-
function shaped(message: string, status: number) {
|
|
6
|
-
return Object.assign(new Error(message), { status })
|
|
5
|
+
function shaped(message: string, status: number, data?: Record<string, unknown>) {
|
|
6
|
+
return Object.assign(new Error(message), { status, data })
|
|
7
7
|
}
|
|
8
8
|
|
|
9
9
|
describe('FetchResult', () => {
|
|
@@ -54,6 +54,24 @@ describe('FetchResult', () => {
|
|
|
54
54
|
expect(html).toContain('Resource cache not available')
|
|
55
55
|
})
|
|
56
56
|
|
|
57
|
+
it('does not blame the cluster for a kind_sync_failed 503', () => {
|
|
58
|
+
const html = renderToString(
|
|
59
|
+
<FetchResult
|
|
60
|
+
loading={false}
|
|
61
|
+
error={shaped('pods failed to load within the sync deadline', 503, { error_code: 'kind_sync_failed' })}
|
|
62
|
+
/>,
|
|
63
|
+
)
|
|
64
|
+
expect(html).not.toContain('Cluster unavailable')
|
|
65
|
+
expect(html).toContain('Couldn')
|
|
66
|
+
expect(html).toContain('pods failed to load within the sync deadline')
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
it('renders a Retry button only when onRetry is given', () => {
|
|
70
|
+
const err = shaped('internal server error', 500)
|
|
71
|
+
expect(renderToString(<FetchResult loading={false} error={err} onRetry={() => {}} />)).toContain('Retry')
|
|
72
|
+
expect(renderToString(<FetchResult loading={false} error={err} />)).not.toContain('Retry')
|
|
73
|
+
})
|
|
74
|
+
|
|
57
75
|
it('renders "Sign-in required" on 401', () => {
|
|
58
76
|
const html = renderToString(
|
|
59
77
|
<FetchResult loading={false} error={shaped('Unauthorized', 401)} />,
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { useState } from 'react'
|
|
2
|
-
import { ShieldOff, AlertTriangle, ServerCrash, LogIn, Copy, Check, type LucideIcon } from 'lucide-react'
|
|
2
|
+
import { ShieldOff, AlertTriangle, ServerCrash, LogIn, Copy, Check, RefreshCw, type LucideIcon } from 'lucide-react'
|
|
3
3
|
import { clsx } from 'clsx'
|
|
4
4
|
import { PaneLoader } from './PaneLoader'
|
|
5
5
|
import { isFetchError } from '../../types/fetch-error'
|
|
@@ -14,6 +14,7 @@ import { isFetchError } from '../../types/fetch-error'
|
|
|
14
14
|
// loading → <PaneLoader/>
|
|
15
15
|
// error (403) → "Access denied" + error.message
|
|
16
16
|
// error (404) → notFoundMessage + error.message
|
|
17
|
+
// error (503 sync failed) → "Couldn't load this view" + error.message
|
|
17
18
|
// error (503) → "Cluster unavailable" + error.message
|
|
18
19
|
// error (401) → "Sign-in required" (apiFetch redirects; fallback)
|
|
19
20
|
// error (other / no shape) → "Couldn't load this view" + error.message
|
|
@@ -34,6 +35,8 @@ interface FetchResultProps {
|
|
|
34
35
|
notFoundMessage?: string
|
|
35
36
|
/** Pin to parent height. Existing call sites use "h-32" or "h-full". */
|
|
36
37
|
className?: string
|
|
38
|
+
/** Renders a Retry button on the error surface. */
|
|
39
|
+
onRetry?: () => void
|
|
37
40
|
}
|
|
38
41
|
|
|
39
42
|
export function FetchResult({
|
|
@@ -41,6 +44,7 @@ export function FetchResult({
|
|
|
41
44
|
error,
|
|
42
45
|
notFoundMessage = 'Resource not found',
|
|
43
46
|
className = 'h-32',
|
|
47
|
+
onRetry,
|
|
44
48
|
}: FetchResultProps) {
|
|
45
49
|
if (loading) {
|
|
46
50
|
return <PaneLoader className={className} />
|
|
@@ -55,16 +59,17 @@ export function FetchResult({
|
|
|
55
59
|
</div>
|
|
56
60
|
)
|
|
57
61
|
}
|
|
58
|
-
return <ErrorSurface error={error} notFoundMessage={notFoundMessage} className={className} />
|
|
62
|
+
return <ErrorSurface error={error} notFoundMessage={notFoundMessage} className={className} onRetry={onRetry} />
|
|
59
63
|
}
|
|
60
64
|
|
|
61
65
|
interface ErrorSurfaceProps {
|
|
62
66
|
error: unknown
|
|
63
67
|
notFoundMessage: string
|
|
64
68
|
className: string
|
|
69
|
+
onRetry?: () => void
|
|
65
70
|
}
|
|
66
71
|
|
|
67
|
-
function ErrorSurface({ error, notFoundMessage, className }: ErrorSurfaceProps) {
|
|
72
|
+
function ErrorSurface({ error, notFoundMessage, className, onRetry }: ErrorSurfaceProps) {
|
|
68
73
|
const classified = classify(error, notFoundMessage)
|
|
69
74
|
const Icon = classified.icon
|
|
70
75
|
|
|
@@ -81,6 +86,16 @@ function ErrorSurface({ error, notFoundMessage, className }: ErrorSurfaceProps)
|
|
|
81
86
|
<CopyErrorButton text={classified.detail} />
|
|
82
87
|
</div>
|
|
83
88
|
)}
|
|
89
|
+
{onRetry && (
|
|
90
|
+
<button
|
|
91
|
+
type="button"
|
|
92
|
+
onClick={onRetry}
|
|
93
|
+
className="mt-1 flex items-center gap-1.5 rounded-md border border-theme-border bg-theme-elevated px-3 py-1.5 text-xs text-theme-text-secondary hover:bg-theme-hover hover:text-theme-text-primary"
|
|
94
|
+
>
|
|
95
|
+
<RefreshCw className="h-3.5 w-3.5" aria-hidden />
|
|
96
|
+
Retry
|
|
97
|
+
</button>
|
|
98
|
+
)}
|
|
84
99
|
</div>
|
|
85
100
|
)
|
|
86
101
|
}
|
|
@@ -101,6 +116,17 @@ function classify(error: unknown, notFoundMessage: string): Classified {
|
|
|
101
116
|
case 401:
|
|
102
117
|
return { headline: 'Sign-in required', detail: error.message, icon: LogIn }
|
|
103
118
|
case 503:
|
|
119
|
+
// The cluster is connected; one kind's informer missed its sync
|
|
120
|
+
// deadline, so "Cluster unavailable" would send the reader the wrong way.
|
|
121
|
+
if (errorCodeOf(error) === 'kind_sync_failed') {
|
|
122
|
+
return { headline: "Couldn't load this view", detail: error.message, icon: AlertTriangle }
|
|
123
|
+
}
|
|
124
|
+
// The cluster is healthy and still loading — the retry policies keep
|
|
125
|
+
// these surfaces polling, but a caller without one must not tell the
|
|
126
|
+
// user a connecting cluster is down.
|
|
127
|
+
if (errorCodeOf(error) === 'cluster_connecting' || errorCodeOf(error) === 'kind_sync_pending') {
|
|
128
|
+
return { headline: 'Cluster is still loading', detail: error.message, icon: AlertTriangle }
|
|
129
|
+
}
|
|
104
130
|
return { headline: 'Cluster unavailable', detail: error.message, icon: ServerCrash }
|
|
105
131
|
default:
|
|
106
132
|
return { headline: "Couldn't load this view", detail: error.message, icon: AlertTriangle }
|
|
@@ -110,6 +136,10 @@ function classify(error: unknown, notFoundMessage: string): Classified {
|
|
|
110
136
|
return { headline: "Couldn't load this view", detail: errorMessageOf(error), icon: AlertTriangle }
|
|
111
137
|
}
|
|
112
138
|
|
|
139
|
+
function errorCodeOf(error: unknown): unknown {
|
|
140
|
+
return (error as { data?: { error_code?: unknown } }).data?.error_code
|
|
141
|
+
}
|
|
142
|
+
|
|
113
143
|
function errorMessageOf(error: unknown): string | null {
|
|
114
144
|
if (error instanceof Error && error.message) return error.message
|
|
115
145
|
if (typeof error === 'string') return error
|
|
@@ -6,7 +6,10 @@ import { useRefreshAnimation } from '../../hooks/useRefreshAnimation'
|
|
|
6
6
|
import { formatUpdatedAgo, msToNextBucket } from '../../utils/format'
|
|
7
7
|
|
|
8
8
|
export type FreshnessMode = 'auto' | 'snapshot'
|
|
9
|
-
|
|
9
|
+
// 'syncing' is the progressive first-load shell: the app has a live link to
|
|
10
|
+
// the cluster and serves kinds as their initial sync completes — nothing was
|
|
11
|
+
// ever connected before, so 'Reconnecting…' would be untrue there.
|
|
12
|
+
export type FreshnessConnection = 'connected' | 'disconnected' | 'connecting' | 'syncing'
|
|
10
13
|
|
|
11
14
|
interface FreshnessControlProps {
|
|
12
15
|
// 'auto' — the view keeps itself current (polls or streams). Reads
|
|
@@ -85,7 +88,10 @@ export function FreshnessControl({
|
|
|
85
88
|
let label: string | null
|
|
86
89
|
let tooltip: string | null
|
|
87
90
|
let live = false
|
|
88
|
-
if (
|
|
91
|
+
if (connectionState === 'syncing') {
|
|
92
|
+
label = 'Loading cluster data…'
|
|
93
|
+
tooltip = 'Initial sync in progress — rows shown are live for the kinds that have finished loading.'
|
|
94
|
+
} else if (degraded) {
|
|
89
95
|
label = 'Reconnecting…'
|
|
90
96
|
tooltip = 'Not connected to the cluster — data may be stale until the connection is restored.'
|
|
91
97
|
} else if (mode === 'auto' && paused) {
|
|
@@ -36,7 +36,7 @@ import {
|
|
|
36
36
|
import type { TimelineEvent, ResourceRef, Relationships, SelectedResource, ResolvedEnvFrom, Topology, TopologyNode, HPADiagnosis, WorkloadPodInfo } from '../../types'
|
|
37
37
|
import type { GitOpsStatus } from '../../types/gitops'
|
|
38
38
|
import type { NavigateToResource } from '../../utils/navigation'
|
|
39
|
-
import { refToSelectedResource, pluralToKind, kindToPlural, kindToPluralWithGroup, apiVersionToGroup } from '../../utils/navigation'
|
|
39
|
+
import { refToSelectedResource, pluralToKind, knownKindForPluralWithGroup, kindToPlural, kindToPluralWithGroup, apiVersionToGroup } from '../../utils/navigation'
|
|
40
40
|
import { neighborhoodFor, seedNodeIds } from '../../utils/topology-neighborhood'
|
|
41
41
|
import { TopologyGraph } from '../topology/TopologyGraph'
|
|
42
42
|
import { gitOpsOwnerFromRelationships, type GitOpsOwnerRef } from '../../utils/gitops-owner'
|
|
@@ -731,7 +731,7 @@ export function WorkloadView({
|
|
|
731
731
|
| ((ctx: { kind: string; group?: string; namespace: string; name: string; health?: DiagnoseHealthHint }) => ReactNode)
|
|
732
732
|
| undefined
|
|
733
733
|
const diagnoseAction = renderDiagnose?.({
|
|
734
|
-
kind: apiKind,
|
|
734
|
+
kind: resource?.kind ?? knownKindForPluralWithGroup(apiKind, group ?? '') ?? apiKind,
|
|
735
735
|
group,
|
|
736
736
|
namespace,
|
|
737
737
|
name,
|
package/src/theme/components.css
CHANGED
|
@@ -95,6 +95,11 @@
|
|
|
95
95
|
border-style: solid;
|
|
96
96
|
border-color: transparent;
|
|
97
97
|
line-height: 1.25rem;
|
|
98
|
+
/* A badge sits in table cells the reader can drag narrower than the label.
|
|
99
|
+
Without a ceiling it keeps its intrinsic width and draws straight over
|
|
100
|
+
the next column, so two cells become unreadable instead of one. */
|
|
101
|
+
max-width: 100%;
|
|
102
|
+
overflow: hidden;
|
|
98
103
|
}
|
|
99
104
|
|
|
100
105
|
.badge-sm {
|
|
@@ -108,6 +113,17 @@
|
|
|
108
113
|
border-style: solid;
|
|
109
114
|
border-color: transparent;
|
|
110
115
|
line-height: 1.25rem;
|
|
116
|
+
max-width: 100%;
|
|
117
|
+
overflow: hidden;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/* The icon is the one part of a badge that must never be traded for text:
|
|
121
|
+
a 12px glyph squeezed to 5px reads as a rendering fault, and a severity
|
|
122
|
+
icon that shrinks is worse than one that is cropped away cleanly. Flex
|
|
123
|
+
would otherwise shrink it first, because it is the item with slack. */
|
|
124
|
+
.badge > svg,
|
|
125
|
+
.badge-sm > svg {
|
|
126
|
+
flex-shrink: 0;
|
|
111
127
|
}
|
|
112
128
|
|
|
113
129
|
/* ── INNER CARDS ── */
|
package/src/types/core.ts
CHANGED
|
@@ -631,6 +631,11 @@ export interface ResourceRef {
|
|
|
631
631
|
|
|
632
632
|
// Computed relationships for a resource
|
|
633
633
|
export interface Relationships {
|
|
634
|
+
reflection?: {
|
|
635
|
+
source?: ResourceRef
|
|
636
|
+
sourceResourceVersion?: string
|
|
637
|
+
mirrors?: ResourceRef[]
|
|
638
|
+
}
|
|
634
639
|
owner?: ResourceRef
|
|
635
640
|
deployment?: ResourceRef // Grandparent Deployment (for Pods owned by ReplicaSets)
|
|
636
641
|
managedBy?: ResourceRef[] // Topmost meaningful manager(s): GitOps controller (ArgoCD Application / Flux Kustomization / Flux HelmRelease), Helm release, or the topmost K8s owner. Synthesized server-side; replaces client-side detectGitOpsOwner.
|
|
@@ -1251,7 +1256,7 @@ export interface MetricsDataPoint {
|
|
|
1251
1256
|
export interface TrafficEndpoint {
|
|
1252
1257
|
name: string
|
|
1253
1258
|
namespace: string
|
|
1254
|
-
kind: string // Pod, Service, External
|
|
1259
|
+
kind: string // Pod, Service, External, Host, Unknown
|
|
1255
1260
|
ip?: string
|
|
1256
1261
|
labels?: Record<string, string>
|
|
1257
1262
|
workload?: string
|
|
@@ -1292,6 +1297,15 @@ export interface TrafficFlow {
|
|
|
1292
1297
|
* status code for no single flow. */
|
|
1293
1298
|
errorRate?: number
|
|
1294
1299
|
verdict: string // forwarded, dropped, error
|
|
1300
|
+
/** The network plugin's own account of which policies decided this flow
|
|
1301
|
+
* (Hubble reports it). Absent when the plugin said nothing. */
|
|
1302
|
+
policyVerdict?: {
|
|
1303
|
+
allowedBy?: { kind: string; namespace?: string; name: string }[]
|
|
1304
|
+
deniedBy?: { kind: string; namespace?: string; name: string }[]
|
|
1305
|
+
/** Denying references removed before delivery because the viewer may not
|
|
1306
|
+
* read policies of that kind there; the plugin still named a policy. */
|
|
1307
|
+
withheld?: number
|
|
1308
|
+
}
|
|
1295
1309
|
lastSeen: string // ISO date string
|
|
1296
1310
|
}
|
|
1297
1311
|
|
|
@@ -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
|
+
})
|