diff --git a/frontend/src/components/common/Resource/RestartButton.stories.tsx b/frontend/src/components/common/Resource/RestartButton.stories.tsx index 3376f84913f..6c10d052043 100644 --- a/frontend/src/components/common/Resource/RestartButton.stories.tsx +++ b/frontend/src/components/common/Resource/RestartButton.stories.tsx @@ -43,6 +43,7 @@ const mockDeployment = new Deployment({ }, spec: { replicas: 3, + selector: { matchLabels: { app: 'headlamp' } }, template: { spec: { nodeName: 'mock-node', diff --git a/frontend/src/components/common/Resource/ScaleButton.stories.tsx b/frontend/src/components/common/Resource/ScaleButton.stories.tsx index b7408a4ccd7..d5c2c810332 100644 --- a/frontend/src/components/common/Resource/ScaleButton.stories.tsx +++ b/frontend/src/components/common/Resource/ScaleButton.stories.tsx @@ -45,6 +45,7 @@ const mockDeployment = new Deployment({ }, spec: { replicas: 3, + selector: { matchLabels: { app: 'headlamp' } }, template: { spec: { nodeName: 'mock-node', diff --git a/frontend/src/components/cronjob/Details.tsx b/frontend/src/components/cronjob/Details.tsx index 421fb3c5fda..ff3c3f35c09 100644 --- a/frontend/src/components/cronjob/Details.tsx +++ b/frontend/src/components/cronjob/Details.tsx @@ -31,6 +31,7 @@ import { useParams } from 'react-router-dom'; import { apply } from '../../lib/k8s/api/v1/apply'; import CronJob from '../../lib/k8s/cronJob'; import Job from '../../lib/k8s/job'; +import { localeDate } from '../../lib/util'; import { clusterAction } from '../../redux/clusterActionSlice'; import { AppDispatch } from '../../redux/stores/store'; import ActionButton from '../common/ActionButton'; @@ -236,6 +237,16 @@ export default function CronJobDetails(props: { name: t('Schedule'), value: getSchedule(item, i18n.language), }, + { + name: t('Time Zone'), + value: item.spec.timeZone, + hide: !item.spec.timeZone, + }, + { + name: t('Concurrency Policy'), + value: item.spec.concurrencyPolicy, + hide: !item.spec.concurrencyPolicy, + }, { name: t('translation|Suspend'), value: (item.spec?.suspend ?? false).toString(), @@ -245,10 +256,32 @@ export default function CronJobDetails(props: { value: `${item.spec.startingDeadlineSeconds}s`, hide: !item.spec.startingDeadlineSeconds, }, + { + name: t('Successful Jobs History Limit'), + value: item.spec.successfulJobsHistoryLimit, + hide: item.spec.successfulJobsHistoryLimit === undefined, + }, + { + name: t('Failed Jobs History Limit'), + value: item.spec.failedJobsHistoryLimit, + hide: item.spec.failedJobsHistoryLimit === undefined, + }, { name: t('Last Schedule'), value: getLastScheduleTime(item), }, + { + name: t('Last Successful Time'), + value: item.status?.lastSuccessfulTime + ? localeDate(item.status.lastSuccessfulTime) + : '', + hide: !item.status?.lastSuccessfulTime, + }, + { + name: t('Active Jobs'), + value: item.status?.active?.length, + hide: !item.status?.active?.length, + }, ] } extraSections={cronJob => diff --git a/frontend/src/components/cronjob/__snapshots__/CronJobDetails.EveryAst.stories.storyshot b/frontend/src/components/cronjob/__snapshots__/CronJobDetails.EveryAst.stories.storyshot index 1b5f704c419..1e6eb440a8c 100644 --- a/frontend/src/components/cronjob/__snapshots__/CronJobDetails.EveryAst.stories.storyshot +++ b/frontend/src/components/cronjob/__snapshots__/CronJobDetails.EveryAst.stories.storyshot @@ -223,6 +223,20 @@ *

+
+ Concurrency Policy +
+
+ + Allow + +
@@ -237,6 +251,26 @@ false +
+ Successful Jobs History Limit +
+
+ 3 +
+
+ Failed Jobs History Limit +
+
+ 1 +
diff --git a/frontend/src/components/cronjob/__snapshots__/CronJobDetails.EveryMinute.stories.storyshot b/frontend/src/components/cronjob/__snapshots__/CronJobDetails.EveryMinute.stories.storyshot index 84edc0605c0..c53302aba23 100644 --- a/frontend/src/components/cronjob/__snapshots__/CronJobDetails.EveryMinute.stories.storyshot +++ b/frontend/src/components/cronjob/__snapshots__/CronJobDetails.EveryMinute.stories.storyshot @@ -223,6 +223,20 @@ * * * * *

+
+ Concurrency Policy +
+
+ + Allow + +
@@ -237,6 +251,26 @@ false +
+ Successful Jobs History Limit +
+
+ 3 +
+
+ Failed Jobs History Limit +
+
+ 1 +
diff --git a/frontend/src/components/horizontalPodAutoscaler/Details.tsx b/frontend/src/components/horizontalPodAutoscaler/Details.tsx index ef495be5115..b0803fc2fbd 100644 --- a/frontend/src/components/horizontalPodAutoscaler/Details.tsx +++ b/frontend/src/components/horizontalPodAutoscaler/Details.tsx @@ -17,6 +17,7 @@ import { useTranslation } from 'react-i18next'; import { useParams } from 'react-router-dom'; import HPA from '../../lib/k8s/hpa'; +import { localeDate } from '../../lib/util'; import Link from '../common/Link'; import { ConditionsSection } from '../common/Resource'; import { DetailsGrid } from '../common/Resource'; @@ -71,6 +72,11 @@ export default function HpaDetails(props: { name?: string; namespace?: string; c desiredReplicas: item.status.desiredReplicas, }), }, + { + name: t('translation|Last Scale Time'), + value: item.status.lastScaleTime ? localeDate(item.status.lastScaleTime) : '', + hide: !item.status.lastScaleTime, + }, ] } extraSections={item => diff --git a/frontend/src/components/horizontalPodAutoscaler/__snapshots__/HPADetails.Default.stories.storyshot b/frontend/src/components/horizontalPodAutoscaler/__snapshots__/HPADetails.Default.stories.storyshot index 094ba5f50ae..855f2e57d86 100644 --- a/frontend/src/components/horizontalPodAutoscaler/__snapshots__/HPADetails.Default.stories.storyshot +++ b/frontend/src/components/horizontalPodAutoscaler/__snapshots__/HPADetails.Default.stories.storyshot @@ -310,12 +310,12 @@ 10
Deployment pods
+
+ Last Scale Time +
+
+ + 2022-10-21T11:15:12.000Z + +
diff --git a/frontend/src/components/ingress/Details.tsx b/frontend/src/components/ingress/Details.tsx index bff6624f739..8c89f0999a6 100644 --- a/frontend/src/components/ingress/Details.tsx +++ b/frontend/src/components/ingress/Details.tsx @@ -217,8 +217,17 @@ export default function IngressDetails(props: { namespace={namespace} cluster={cluster} withEvents - extraInfo={ingress => - ingress && [ + extraInfo={ingress => { + if (!ingress) { + return null; + } + const addresses = ingress.getAddresses(); + return [ + { + name: t('translation|Address'), + value: addresses, + hide: !addresses, + }, { name: t('Default Backend'), value: getDefaultBackend(ingress), @@ -250,8 +259,8 @@ export default function IngressDetails(props: { ) : null, }, - ] - } + ]; + }} extraSections={item => [ { id: 'headlamp.ingress-rules', diff --git a/frontend/src/components/job/List.tsx b/frontend/src/components/job/List.tsx index 0f5222cd08d..a2442fb1045 100644 --- a/frontend/src/components/job/List.tsx +++ b/frontend/src/components/job/List.tsx @@ -114,9 +114,9 @@ export function JobsListRenderer(props: JobsListRendererProps) { } function sortByCompletions(job1: Job, job2: Job) { - const parallelismSorted = job1.spec.parallelism - job2.spec.parallelism; + const parallelismSorted = (job1.spec.parallelism ?? 0) - (job2.spec.parallelism ?? 0); if (parallelismSorted === 0) { - return job1.spec.completions - job2.spec.completions; + return (job1.spec.completions ?? 0) - (job2.spec.completions ?? 0); } return parallelismSorted; } diff --git a/frontend/src/components/networkpolicy/Details.tsx b/frontend/src/components/networkpolicy/Details.tsx index 8f7faf6bb95..5181709b877 100644 --- a/frontend/src/components/networkpolicy/Details.tsx +++ b/frontend/src/components/networkpolicy/Details.tsx @@ -65,8 +65,8 @@ export function NetworkPolicyDetails(props: { function PodSelector(props: { networkPolicy: NetworkPolicy }) { const { networkPolicy } = props; return prepareMatchLabelsAndExpressions( - networkPolicy.jsonData.spec?.podSelector?.matchLabels, - networkPolicy.jsonData.spec?.podSelector?.matchExpressions + networkPolicy.spec?.podSelector?.matchLabels, + networkPolicy.spec?.podSelector?.matchExpressions ); } @@ -243,17 +243,22 @@ export function NetworkPolicyDetails(props: { name: t('Pod Selector'), value: , }, + { + name: t('Policy Types'), + value: item.policyTypes.join(', '), + hide: !item.policyTypes.length, + }, ] } extraSections={item => item && [ { id: 'networkpolicy-ingress', - section: , + section: , }, { id: 'networkpolicy-egress', - section: , + section: , }, ] } diff --git a/frontend/src/components/networkpolicy/__snapshots__/Details.Default.stories.storyshot b/frontend/src/components/networkpolicy/__snapshots__/Details.Default.stories.storyshot index dfe7abbd4bd..2e3212e9d7f 100644 --- a/frontend/src/components/networkpolicy/__snapshots__/Details.Default.stories.storyshot +++ b/frontend/src/components/networkpolicy/__snapshots__/Details.Default.stories.storyshot @@ -178,12 +178,12 @@
Pod Selector

+
+ Policy Types +
+
+ + Ingress, Egress + +
diff --git a/frontend/src/components/node/Details.tsx b/frontend/src/components/node/Details.tsx index 62da23633f6..48bf3f15317 100644 --- a/frontend/src/components/node/Details.tsx +++ b/frontend/src/components/node/Details.tsx @@ -18,7 +18,7 @@ import { InlineIcon } from '@iconify/react'; import Box from '@mui/material/Box'; import Paper from '@mui/material/Paper'; import Typography from '@mui/material/Typography'; -import _ from 'lodash'; +import { cloneDeep, isEmpty } from 'lodash'; import { useSnackbar } from 'notistack'; import React, { useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; @@ -47,7 +47,12 @@ import ActionButton from '../common/ActionButton'; import ConfirmDialog from '../common/ConfirmDialog'; import { StatusLabelProps } from '../common/Label'; import { HeaderLabel, StatusLabel, ValueLabel } from '../common/Label'; -import { ConditionsSection, DetailsGrid, OwnedPodsSection } from '../common/Resource'; +import { + ConditionsSection, + DetailsGrid, + MetadataDictGrid, + OwnedPodsSection, +} from '../common/Resource'; import AuthVisible from '../common/Resource/AuthVisible'; import { SectionBox } from '../common/SectionBox'; import { NameValueTable } from '../common/SimpleTable'; @@ -104,7 +109,7 @@ export default function NodeDetails(props: { name?: string; cluster?: string }) function handleNodeScheduleState(node: Node, cordon: boolean) { setisUpdatingNodeScheduleProperty(true); - const cloneNode = _.cloneDeep(node); + const cloneNode = cloneDeep(node); cloneNode.spec.unschedulable = !cordon; dispatch( @@ -152,7 +157,7 @@ export default function NodeDetails(props: { name?: string; cluster?: string }) getDrainNodeStatus(cluster, nodeName); return; } - const cloneNode = _.cloneDeep(node); + const cloneNode = cloneDeep(node); cloneNode!.spec.unschedulable = !node!.spec.unschedulable; setNode(cloneNode); @@ -277,8 +282,22 @@ export default function NodeDetails(props: { name?: string; cluster?: string }) }, ]; }} - extraInfo={item => - item && [ + extraInfo={item => { + if (!item) return []; + const roles = item.getRoles(); + // The keys of interest are reported by the API in kebab-case. + const reportedKeys = ['cpu', 'memory', 'pods', 'ephemeral-storage']; + const pickResources = (res: { [key: string]: string } = {}) => + Object.fromEntries(reportedKeys.filter(key => res[key]).map(key => [key, res[key]])); + const capacity = pickResources(item.status?.capacity); + const allocatable = pickResources(item.status?.allocatable); + + return [ + { + name: t('translation|Roles'), + value: roles.join(', '), + hide: roles.length === 0, + }, { name: t('translation|Taints'), value: , @@ -296,8 +315,18 @@ export default function NodeDetails(props: { name?: string; cluster?: string }) value: item.spec.podCIDR, }, ...getAddresses(item), - ] - } + { + name: t('Capacity'), + value: , + hide: isEmpty(capacity), + }, + { + name: t('Allocatable'), + value: , + hide: isEmpty(allocatable), + }, + ]; + }} extraSections={item => item && [ { diff --git a/frontend/src/components/pod/Details.tsx b/frontend/src/components/pod/Details.tsx index c37eca16f0b..f689874338a 100644 --- a/frontend/src/components/pod/Details.tsx +++ b/frontend/src/components/pod/Details.tsx @@ -23,23 +23,26 @@ import Select from '@mui/material/Select'; import Switch from '@mui/material/Switch'; import { styled } from '@mui/system'; import { Terminal as XTerminal } from '@xterm/xterm'; -import _ from 'lodash'; +import { debounce, isEmpty } from 'lodash'; import React from 'react'; import { useTranslation } from 'react-i18next'; import { useLocation, useParams } from 'react-router-dom'; import { getDefaultContainer } from '../../helpers/podContainer'; import { KubeContainerStatus } from '../../lib/k8s/cluster'; import Pod from '../../lib/k8s/pod'; +import { localeDate } from '../../lib/util'; import { DefaultHeaderAction } from '../../redux/actionButtonsSlice'; import { EventStatus, HeadlampEventType, useEventCallback } from '../../redux/headlampEventSlice'; import { Activity } from '../activity/Activity'; import ActionButton from '../common/ActionButton'; import Link from '../common/Link'; import { LogViewer, LogViewerProps } from '../common/LogViewer'; +import { NameValueTableRow } from '../common/NameValueTable'; import { ConditionsSection, ContainersSection, DetailsGrid, + MetadataDictGrid, VolumeSection, } from '../common/Resource'; import AuthVisible from '../common/Resource/AuthVisible'; @@ -133,7 +136,7 @@ export function PodLogViewer(props: PodLogViewerProps) { } } - const debouncedSetState = _.debounce(setLogsDebounced, 500, options); + const debouncedSetState = debounce(setLogsDebounced, 500, options); React.useEffect(() => { const next = getDefaultContainer(item); if (next && !container) { @@ -587,11 +590,7 @@ export default function PodDetails(props: PodDetailsProps) { }, [podItem, launchTerminal, autoLaunchView]); function prepareExtraInfo(item: Pod | null) { - let extraInfo: { - name: string; - value: React.ReactNode; - hideLabel?: boolean; - }[] = []; + let extraInfo: (NameValueTableRow & { hideLabel?: boolean })[] = []; if (item) { extraInfo = [ { @@ -672,6 +671,61 @@ export default function PodDetails(props: PodDetailsProps) { name: t('Priority'), value: item.spec.priority, }, + { + name: t('Priority Class'), + value: item.spec.priorityClassName ? ( + + {item.spec.priorityClassName} + + ) : ( + '' + ), + hide: !item.spec.priorityClassName, + }, + { + name: t('Runtime Class'), + value: item.spec.runtimeClassName, + hide: !item.spec.runtimeClassName, + }, + { + name: t('Nominated Node'), + value: item.status.nominatedNodeName, + hide: !item.status.nominatedNodeName, + }, + { + name: t('Start Time'), + value: item.status.startTime ? localeDate(item.status.startTime) : '', + hide: !item.status.startTime, + }, + { + name: t('Termination Grace Period'), + value: + item.spec.terminationGracePeriodSeconds !== undefined + ? t('translation|{{ seconds }}s', { + seconds: item.spec.terminationGracePeriodSeconds, + }) + : '', + hide: item.spec.terminationGracePeriodSeconds === undefined, + }, + { + name: t('translation|Reason'), + value: item.status.reason, + hide: !item.status.reason, + }, + { + name: t('translation|Message'), + value: item.status.message, + hide: !item.status.message, + }, + { + name: t('Node Selectors'), + value: , + hide: isEmpty(item.spec.nodeSelector), + }, ]; } return extraInfo; diff --git a/frontend/src/components/pod/List.tsx b/frontend/src/components/pod/List.tsx index f5534eeb344..08ca79d6f25 100644 --- a/frontend/src/components/pod/List.tsx +++ b/frontend/src/components/pod/List.tsx @@ -42,7 +42,7 @@ function getPodStatus(pod: Pod) { if (phase === 'Failed') { status = 'error'; } else if (phase === 'Succeeded' || phase === 'Running') { - const readyCondition = pod.status.conditions.find(condition => condition.type === 'Ready'); + const readyCondition = pod.status.conditions?.find(condition => condition.type === 'Ready'); if (readyCondition?.status === 'True' || phase === 'Succeeded') { status = 'success'; } else { diff --git a/frontend/src/components/resourceMap/KubeObjectGlance/ServiceGlance.tsx b/frontend/src/components/resourceMap/KubeObjectGlance/ServiceGlance.tsx index 4f7704d8aa0..97ee7488905 100644 --- a/frontend/src/components/resourceMap/KubeObjectGlance/ServiceGlance.tsx +++ b/frontend/src/components/resourceMap/KubeObjectGlance/ServiceGlance.tsx @@ -36,11 +36,15 @@ export function ServiceGlance({ service }: { service: Service }) { {t('glossary|External IP')}: {externalIP} )} - {service.spec?.ports?.map(it => ( - - {it.protocol}:{it.port} - - ))} + {service.spec?.ports?.map(it => { + // protocol defaults to TCP per the Kubernetes Service API. + const proto = it.protocol ?? 'TCP'; + return ( + + {proto}:{it.port} + + ); + })} ); } diff --git a/frontend/src/components/resourceMap/nodes/KubeObjectStatus.tsx b/frontend/src/components/resourceMap/nodes/KubeObjectStatus.tsx index f721e4367bd..0f7045b30db 100644 --- a/frontend/src/components/resourceMap/nodes/KubeObjectStatus.tsx +++ b/frontend/src/components/resourceMap/nodes/KubeObjectStatus.tsx @@ -30,7 +30,7 @@ function getPodStatus(pod: Pod): KubeObjectStatus { if (phase === 'Failed') { return 'error'; } else if (phase === 'Succeeded' || phase === 'Running') { - const readyCondition = pod.status.conditions.find(condition => condition.type === 'Ready'); + const readyCondition = pod.status.conditions?.find(condition => condition.type === 'Ready'); if (readyCondition?.status === 'True' || phase === 'Succeeded') { return 'success'; } else { diff --git a/frontend/src/components/resourceMap/sources/definitions/relations.tsx b/frontend/src/components/resourceMap/sources/definitions/relations.tsx index 5cdbff11cae..3ec4abaec66 100644 --- a/frontend/src/components/resourceMap/sources/definitions/relations.tsx +++ b/frontend/src/components/resourceMap/sources/definitions/relations.tsx @@ -187,7 +187,7 @@ const ingressToSecret = makeRelation(Ingress, Secret, (ingress, secret) => ); const networkPolicyToPod = makeRelation(NetworkPolicy, Pod, (np, pod) => - matchesLabels(np.jsonData.spec.podSelector.matchLabels, pod) + matchesLabels(np.spec.podSelector.matchLabels ?? {}, pod) ); const roleBindingsToRole = makeRelation( diff --git a/frontend/src/components/service/Details.tsx b/frontend/src/components/service/Details.tsx index 64400bca447..1ebfcaae6c0 100644 --- a/frontend/src/components/service/Details.tsx +++ b/frontend/src/components/service/Details.tsx @@ -16,13 +16,13 @@ import { InlineIcon } from '@iconify/react'; import Box from '@mui/material/Box'; -import _ from 'lodash'; +import { isEmpty, isEqual } from 'lodash'; import React from 'react'; import { useTranslation } from 'react-i18next'; import { useParams } from 'react-router-dom'; import Endpoint from '../../lib/k8s/endpoints'; import EndpointSlice from '../../lib/k8s/endpointSlices'; -import Service from '../../lib/k8s/service'; +import Service, { KubeServicePort } from '../../lib/k8s/service'; import Empty from '../common/EmptyContent'; import { ValueLabel } from '../common/Label'; import Link from '../common/Link'; @@ -71,10 +71,73 @@ export default function ServiceDetails(props: { name: t('Cluster IP'), value: item.spec.clusterIP, }, + { + name: t('Cluster IPs'), + value: item.spec.clusterIPs?.join(', '), + // Hide when redundant with the Cluster IP row above (single entry, same value). + hide: + !item.spec.clusterIPs?.length || + isEqual(item.spec.clusterIPs, [item.spec.clusterIP]), + }, { name: t('External IP'), value: item.getExternalAddresses(), - hide: _.isEmpty, + hide: isEmpty, + }, + { + name: t('External Name'), + value: item.spec.externalName, + hide: isEmpty, + }, + { + name: t('IP Families'), + value: item.spec.ipFamilies?.join(', '), + hide: isEmpty, + }, + { + name: t('IP Family Policy'), + value: item.spec.ipFamilyPolicy, + hide: isEmpty, + }, + { + name: t('Session Affinity'), + value: + item.spec.sessionAffinity === 'ClientIP' && + item.spec.sessionAffinityConfig?.clientIP?.timeoutSeconds + ? `${item.spec.sessionAffinity} (${item.spec.sessionAffinityConfig.clientIP.timeoutSeconds}s)` + : item.spec.sessionAffinity, + // Match kubectl describe: skip the row for the default 'None' affinity. + hide: !item.spec.sessionAffinity || item.spec.sessionAffinity === 'None', + }, + { + name: t('External Traffic Policy'), + value: item.spec.externalTrafficPolicy, + hide: isEmpty, + }, + { + name: t('Internal Traffic Policy'), + value: item.spec.internalTrafficPolicy, + hide: isEmpty, + }, + { + name: t('Health Check Node Port'), + value: item.spec.healthCheckNodePort, + hide: value => !value, + }, + { + name: t('Load Balancer Class'), + value: item.spec.loadBalancerClass, + hide: isEmpty, + }, + { + name: t('Load Balancer Source Ranges'), + value: item.spec.loadBalancerSourceRanges?.join(', '), + hide: isEmpty, + }, + { + name: t('Traffic Distribution'), + value: item.spec.trafficDistribution, + hide: isEmpty, }, { name: t('Selector'), @@ -108,6 +171,23 @@ export default function ServiceDetails(props: { ), }, + ...(item.spec.ports?.some(p => p.nodePort) + ? [ + { + label: t('Node Port'), + getter: ({ nodePort }: KubeServicePort) => + nodePort ? {nodePort} : '-', + }, + ] + : []), + ...(item.spec.ports?.some(p => p.appProtocol) + ? [ + { + label: t('App Protocol'), + getter: ({ appProtocol }: KubeServicePort) => appProtocol ?? '-', + }, + ] + : []), ]} reflectInURL="ports" /> diff --git a/frontend/src/components/service/ServiceDetails.stories.tsx b/frontend/src/components/service/ServiceDetails.stories.tsx index 7058097ceda..bc186200539 100644 --- a/frontend/src/components/service/ServiceDetails.stories.tsx +++ b/frontend/src/components/service/ServiceDetails.stories.tsx @@ -78,6 +78,97 @@ const serviceMockWithA8RAnnotations = { }, }; +/** Service mock for a NodePort with full spec fields */ +const serviceMockNodePort = { + ...serviceMock, + metadata: { + ...serviceMock.metadata, + name: 'nodeport-service', + }, + spec: { + type: 'NodePort', + clusterIP: '10.96.0.200', + clusterIPs: ['10.96.0.200'], + externalIPs: [], + ipFamilies: ['IPv4'], + ipFamilyPolicy: 'SingleStack', + sessionAffinity: 'ClientIP', + sessionAffinityConfig: { + clientIP: { timeoutSeconds: 10800 }, + }, + externalTrafficPolicy: 'Cluster', + internalTrafficPolicy: 'Cluster', + ports: [ + { + name: 'http', + protocol: 'TCP', + port: 80, + targetPort: 8080, + nodePort: 31080, + appProtocol: 'http', + }, + ], + selector: { app: 'example' }, + }, + status: {}, +}; + +/** Service mock for a LoadBalancer with health-check and source ranges */ +const serviceMockLoadBalancer = { + ...serviceMock, + metadata: { + ...serviceMock.metadata, + name: 'lb-service', + }, + spec: { + type: 'LoadBalancer', + clusterIP: '10.96.0.300', + clusterIPs: ['10.96.0.300', 'fd00::1'], + externalIPs: [], + ipFamilies: ['IPv4', 'IPv6'], + ipFamilyPolicy: 'PreferDualStack', + externalTrafficPolicy: 'Local', + healthCheckNodePort: 32123, + loadBalancerClass: 'example.com/lb', + loadBalancerSourceRanges: ['10.0.0.0/8', '192.168.0.0/16'], + trafficDistribution: 'PreferClose', + sessionAffinity: 'None', + ports: [ + { + name: 'https', + protocol: 'TCP', + port: 443, + targetPort: 8443, + nodePort: 31443, + }, + ], + selector: { app: 'example' }, + }, + status: { + loadBalancer: { + ingress: [{ ip: '203.0.113.10' }], + }, + }, +}; + +/** Service mock for an ExternalName service */ +const serviceMockExternalName = { + ...serviceMock, + metadata: { + ...serviceMock.metadata, + name: 'externalname-service', + }, + spec: { + type: 'ExternalName', + clusterIP: '', + externalIPs: [], + externalName: 'api.example.com', + selector: {}, + ports: [], + }, + status: {}, +}; + /** Service mock with only a8r.io/owner annotation */ const serviceMockWithA8ROwnerOnly = { ...serviceMock, @@ -182,6 +273,18 @@ const TemplateA8ROwnerOnly: StoryFn = () => { return
; }; +const TemplateNodePort: StoryFn = () => { + return
; +}; + +const TemplateLoadBalancer: StoryFn = () => { + return
; +}; + +const TemplateExternalName: StoryFn = () => { + return
; +}; + export const Default = Template.bind({}); Default.parameters = { msw: { @@ -315,3 +418,41 @@ WithA8ROwnerOnly.parameters = { }, }, }; + +function makeServiceHandlers(name: string, mock: object) { + return [ + http.get('http://localhost:4466/api/v1/namespaces/default/events', () => + HttpResponse.json({ kind: 'EventList', items: [], metadata: {} }) + ), + http.get('http://localhost:4466/api/v1/namespaces/default/services', () => + HttpResponse.error() + ), + http.get(`http://localhost:4466/api/v1/namespaces/default/services/${name}`, () => + HttpResponse.json(mock) + ), + http.get('http://localhost:4466/api/v1/namespaces/default/endpoints', () => + HttpResponse.json({ kind: 'List', items: [], metadata: {} }) + ), + http.get( + 'http://localhost:4466/apis/discovery.k8s.io/v1/namespaces/default/endpointslices', + () => HttpResponse.json({ kind: 'List', items: [], metadata: {} }) + ), + ]; +} + +export const NodePort = TemplateNodePort.bind({}); +NodePort.parameters = { + msw: { handlers: { story: makeServiceHandlers('nodeport-service', serviceMockNodePort) } }, +}; + +export const LoadBalancer = TemplateLoadBalancer.bind({}); +LoadBalancer.parameters = { + msw: { handlers: { story: makeServiceHandlers('lb-service', serviceMockLoadBalancer) } }, +}; + +export const ExternalName = TemplateExternalName.bind({}); +ExternalName.parameters = { + msw: { + handlers: { story: makeServiceHandlers('externalname-service', serviceMockExternalName) }, + }, +}; diff --git a/frontend/src/components/service/__snapshots__/ServiceDetails.ExternalName.stories.storyshot b/frontend/src/components/service/__snapshots__/ServiceDetails.ExternalName.stories.storyshot new file mode 100644 index 00000000000..0d0fdfd8f9a --- /dev/null +++ b/frontend/src/components/service/__snapshots__/ServiceDetails.ExternalName.stories.storyshot @@ -0,0 +1,440 @@ + +
+
+
+
+ +
+
+
+
+
+
+
+

+ Service: externalname-service +

+
+ +
+
+
+
+
+
+
+
+ +
+
+ +
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Name +
+
+ + externalname-service + +
+
+ Namespace +
+
+ + default + +
+
+ Creation +
+
+ + 2022-10-25T11:48:48.000Z + +
+
+ Type +
+
+ + ExternalName + +
+
+ Cluster IP +
+
+ +
+
+ External Name +
+
+ + api.example.com + +
+
+ Selector +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+

+ Ports +

+
+
+
+
+
+
+
+

+ No data to be shown. +

+
+
+
+
+
+
+
+
+
+
+
+
+

+ Endpoints +

+
+
+
+
+
+
+
+

+ No data to be shown. +

+
+
+
+
+
+
+
+
+
+
+
+
+

+ Endpoint Slices +

+
+
+
+
+
+
+
+

+ No data to be shown. +

+
+
+
+
+
+
+
+
+
+
+
+
+

+ Events +

+
+
+
+
+
+
+
+

+ No data to be shown. +

+
+
+
+
+
+
+
+
+ \ No newline at end of file diff --git a/frontend/src/components/service/__snapshots__/ServiceDetails.LoadBalancer.stories.storyshot b/frontend/src/components/service/__snapshots__/ServiceDetails.LoadBalancer.stories.storyshot new file mode 100644 index 00000000000..625d7889ef8 --- /dev/null +++ b/frontend/src/components/service/__snapshots__/ServiceDetails.LoadBalancer.stories.storyshot @@ -0,0 +1,625 @@ + +
+
+
+
+ +
+
+
+
+
+
+
+

+ Service: lb-service +

+
+ +
+
+
+
+
+
+
+
+ +
+
+ +
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Name +
+
+ + lb-service + +
+
+ Namespace +
+
+ + default + +
+
+ Creation +
+
+ + 2022-10-25T11:48:48.000Z + +
+
+ Type +
+
+ + LoadBalancer + +
+
+ Cluster IP +
+
+ + 10.96.0.300 + +
+
+ Cluster IPs +
+
+ + 10.96.0.300, fd00::1 + +
+
+ External IP +
+
+ + 203.0.113.10 + +
+
+ IP Families +
+
+ + IPv4, IPv6 + +
+
+ IP Family Policy +
+
+ + PreferDualStack + +
+
+ External Traffic Policy +
+
+ + Local + +
+
+ Health Check Node Port +
+
+ 32123 +
+
+ Load Balancer Class +
+
+ + example.com/lb + +
+
+ Load Balancer Source Ranges +
+
+ + 10.0.0.0/8, 192.168.0.0/16 + +
+
+ Traffic Distribution +
+
+ + PreferClose + +
+
+ Selector +
+
+
+
+

+ app: example +

+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+

+ Ports +

+
+
+
+
+
+
+ + + + + + + + + + + + + + + + + +
+ Protocol + + Name + + Ports + + Node Port +
+ TCP + + https + + + 443 + + + 8443 + + + + 31443 + +
+
+
+
+
+
+
+
+
+
+
+
+

+ Endpoints +

+
+
+
+
+
+
+
+

+ No data to be shown. +

+
+
+
+
+
+
+
+
+
+
+
+
+

+ Endpoint Slices +

+
+
+
+
+
+
+
+

+ No data to be shown. +

+
+
+
+
+
+
+
+
+
+
+
+
+

+ Events +

+
+
+
+
+
+
+
+

+ No data to be shown. +

+
+
+
+
+
+
+
+
+ \ No newline at end of file diff --git a/frontend/src/components/service/__snapshots__/ServiceDetails.NodePort.stories.storyshot b/frontend/src/components/service/__snapshots__/ServiceDetails.NodePort.stories.storyshot new file mode 100644 index 00000000000..862ec47f1bd --- /dev/null +++ b/frontend/src/components/service/__snapshots__/ServiceDetails.NodePort.stories.storyshot @@ -0,0 +1,584 @@ + +
+
+
+
+ +
+
+
+
+
+
+
+

+ Service: nodeport-service +

+
+ +
+
+
+
+
+
+
+
+ +
+
+ +
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Name +
+
+ + nodeport-service + +
+
+ Namespace +
+
+ + default + +
+
+ Creation +
+
+ + 2022-10-25T11:48:48.000Z + +
+
+ Type +
+
+ + NodePort + +
+
+ Cluster IP +
+
+ + 10.96.0.200 + +
+
+ IP Families +
+
+ + IPv4 + +
+
+ IP Family Policy +
+
+ + SingleStack + +
+
+ Session Affinity +
+
+ + ClientIP (10800s) + +
+
+ External Traffic Policy +
+
+ + Cluster + +
+
+ Internal Traffic Policy +
+
+ + Cluster + +
+
+ Selector +
+
+
+
+

+ app: example +

+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+

+ Ports +

+
+
+
+
+
+
+ + + + + + + + + + + + + + + + + + + +
+ Protocol + + Name + + Ports + + Node Port + + App Protocol +
+ TCP + + http + + + 80 + + + 8080 + + + + 31080 + + + http +
+
+
+
+
+
+
+
+
+
+
+
+

+ Endpoints +

+
+
+
+
+
+
+
+

+ No data to be shown. +

+
+
+
+
+
+
+
+
+
+
+
+
+

+ Endpoint Slices +

+
+
+
+
+
+
+
+

+ No data to be shown. +

+
+
+
+
+
+
+
+
+
+
+
+
+

+ Events +

+
+
+
+
+
+
+
+

+ No data to be shown. +

+
+
+
+
+
+
+
+
+ \ No newline at end of file diff --git a/frontend/src/components/serviceaccount/Details.tsx b/frontend/src/components/serviceaccount/Details.tsx index 2f362aad945..c35449340eb 100644 --- a/frontend/src/components/serviceaccount/Details.tsx +++ b/frontend/src/components/serviceaccount/Details.tsx @@ -21,6 +21,23 @@ import ServiceAccount from '../../lib/k8s/serviceAccount'; import Link from '../common/Link'; import { DetailsGrid } from '../common/Resource'; +function SecretLinks(props: { items?: { name: string }[]; namespace: string }) { + const { items, namespace } = props; + if (!items?.length) return null; + return ( + + {items.map(({ name }, index) => ( + + + {name} + + {index !== items.length - 1 && ', '} + + ))} + + ); +} + export default function ServiceAccountDetails(props: { name?: string; namespace?: string; @@ -41,18 +58,23 @@ export default function ServiceAccountDetails(props: { item && [ { name: t('Secrets'), - value: ( - - {item.secrets?.map(({ name }, index) => ( - - - {name} - - {index !== item.secrets.length - 1 && ','} - - ))} - - ), + value: , + hide: !item.secrets?.length, + }, + { + name: t('Image Pull Secrets'), + value: , + hide: !item.imagePullSecrets?.length, + }, + { + name: t('Automount Service Account Token'), + value: + item.automountServiceAccountToken === undefined + ? '' + : item.automountServiceAccountToken + ? t('translation|Yes') + : t('translation|No'), + hide: item.automountServiceAccountToken === undefined, }, ] } diff --git a/frontend/src/components/statefulset/Details.tsx b/frontend/src/components/statefulset/Details.tsx index 3f8ba2a155e..28d7a252618 100644 --- a/frontend/src/components/statefulset/Details.tsx +++ b/frontend/src/components/statefulset/Details.tsx @@ -27,6 +27,7 @@ import { RevisionHistorySection, RollbackButton, } from '../common/Resource'; +import { statefulSetExtraInfo } from '../workload/extraInfo'; export default function StatefulSetDetails(props: { name?: string; @@ -59,9 +60,11 @@ export default function StatefulSetDetails(props: { }} extraInfo={item => item && [ + ...statefulSetExtraInfo(item, t), { name: t('Update Strategy'), - value: item.spec.updateStrategy.type, + value: item.spec.updateStrategy?.type, + hide: !item.spec.updateStrategy?.type, }, { name: t('Selector'), diff --git a/frontend/src/components/statefulset/__snapshots__/Details.Default.stories.storyshot b/frontend/src/components/statefulset/__snapshots__/Details.Default.stories.storyshot index 1be0674197a..1e6401558a5 100644 --- a/frontend/src/components/statefulset/__snapshots__/Details.Default.stories.storyshot +++ b/frontend/src/components/statefulset/__snapshots__/Details.Default.stories.storyshot @@ -232,6 +232,23 @@ 2025-05-31T17:15:53.298Z +
+ Service Name +
+
+ + mock-service + +
diff --git a/frontend/src/components/statefulset/__snapshots__/Details.WithComplexSelector.stories.storyshot b/frontend/src/components/statefulset/__snapshots__/Details.WithComplexSelector.stories.storyshot index 2efe2b5474d..740e7710cbb 100644 --- a/frontend/src/components/statefulset/__snapshots__/Details.WithComplexSelector.stories.storyshot +++ b/frontend/src/components/statefulset/__snapshots__/Details.WithComplexSelector.stories.storyshot @@ -232,6 +232,23 @@ 2025-05-31T17:15:53.298Z +
+ Service Name +
+
+ + mock-service + +
diff --git a/frontend/src/components/statefulset/__snapshots__/Details.WithMultipleContainers.stories.storyshot b/frontend/src/components/statefulset/__snapshots__/Details.WithMultipleContainers.stories.storyshot index beb9fe047f8..e406b13def7 100644 --- a/frontend/src/components/statefulset/__snapshots__/Details.WithMultipleContainers.stories.storyshot +++ b/frontend/src/components/statefulset/__snapshots__/Details.WithMultipleContainers.stories.storyshot @@ -232,6 +232,23 @@ 2025-05-31T17:15:53.298Z +
+ Service Name +
+
+ + mock-service + +
diff --git a/frontend/src/components/statefulset/__snapshots__/Details.WithOnDeleteStrategy.stories.storyshot b/frontend/src/components/statefulset/__snapshots__/Details.WithOnDeleteStrategy.stories.storyshot index fd33b71e171..361f830c103 100644 --- a/frontend/src/components/statefulset/__snapshots__/Details.WithOnDeleteStrategy.stories.storyshot +++ b/frontend/src/components/statefulset/__snapshots__/Details.WithOnDeleteStrategy.stories.storyshot @@ -232,6 +232,23 @@ 2025-05-31T17:15:53.298Z +
+ Service Name +
+
+ + mock-service + +
diff --git a/frontend/src/components/storage/ClaimDetails.tsx b/frontend/src/components/storage/ClaimDetails.tsx index 1c931ba1d26..ce11a6a1bb3 100644 --- a/frontend/src/components/storage/ClaimDetails.tsx +++ b/frontend/src/components/storage/ClaimDetails.tsx @@ -22,8 +22,8 @@ import { DetailsGrid } from '../common/Resource'; import { StatusLabelByPhase } from './utils'; export function makePVCStatusLabel(item: PersistentVolumeClaim) { - const status = item.status!.phase; - return StatusLabelByPhase(status!); + const status = item.status?.phase; + return StatusLabelByPhase(status); } export default function VolumeClaimDetails(props: { @@ -48,30 +48,57 @@ export default function VolumeClaimDetails(props: { name: t('translation|Status'), value: makePVCStatusLabel(item), }, + { + name: t('Volume'), + value: item.spec?.volumeName ? ( + + {item.spec.volumeName} + + ) : ( + '' + ), + hide: !item.spec?.volumeName, + }, + { + name: t('Requested'), + value: item.spec?.resources?.requests?.storage, + hide: !item.spec?.resources?.requests?.storage, + }, { name: t('Capacity'), - value: item.spec!.resources!.requests.storage, + value: item.status?.capacity?.storage ?? item.spec?.resources?.requests?.storage, + hide: value => !value, }, { name: t('Access Modes'), - value: item.spec!.accessModes!.join(', '), + value: (item.status?.accessModes ?? item.spec?.accessModes ?? []).join(', '), + hide: value => !value, }, { name: t('Volume Mode'), - value: item.spec!.volumeMode, + value: item.spec?.volumeMode, + hide: !item.spec?.volumeMode, }, { name: t('Storage Class'), - value: ( + value: item.spec?.storageClassName ? ( - {item.spec!.storageClassName} + {item.spec.storageClassName} + ) : ( + '' ), + hide: !item.spec?.storageClassName, }, ] } diff --git a/frontend/src/components/storage/ClassDetails.tsx b/frontend/src/components/storage/ClassDetails.tsx index ed65b2b4caa..94ac207518c 100644 --- a/frontend/src/components/storage/ClassDetails.tsx +++ b/frontend/src/components/storage/ClassDetails.tsx @@ -14,11 +14,12 @@ * limitations under the License. */ +import { isEmpty } from 'lodash'; import React from 'react'; import { useTranslation } from 'react-i18next'; import { useParams } from 'react-router-dom'; import StorageClass from '../../lib/k8s/storageClass'; -import { DetailsGrid } from '../common/Resource'; +import { DetailsGrid, MetadataDictGrid } from '../common/Resource'; export default function StorageClassDetails(props: { name?: string; cluster?: string }) { const params = useParams<{ name: string }>(); @@ -33,6 +34,10 @@ export default function StorageClassDetails(props: { name?: string; cluster?: st withEvents extraInfo={item => item && [ + { + name: t('Provisioner'), + value: item.provisioner, + }, { name: t('Reclaim Policy'), value: item.reclaimPolicy, @@ -42,8 +47,23 @@ export default function StorageClassDetails(props: { name?: string; cluster?: st value: item.volumeBindingMode, }, { - name: t('Provisioner'), - value: item.provisioner, + name: t('Default'), + value: item.isDefault ? t('translation|Yes') : t('translation|No'), + }, + { + name: t('Allow Volume Expansion'), + value: item.allowVolumeExpansion ? t('translation|Yes') : t('translation|No'), + hide: item.allowVolumeExpansion === undefined, + }, + { + name: t('Parameters'), + value: , + hide: isEmpty(item.parameters), + }, + { + name: t('Mount Options'), + value: item.mountOptions?.join(', '), + hide: !item.mountOptions?.length, }, ] } diff --git a/frontend/src/components/storage/VolumeDetails.stories.tsx b/frontend/src/components/storage/VolumeDetails.stories.tsx index 3982484b5ba..ccded458c22 100644 --- a/frontend/src/components/storage/VolumeDetails.stories.tsx +++ b/frontend/src/components/storage/VolumeDetails.stories.tsx @@ -17,8 +17,8 @@ import { Meta, StoryFn } from '@storybook/react'; import { http, HttpResponse } from 'msw'; import { TestContext } from '../../test'; -import Details from './ClaimDetails'; import { BASE_PV } from './storyHelper'; +import Details from './VolumeDetails'; export default { title: 'PersistentVolume/DetailsView', @@ -44,10 +44,10 @@ Base.parameters = { msw: { handlers: { story: [ - http.get('http://localhost:4466/api/v1/persistentvolumeclaims/my-pv', () => + http.get('http://localhost:4466/api/v1/persistentvolumes/my-pv', () => HttpResponse.json(BASE_PV) ), - http.get('http://localhost:4466/api/v1/persistentvolumeclaims', () => HttpResponse.error()), + http.get('http://localhost:4466/api/v1/persistentvolumes', () => HttpResponse.error()), http.get('http://localhost:4466/api/v1/namespaces/default/events', () => HttpResponse.json({ kind: 'EventList', diff --git a/frontend/src/components/storage/VolumeDetails.tsx b/frontend/src/components/storage/VolumeDetails.tsx index b834e8a8c51..3c23951cfcb 100644 --- a/frontend/src/components/storage/VolumeDetails.tsx +++ b/frontend/src/components/storage/VolumeDetails.tsx @@ -14,18 +14,38 @@ * limitations under the License. */ +import { ReactNode } from 'react'; import { useTranslation } from 'react-i18next'; import { useParams } from 'react-router-dom'; -import PersistentVolume from '../../lib/k8s/persistentVolume'; +import PersistentVolume, { KubeClaimRef } from '../../lib/k8s/persistentVolume'; import Link from '../common/Link'; import { DetailsGrid } from '../common/Resource'; import { StatusLabelByPhase } from './utils'; export function makePVStatusLabel(item: PersistentVolume) { - const status = item.status!.phase; + const status = item.status?.phase; return StatusLabelByPhase(status); } +function renderClaimRef(claimRef: KubeClaimRef, cluster?: string): ReactNode { + if (claimRef.kind === 'PersistentVolumeClaim' && claimRef.name && claimRef.namespace) { + return ( + + {`${claimRef.namespace}/${claimRef.name}`} + + ); + } + if (claimRef.name) { + return `${claimRef.namespace ? `${claimRef.namespace}/` : ''}${claimRef.name}`; + } + return ''; +} + export default function VolumeDetails(props: { name?: string; cluster?: string }) { const params = useParams<{ name: string }>(); const { name = params.name, cluster } = props; @@ -37,39 +57,74 @@ export default function VolumeDetails(props: { name?: string; cluster?: string } name={name} cluster={cluster} withEvents - extraInfo={item => - item && [ + extraInfo={item => { + if (!item) return []; + const { spec, status } = item; + const claimRef = spec?.claimRef; + const sourceType = item.getSourceType(); + + return [ { name: t('translation|Status'), value: makePVStatusLabel(item), }, { name: t('Capacity'), - value: item.spec!.capacity.storage, + value: spec?.capacity?.storage, }, { name: t('Access Modes'), - value: item.spec!.accessModes.join(', '), + value: spec?.accessModes?.join(', '), + hide: !spec?.accessModes?.length, + }, + { + name: t('Volume Mode'), + value: spec?.volumeMode, + hide: !spec?.volumeMode, }, { name: t('Reclaim Policy'), - value: item.spec!.persistentVolumeReclaimPolicy, + value: spec?.persistentVolumeReclaimPolicy, + hide: !spec?.persistentVolumeReclaimPolicy, }, { name: t('Storage Class'), - value: ( + value: spec?.storageClassName ? ( - {item.spec!.storageClassName} + {spec.storageClassName} + ) : ( + '' ), + hide: !spec?.storageClassName, + }, + { + name: t('Claim'), + value: claimRef ? renderClaimRef(claimRef, item.cluster) : '', + hide: !claimRef, + }, + { + name: t('Source'), + value: sourceType, + hide: !sourceType, + }, + { + name: t('translation|Reason'), + value: status?.reason, + hide: !status?.reason, + }, + { + name: t('translation|Message'), + value: status?.message, + hide: !status?.message, }, - ] - } + ]; + }} /> ); } diff --git a/frontend/src/components/storage/VolumeList.tsx b/frontend/src/components/storage/VolumeList.tsx index 05bd60661f8..926cb811a7d 100644 --- a/frontend/src/components/storage/VolumeList.tsx +++ b/frontend/src/components/storage/VolumeList.tsx @@ -59,7 +59,7 @@ export default function VolumeList() { { id: 'capacity', label: t('Capacity'), - getValue: volume => volume.spec.capacity.storage, + getValue: volume => volume.spec.capacity?.storage, }, { id: 'accessModes', diff --git a/frontend/src/components/storage/__snapshots__/ClaimDetails.Base.stories.storyshot b/frontend/src/components/storage/__snapshots__/ClaimDetails.Base.stories.storyshot index 57fc4d4ded9..a87132d632c 100644 --- a/frontend/src/components/storage/__snapshots__/ClaimDetails.Base.stories.storyshot +++ b/frontend/src/components/storage/__snapshots__/ClaimDetails.Base.stories.storyshot @@ -191,6 +191,37 @@ Bound +
+ Volume +
+
+ + pvc-abc-1234 + +
+
+ Requested +
+
+ + 8Gi + +
diff --git a/frontend/src/components/storage/__snapshots__/ClassDetails.Base.stories.storyshot b/frontend/src/components/storage/__snapshots__/ClassDetails.Base.stories.storyshot index 1f144ec57fa..818bc1862ee 100644 --- a/frontend/src/components/storage/__snapshots__/ClassDetails.Base.stories.storyshot +++ b/frontend/src/components/storage/__snapshots__/ClassDetails.Base.stories.storyshot @@ -162,6 +162,20 @@ 2023-04-27T20:31:27.000Z +
+ Provisioner +
+
+ + csi.test + +
@@ -190,10 +204,24 @@ WaitForFirstConsumer +
+ Default +
+
+ + No + +
- Provisioner + Allow Volume Expansion
- csi.test + Yes
diff --git a/frontend/src/components/storage/__snapshots__/VolumeDetails.Base.stories.storyshot b/frontend/src/components/storage/__snapshots__/VolumeDetails.Base.stories.storyshot index 06faaf5a34f..6f6afd66559 100644 --- a/frontend/src/components/storage/__snapshots__/VolumeDetails.Base.stories.storyshot +++ b/frontend/src/components/storage/__snapshots__/VolumeDetails.Base.stories.storyshot @@ -201,7 +201,9 @@ > + > + 8Gi +
Storage Class
+
+ Reason +
+
+ + test + +
+
+ Message +
+
+ + test + +
diff --git a/frontend/src/components/storage/utils.tsx b/frontend/src/components/storage/utils.tsx index 0a9bb7dd44f..aeff4f53763 100644 --- a/frontend/src/components/storage/utils.tsx +++ b/frontend/src/components/storage/utils.tsx @@ -16,12 +16,12 @@ import { StatusLabel } from '../common/Label'; -export function StatusLabelByPhase(phase: string) { +export function StatusLabelByPhase(phase?: string) { return ( - {phase} + {phase ?? ''} ); } diff --git a/frontend/src/components/workload/Details.tsx b/frontend/src/components/workload/Details.tsx index bea441cf220..2b01ebd63c7 100644 --- a/frontend/src/components/workload/Details.tsx +++ b/frontend/src/components/workload/Details.tsx @@ -16,8 +16,7 @@ import { useTranslation } from 'react-i18next'; import { useParams } from 'react-router-dom'; -import { WorkloadClass } from '../../lib/k8s/Workload'; -import { Workload } from '../../lib/k8s/Workload'; +import { Workload, WorkloadClass } from '../../lib/k8s/Workload'; import { ConditionsSection, ContainersSection, @@ -28,6 +27,7 @@ import { RevisionHistorySection, RollbackButton, } from '../common/Resource'; +import { KIND_EXTRA_INFO } from './extraInfo'; interface WorkloadDetailsProps { workloadKind: T; @@ -130,8 +130,11 @@ export default function WorkloadDetails(props: Workload return actions; }} - extraInfo={item => - item && [ + extraInfo={item => { + if (!item) return []; + const extraInfoFn = KIND_EXTRA_INFO[workloadKind.kind]; + const extraRows = extraInfoFn ? extraInfoFn(item, t) : []; + return [ { name: t('Strategy Type'), value: renderUpdateStrategy(item), @@ -158,8 +161,9 @@ export default function WorkloadDetails(props: Workload value: renderReplicas(item), hide: !showReplicas(item), }, - ] - } + ...extraRows, + ]; + }} extraSections={item => { if (!item) return []; const sections = [ diff --git a/frontend/src/components/workload/extraInfo.tsx b/frontend/src/components/workload/extraInfo.tsx new file mode 100644 index 00000000000..673146eeda0 --- /dev/null +++ b/frontend/src/components/workload/extraInfo.tsx @@ -0,0 +1,173 @@ +/* + * Copyright 2025 The Kubernetes Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { TFunction } from 'i18next'; +import Deployment from '../../lib/k8s/deployment'; +import Job from '../../lib/k8s/job'; +import StatefulSet from '../../lib/k8s/statefulSet'; +import { localeDate } from '../../lib/util'; +import Link from '../common/Link'; +import { NameValueTableRow } from '../common/NameValueTable'; + +export function formatDuration(ms: number): string { + if (ms < 0) return ''; + const seconds = Math.floor(ms / 1000); + if (seconds < 60) return `${seconds}s`; + const minutes = Math.floor(seconds / 60); + if (minutes < 60) return `${minutes}m${seconds % 60 ? ` ${seconds % 60}s` : ''}`; + const hours = Math.floor(minutes / 60); + if (hours < 24) return `${hours}h${minutes % 60 ? ` ${minutes % 60}m` : ''}`; + const days = Math.floor(hours / 24); + return `${days}d${hours % 24 ? ` ${hours % 24}h` : ''}`; +} + +export function deploymentExtraInfo(item: Deployment, t: TFunction): NameValueTableRow[] { + const spec = item.spec ?? ({} as Deployment['spec']); + return [ + { + name: t('glossary|Min Ready Seconds'), + value: spec.minReadySeconds !== undefined ? `${spec.minReadySeconds}s` : '', + hide: spec.minReadySeconds === undefined, + }, + { + name: t('glossary|Progress Deadline'), + value: spec.progressDeadlineSeconds !== undefined ? `${spec.progressDeadlineSeconds}s` : '', + hide: spec.progressDeadlineSeconds === undefined, + }, + { + name: t('glossary|Revision History Limit'), + value: spec.revisionHistoryLimit, + hide: spec.revisionHistoryLimit === undefined, + }, + ]; +} + +export function statefulSetExtraInfo(item: StatefulSet, t: TFunction): NameValueTableRow[] { + const spec = item.spec ?? ({} as StatefulSet['spec']); + return [ + { + name: t('glossary|Service Name'), + value: spec.serviceName ? ( + + {spec.serviceName} + + ) : ( + '' + ), + hide: !spec.serviceName, + }, + { + name: t('glossary|Pod Management Policy'), + value: spec.podManagementPolicy, + hide: !spec.podManagementPolicy, + }, + ]; +} + +export function jobExtraInfo(item: Job, t: TFunction): NameValueTableRow[] { + const status = item.status ?? ({} as Job['status']); + const spec = item.spec ?? ({} as Job['spec']); + const counts = [ + status.active !== undefined && t('translation|Active: {{ n }}', { n: status.active }), + status.ready !== undefined && t('translation|Ready: {{ n }}', { n: status.ready }), + status.succeeded !== undefined && t('translation|Succeeded: {{ n }}', { n: status.succeeded }), + status.failed !== undefined && t('translation|Failed: {{ n }}', { n: status.failed }), + ] + .filter(Boolean) + .join(', '); + const duration = formatDuration(item.getDuration()); + + return [ + { + name: t('glossary|Completions'), + value: + spec.completions !== undefined + ? `${status.succeeded ?? 0}/${spec.completions}` + : status.succeeded !== undefined + ? `${status.succeeded}` + : '', + hide: spec.completions === undefined && status.succeeded === undefined, + }, + { + name: t('glossary|Parallelism'), + value: spec.parallelism, + hide: spec.parallelism === undefined, + }, + { + name: t('glossary|Completion Mode'), + value: spec.completionMode, + hide: !spec.completionMode || spec.completionMode === 'NonIndexed', + }, + { + name: t('translation|Suspend'), + value: spec.suspend !== undefined ? String(spec.suspend) : '', + hide: spec.suspend === undefined, + }, + { + name: t('glossary|Backoff Limit'), + value: spec.backoffLimit, + hide: spec.backoffLimit === undefined, + }, + { + name: t('glossary|Active Deadline'), + value: spec.activeDeadlineSeconds ? `${spec.activeDeadlineSeconds}s` : '', + hide: !spec.activeDeadlineSeconds, + }, + { + name: t('glossary|TTL After Finished'), + value: spec.ttlSecondsAfterFinished ? `${spec.ttlSecondsAfterFinished}s` : '', + hide: spec.ttlSecondsAfterFinished === undefined, + }, + { + name: t('glossary|Pods Status'), + value: counts, + hide: !counts, + }, + { + name: t('glossary|Start Time'), + value: status.startTime ? localeDate(status.startTime) : '', + hide: !status.startTime, + }, + { + name: t('glossary|Completion Time'), + value: status.completionTime ? localeDate(status.completionTime) : '', + hide: !status.completionTime, + }, + { + name: t('glossary|Duration'), + value: duration, + hide: !duration, + }, + { + name: t('glossary|Completed Indexes'), + value: status.completedIndexes, + hide: !status.completedIndexes, + }, + ]; +} + +export const KIND_EXTRA_INFO: Partial< + Record NameValueTableRow[]> +> = { + Deployment: deploymentExtraInfo, + StatefulSet: statefulSetExtraInfo, + Job: jobExtraInfo, +}; diff --git a/frontend/src/lib/k8s/cronJob.ts b/frontend/src/lib/k8s/cronJob.ts index 5a627ef66dc..9de8d395e10 100644 --- a/frontend/src/lib/k8s/cronJob.ts +++ b/frontend/src/lib/k8s/cronJob.ts @@ -27,12 +27,13 @@ import { KubeObject, type KubeObjectInterface } from './KubeObject'; */ export interface KubeCronJob extends KubeObjectInterface { spec: { - suspend: boolean; + suspend?: boolean; schedule: string; + timeZone?: string; startingDeadlineSeconds?: number; - successfulJobsHistoryLimit: number; - failedJobsHistoryLimit: number; - concurrencyPolicy: string; + successfulJobsHistoryLimit?: number; + failedJobsHistoryLimit?: number; + concurrencyPolicy?: string; jobTemplate: { spec: { metadata?: Partial; @@ -47,6 +48,9 @@ export interface KubeCronJob extends KubeObjectInterface { [otherProps: string]: any; }; status: { + active?: { name: string }[]; + lastScheduleTime?: string; + lastSuccessfulTime?: string; [otherProps: string]: any; }; } diff --git a/frontend/src/lib/k8s/deployment.ts b/frontend/src/lib/k8s/deployment.ts index 777127c6042..838b1fd651e 100644 --- a/frontend/src/lib/k8s/deployment.ts +++ b/frontend/src/lib/k8s/deployment.ts @@ -28,7 +28,7 @@ export type { RollbackResult }; export interface KubeDeployment extends KubeObjectInterface { spec: { - selector?: LabelSelector; + selector: LabelSelector; strategy?: { type: string; [otherProps: string]: any; @@ -37,6 +37,9 @@ export interface KubeDeployment extends KubeObjectInterface { metadata?: KubeMetadata; spec: KubePodSpec; }; + minReadySeconds?: number; + progressDeadlineSeconds?: number; + revisionHistoryLimit?: number; [otherProps: string]: any; }; status: { diff --git a/frontend/src/lib/k8s/ingress.ts b/frontend/src/lib/k8s/ingress.ts index 392c95e3728..95d6ff9b002 100644 --- a/frontend/src/lib/k8s/ingress.ts +++ b/frontend/src/lib/k8s/ingress.ts @@ -16,6 +16,7 @@ import type { KubeObjectInterface } from './KubeObject'; import { KubeObject } from './KubeObject'; +import type { KubeLoadBalancerIngress } from './service'; interface LegacyIngressRule { host: string; @@ -82,6 +83,11 @@ export interface KubeIngress extends KubeObjectInterface { }; [key: string]: any; }; + status?: { + loadBalancer?: { + ingress?: KubeLoadBalancerIngress[]; + }; + }; } class Ingress extends KubeObject { @@ -130,6 +136,14 @@ class Ingress extends KubeObject { return this.jsonData.spec; } + getAddresses(): string { + const ingressEntries = this.jsonData.status?.loadBalancer?.ingress ?? []; + return ingressEntries + .map(entry => entry.hostname || entry.ip) + .filter(Boolean) + .join(', '); + } + getHosts() { return this.spec.rules?.map(({ host }) => host).join(' | '); } diff --git a/frontend/src/lib/k8s/job.ts b/frontend/src/lib/k8s/job.ts index b63e13b8a4b..2a4a46aebdb 100644 --- a/frontend/src/lib/k8s/job.ts +++ b/frontend/src/lib/k8s/job.ts @@ -27,9 +27,23 @@ export interface KubeJob extends KubeObjectInterface { metadata?: KubeMetadata; spec: KubePodSpec; }; + completions?: number; + parallelism?: number; + completionMode?: string; + suspend?: boolean; + backoffLimit?: number; + activeDeadlineSeconds?: number; + ttlSecondsAfterFinished?: number; [otherProps: string]: any; }; status: { + active?: number; + ready?: number; + succeeded?: number; + failed?: number; + startTime?: string; + completionTime?: string; + completedIndexes?: string; [otherProps: string]: any; }; } diff --git a/frontend/src/lib/k8s/networkpolicy.tsx b/frontend/src/lib/k8s/networkpolicy.tsx index 79a7dc8b2ea..6128448435c 100644 --- a/frontend/src/lib/k8s/networkpolicy.tsx +++ b/frontend/src/lib/k8s/networkpolicy.tsx @@ -46,10 +46,12 @@ export interface NetworkPolicyIngressRule { } export interface KubeNetworkPolicy extends KubeObjectInterface { - egress: NetworkPolicyEgressRule[]; - ingress: NetworkPolicyIngressRule[]; - podSelector: LabelSelector; - policyTypes: string[]; + spec: { + egress?: NetworkPolicyEgressRule[]; + ingress?: NetworkPolicyIngressRule[]; + podSelector: LabelSelector; + policyTypes?: string[]; + }; } class NetworkPolicy extends KubeObject { @@ -58,46 +60,56 @@ class NetworkPolicy extends KubeObject { static apiVersion = 'networking.k8s.io/v1'; static isNamespaced = true; + get spec(): KubeNetworkPolicy['spec'] { + return this.jsonData.spec; + } + + get policyTypes(): string[] { + return this.spec?.policyTypes ?? []; + } + static getBaseObject(): KubeNetworkPolicy { const baseObject = super.getBaseObject() as KubeNetworkPolicy; - baseObject.egress = [ - { - ports: [ - { - port: 80, - protocol: 'TCP', - }, - ], - to: [ - { - podSelector: { - matchLabels: { app: 'headlamp' }, + baseObject.spec = { + egress: [ + { + ports: [ + { + port: 80, + protocol: 'TCP', }, - }, - ], - }, - ]; - baseObject.ingress = [ - { - ports: [ - { - port: 80, - protocol: 'TCP', - }, - ], - from: [ - { - podSelector: { - matchLabels: { app: 'headlamp' }, + ], + to: [ + { + podSelector: { + matchLabels: { app: 'headlamp' }, + }, + }, + ], + }, + ], + ingress: [ + { + ports: [ + { + port: 80, + protocol: 'TCP', + }, + ], + from: [ + { + podSelector: { + matchLabels: { app: 'headlamp' }, + }, }, - }, - ], + ], + }, + ], + podSelector: { + matchLabels: { app: 'headlamp' }, }, - ]; - baseObject.podSelector = { - matchLabels: { app: 'headlamp' }, + policyTypes: ['Ingress', 'Egress'], }; - baseObject.policyTypes = ['Ingress', 'Egress']; return baseObject; } diff --git a/frontend/src/lib/k8s/node.ts b/frontend/src/lib/k8s/node.ts index 7b3c3c865dd..3f2f4fb9a5f 100644 --- a/frontend/src/lib/k8s/node.ts +++ b/frontend/src/lib/k8s/node.ts @@ -30,22 +30,12 @@ export interface KubeNode extends KubeObjectInterface { address: string; type: string; }[]; - allocatable?: { - cpu: any; - memory: any; - ephemeralStorage: any; - hugepages_1Gi: any; - hugepages_2Mi: any; - pods: any; - }; - capacity?: { - cpu: any; - memory: any; - ephemeralStorage: any; - hugepages_1Gi: any; - hugepages_2Mi: any; - pods: any; - }; + /** + * Resource quantities keyed by their k8s name (e.g. cpu, memory, pods, ephemeral-storage). + * Note: keys are kebab-case as returned by the API, not camelCase. + */ + allocatable?: { [key: string]: string }; + capacity?: { [key: string]: string }; conditions?: (Omit & { lastHeartbeatTime: string; })[]; @@ -137,6 +127,19 @@ class Node extends KubeObject { getInternalIP(): string { return this.status.addresses?.find(address => address.type === 'InternalIP')?.address || ''; } + + /** + * Roles derived from the conventional `node-role.kubernetes.io/` labels. + * + * @see {@link https://kubernetes.io/docs/reference/labels-annotations-taints/#node-role-kubernetes-io} + */ + getRoles(): string[] { + const labels = this.metadata?.labels ?? {}; + const rolePrefix = 'node-role.kubernetes.io/'; + return Object.keys(labels) + .filter(key => key.startsWith(rolePrefix)) + .map(key => key.slice(rolePrefix.length)); + } } export default Node; diff --git a/frontend/src/lib/k8s/persistentVolume.ts b/frontend/src/lib/k8s/persistentVolume.ts index d2240a55399..35c81aec635 100644 --- a/frontend/src/lib/k8s/persistentVolume.ts +++ b/frontend/src/lib/k8s/persistentVolume.ts @@ -17,17 +17,62 @@ import type { KubeObjectInterface } from './KubeObject'; import { KubeObject } from './KubeObject'; +export interface KubeClaimRef { + apiVersion?: string; + kind?: string; + name?: string; + namespace?: string; + uid?: string; +} + +/** + * Volume source keys recognized on a PersistentVolume spec, in the order they should be reported. + * + * @see {@link https://kubernetes.io/docs/concepts/storage/persistent-volumes/#types-of-persistent-volumes} + */ +export const PV_SOURCE_TYPES = [ + 'csi', + 'hostPath', + 'nfs', + 'local', + 'iscsi', + 'cephfs', + 'rbd', + 'glusterfs', + 'awsElasticBlockStore', + 'gcePersistentDisk', + 'azureDisk', + 'azureFile', + 'cinder', + 'fc', + 'flexVolume', + 'flocker', + 'photonPersistentDisk', + 'portworxVolume', + 'quobyte', + 'scaleIO', + 'storageos', + 'vsphereVolume', +] as const; + +export type KubePersistentVolumeSourceKey = (typeof PV_SOURCE_TYPES)[number]; + export interface KubePersistentVolume extends KubeObjectInterface { spec: { - capacity: { - storage: string; + capacity?: { + storage?: string; }; + accessModes?: string[]; + volumeMode?: string; + persistentVolumeReclaimPolicy?: string; + storageClassName?: string; + claimRef?: KubeClaimRef; [other: string]: any; }; status: { - message: string; - phase: string; - reason: string; + message?: string; + phase?: string; + reason?: string; }; } @@ -63,6 +108,11 @@ class PersistentVolume extends KubeObject { get status() { return this.jsonData.status; } + + /** First volume-source key set on this PV's spec, e.g. 'csi', 'hostPath'. */ + getSourceType(): KubePersistentVolumeSourceKey | undefined { + return PV_SOURCE_TYPES.find(key => (this.spec as Record)?.[key]); + } } export default PersistentVolume; diff --git a/frontend/src/lib/k8s/pod.ts b/frontend/src/lib/k8s/pod.ts index 63f88951408..07127e39682 100644 --- a/frontend/src/lib/k8s/pod.ts +++ b/frontend/src/lib/k8s/pod.ts @@ -29,7 +29,7 @@ export interface KubeVolume { export interface KubePodSpec { containers: KubeContainer[]; - nodeName: string; + nodeName?: string; nodeSelector?: { [key: string]: string; }; @@ -41,7 +41,10 @@ export interface KubePodSpec { volumes?: KubeVolume[]; serviceAccountName?: string; serviceAccount?: string; - priority?: string; + priority?: number; + priorityClassName?: string; + runtimeClassName?: string; + terminationGracePeriodSeconds?: number; tolerations?: any[]; restartPolicy?: string; } @@ -49,18 +52,19 @@ export interface KubePodSpec { export interface KubePod extends KubeObjectInterface { spec: KubePodSpec; status: { - conditions: KubeCondition[]; - containerStatuses: KubeContainerStatus[]; + conditions?: KubeCondition[]; + containerStatuses?: KubeContainerStatus[]; initContainerStatuses?: KubeContainerStatus[]; ephemeralContainerStatuses?: KubeContainerStatus[]; hostIP?: string; hostIPs?: { ip: string }[]; podIPs?: { ip: string }[]; message?: string; - phase: string; + phase?: string; qosClass?: string; reason?: string; - startTime: Time; + nominatedNodeName?: string; + startTime?: Time; [other: string]: any; }; } @@ -390,7 +394,7 @@ class Pod extends KubeObject { let lastRestartDate = new Date(0); let lastRestartableInitContainerRestartDate = new Date(0); - let reason = this.status.reason || this.status.phase; + let reason = this.status.reason || this.status.phase || ''; const initContainers: Record = {}; let totalContainers = (this.spec.containers ?? []).length; @@ -467,7 +471,7 @@ class Pod extends KubeObject { lastRestartDate = lastRestartableInitContainerRestartDate; let hasRunning = false; for (let i = (this.status?.containerStatuses?.length || 0) - 1; i >= 0; i--) { - const container = this.status?.containerStatuses[i]; + const container = this.status!.containerStatuses![i]; restarts += container.restartCount; lastRestartDate = this.getLastRestartDate(container, lastRestartDate); diff --git a/frontend/src/lib/k8s/service.ts b/frontend/src/lib/k8s/service.ts index 95ea6ce1a6f..bc0c042ee68 100644 --- a/frontend/src/lib/k8s/service.ts +++ b/frontend/src/lib/k8s/service.ts @@ -31,18 +31,38 @@ export interface KubeLoadBalancerIngress { ports?: KubePortStatus[]; } +export interface KubeServicePort { + name?: string; + nodePort?: number; + port: number; + protocol?: string; + targetPort?: number | string; + appProtocol?: string; +} + export interface KubeService extends KubeObjectInterface { spec: { - clusterIP: string; - ports?: { - name: string; - nodePort: number; - port: number; - protocol: string; - targetPort: number | string; - }[]; - type: string; - externalIPs: string[]; + clusterIP?: string; + clusterIPs?: string[]; + ports?: KubeServicePort[]; + type?: string; + externalIPs?: string[]; + externalName?: string; + externalTrafficPolicy?: string; + internalTrafficPolicy?: string; + healthCheckNodePort?: number; + sessionAffinity?: string; + sessionAffinityConfig?: { + clientIP?: { + timeoutSeconds?: number; + }; + }; + ipFamilies?: string[]; + ipFamilyPolicy?: string; + loadBalancerClass?: string; + loadBalancerIP?: string; + loadBalancerSourceRanges?: string[]; + trafficDistribution?: string; selector: { [key: string]: string; }; diff --git a/frontend/src/lib/k8s/serviceAccount.ts b/frontend/src/lib/k8s/serviceAccount.ts index 55236b6fd6f..ce654cf0f45 100644 --- a/frontend/src/lib/k8s/serviceAccount.ts +++ b/frontend/src/lib/k8s/serviceAccount.ts @@ -18,7 +18,7 @@ import type { KubeObjectInterface } from './KubeObject'; import { KubeObject } from './KubeObject'; export interface KubeServiceAccount extends KubeObjectInterface { - secrets: { + secrets?: { apiVersion: string; fieldPath: string; kind: string; @@ -26,6 +26,8 @@ export interface KubeServiceAccount extends KubeObjectInterface { namespace: string; uid: string; }[]; + imagePullSecrets?: { name: string }[]; + automountServiceAccountToken?: boolean; } class ServiceAccount extends KubeObject { @@ -47,6 +49,14 @@ class ServiceAccount extends KubeObject { get secrets(): KubeServiceAccount['secrets'] { return this.jsonData.secrets; } + + get imagePullSecrets(): KubeServiceAccount['imagePullSecrets'] { + return this.jsonData.imagePullSecrets; + } + + get automountServiceAccountToken(): KubeServiceAccount['automountServiceAccountToken'] { + return this.jsonData.automountServiceAccountToken; + } } export default ServiceAccount; diff --git a/frontend/src/lib/k8s/statefulSet.ts b/frontend/src/lib/k8s/statefulSet.ts index 59688748217..7ad8e81e63c 100644 --- a/frontend/src/lib/k8s/statefulSet.ts +++ b/frontend/src/lib/k8s/statefulSet.ts @@ -27,11 +27,13 @@ import type { RevisionInfo, RollbackResult } from './rollback'; export interface KubeStatefulSet extends KubeObjectInterface { spec: { selector: LabelSelector; - updateStrategy: { - rollingUpdate: { - partition: number; + serviceName?: string; + podManagementPolicy?: 'OrderedReady' | 'Parallel'; + updateStrategy?: { + rollingUpdate?: { + partition?: number; }; - type: string; + type?: string; }; template: { metadata?: KubeMetadata; diff --git a/frontend/src/lib/k8s/storageClass.ts b/frontend/src/lib/k8s/storageClass.ts index 27b53cade03..a3c1230f357 100644 --- a/frontend/src/lib/k8s/storageClass.ts +++ b/frontend/src/lib/k8s/storageClass.ts @@ -19,9 +19,11 @@ import { KubeObject } from './KubeObject'; export interface KubeStorageClass extends KubeObjectInterface { provisioner: string; - reclaimPolicy: string; - volumeBindingMode: string; + reclaimPolicy?: string; + volumeBindingMode?: string; allowVolumeExpansion?: boolean; + parameters?: { [key: string]: string }; + mountOptions?: string[]; } class StorageClass extends KubeObject { @@ -63,6 +65,14 @@ class StorageClass extends KubeObject { return this.jsonData.allowVolumeExpansion; } + get parameters() { + return this.jsonData.parameters; + } + + get mountOptions() { + return this.jsonData.mountOptions; + } + static get listRoute() { return 'storageClasses'; } diff --git a/frontend/src/plugin/__snapshots__/pluginLib.snapshot b/frontend/src/plugin/__snapshots__/pluginLib.snapshot index fc66de18da9..06797cdbed7 100644 --- a/frontend/src/plugin/__snapshots__/pluginLib.snapshot +++ b/frontend/src/plugin/__snapshots__/pluginLib.snapshot @@ -420,6 +420,30 @@ "default": [Function], }, "persistentVolume": { + "PV_SOURCE_TYPES": [ + "csi", + "hostPath", + "nfs", + "local", + "iscsi", + "cephfs", + "rbd", + "glusterfs", + "awsElasticBlockStore", + "gcePersistentDisk", + "azureDisk", + "azureFile", + "cinder", + "fc", + "flexVolume", + "flocker", + "photonPersistentDisk", + "portworxVolume", + "quobyte", + "scaleIO", + "storageos", + "vsphereVolume", + ], "default": [Function], }, "persistentVolumeClaim": { @@ -463,6 +487,7 @@ }, "Lodash": Function { "default": [Function], + "module.exports": [Function], }, "MonacoEditor": "Present", "MuiCore": "Present",