diff --git a/frontend/src/components/common/CopyLabel.stories.tsx b/frontend/src/components/common/CopyLabel.stories.tsx new file mode 100644 index 00000000000..311a10101fa --- /dev/null +++ b/frontend/src/components/common/CopyLabel.stories.tsx @@ -0,0 +1,46 @@ +/* + * 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 { Meta, StoryFn } from '@storybook/react'; +import { TestContext } from '../../test'; +import CopyLabel, { CopyLabelProps } from './CopyLabel'; + +export default { + title: 'CopyLabel', + component: CopyLabel, + argTypes: {}, + decorators: [ + Story => ( + + + + ), + ], +} as Meta; + +const Template: StoryFn = args => ; + +export const Default = Template.bind({}); +Default.args = { + textToCopy: '192.168.1.1', + children: '192.168.1.1', +}; + +export const WithDifferentContent = Template.bind({}); +WithDifferentContent.args = { + textToCopy: 'my-pod-12345', + children: 'my-pod (copy name)', +}; diff --git a/frontend/src/components/common/CopyLabel.tsx b/frontend/src/components/common/CopyLabel.tsx new file mode 100644 index 00000000000..74d2d455c4a --- /dev/null +++ b/frontend/src/components/common/CopyLabel.tsx @@ -0,0 +1,59 @@ +/* + * 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 Box from '@mui/material/Box'; +import React, { ReactNode } from 'react'; +import CopyButton from './Resource/CopyButton'; + +export interface CopyLabelProps { + /** The text to copy when the copy button is clicked */ + textToCopy: string; + /** The content to display (can be different from textToCopy) */ + children: ReactNode; +} + +/** + * A component that displays content with a copy button that appears on hover. + * This is useful for values that users may want to copy, like IPs, resource names, etc. + */ +export default function CopyLabel({ textToCopy, children }: CopyLabelProps) { + return ( + + {children} + + + + + ); +} diff --git a/frontend/src/components/common/NameValueTable/NameValueTable.stories.tsx b/frontend/src/components/common/NameValueTable/NameValueTable.stories.tsx index 3e306475e51..0a515d579f3 100644 --- a/frontend/src/components/common/NameValueTable/NameValueTable.stories.tsx +++ b/frontend/src/components/common/NameValueTable/NameValueTable.stories.tsx @@ -72,3 +72,23 @@ WithHiddenLastChildren.args = { }, ], }; + +export const WithCopyableValue = Template.bind({}); +WithCopyableValue.args = { + rows: [ + { + name: 'IP Address', + value: '192.168.1.1', + copyValue: '192.168.1.1', + }, + { + name: 'Pod Name', + value: 'my-pod-12345', + copyValue: 'my-pod-12345', + }, + { + name: 'Regular Value', + value: 'Not copyable', + }, + ], +}; diff --git a/frontend/src/components/common/NameValueTable/NameValueTable.tsx b/frontend/src/components/common/NameValueTable/NameValueTable.tsx index bc942b86c75..017928eb9ef 100644 --- a/frontend/src/components/common/NameValueTable/NameValueTable.tsx +++ b/frontend/src/components/common/NameValueTable/NameValueTable.tsx @@ -17,6 +17,7 @@ import { GridProps } from '@mui/material/Grid'; import Grid from '@mui/material/Grid'; import React, { ReactNode } from 'react'; +import CopyLabel from '../CopyLabel'; import { ValueLabel } from '../Label'; // TODO: use ReactNode after migration to react 18 @@ -35,6 +36,9 @@ export interface NameValueTableRow { withHighlightStyle?: boolean; /** The ID to use for the name element, useful for accessibility */ nameID?: string; + /** If provided, shows a copy button on hover that copies this text. + * Useful for values like IPs, resource names, etc. that users may want to copy. */ + copyValue?: string; } export interface NameValueTableProps { @@ -99,6 +103,7 @@ export default function NameValueTable(props: NameValueTableProps) { withHighlightStyle = false, valueFullRow = false, valueCellProps = {}, + copyValue, }, i ) => { @@ -200,7 +205,13 @@ export default function NameValueTable(props: NameValueTableProps) { {...otherValueCellProps} {...valueCellProps} > - + {copyValue ? ( + + + + ) : ( + + )} ); } diff --git a/frontend/src/components/common/NameValueTable/__snapshots__/NameValueTable.WithCopyableValue.stories.storyshot b/frontend/src/components/common/NameValueTable/__snapshots__/NameValueTable.WithCopyableValue.stories.storyshot new file mode 100644 index 00000000000..ddc444e6cf7 --- /dev/null +++ b/frontend/src/components/common/NameValueTable/__snapshots__/NameValueTable.WithCopyableValue.stories.storyshot @@ -0,0 +1,96 @@ + +
+
+
+ IP Address +
+
+
+ + + 192.168.1.1 + + + + + +
+
+
+ Pod Name +
+
+
+ + + my-pod-12345 + + + + + +
+
+
+ Regular Value +
+
+ + Not copyable + +
+
+
+ \ No newline at end of file diff --git a/frontend/src/components/common/__snapshots__/CopyLabel.Default.stories.storyshot b/frontend/src/components/common/__snapshots__/CopyLabel.Default.stories.storyshot new file mode 100644 index 00000000000..d9dd946a695 --- /dev/null +++ b/frontend/src/components/common/__snapshots__/CopyLabel.Default.stories.storyshot @@ -0,0 +1,28 @@ + +
+
+ + 192.168.1.1 + + + + +
+
+ \ No newline at end of file diff --git a/frontend/src/components/common/__snapshots__/CopyLabel.WithDifferentContent.stories.storyshot b/frontend/src/components/common/__snapshots__/CopyLabel.WithDifferentContent.stories.storyshot new file mode 100644 index 00000000000..d1628dd89ac --- /dev/null +++ b/frontend/src/components/common/__snapshots__/CopyLabel.WithDifferentContent.stories.storyshot @@ -0,0 +1,28 @@ + +
+
+ + my-pod (copy name) + + + + +
+
+ \ No newline at end of file diff --git a/frontend/src/components/common/index.test.ts b/frontend/src/components/common/index.test.ts index 1d8b6e2a18e..f9e90530703 100644 --- a/frontend/src/components/common/index.test.ts +++ b/frontend/src/components/common/index.test.ts @@ -36,6 +36,7 @@ const checkExports = [ 'Chart', 'ConfirmDialog', 'ConfirmButton', + 'CopyLabel', 'CreateResourceButton', 'Dialog', 'EmptyContent', diff --git a/frontend/src/components/common/index.ts b/frontend/src/components/common/index.ts index 4366e948074..b5d466f17a5 100644 --- a/frontend/src/components/common/index.ts +++ b/frontend/src/components/common/index.ts @@ -18,6 +18,8 @@ export * from './ActionButton'; export { default as ActionButton } from './ActionButton'; export * from './BackLink'; export * from './Chart'; +export * from './CopyLabel'; +export { default as CopyLabel } from './CopyLabel'; export * from './Dialog'; export * from './EmptyContent'; export { default as EmptyContent } from './EmptyContent'; diff --git a/frontend/src/components/endpointSlices/Details.tsx b/frontend/src/components/endpointSlices/Details.tsx index 8d722ae8cbb..d99bab6ce93 100644 --- a/frontend/src/components/endpointSlices/Details.tsx +++ b/frontend/src/components/endpointSlices/Details.tsx @@ -18,7 +18,7 @@ import Box from '@mui/material/Box'; import { useTranslation } from 'react-i18next'; import { useParams } from 'react-router-dom'; import EndpointSlice from '../../lib/k8s/endpointSlices'; -import { SectionBox, SimpleTable, StatusLabel } from '../common'; +import { CopyLabel, SectionBox, SimpleTable, StatusLabel } from '../common'; import { DetailsGrid } from '../common/Resource'; export default function EndpointSliceDetails(props: { @@ -71,7 +71,14 @@ export default function EndpointSliceDetails(props: { }, { label: t('Addresses'), - getter: endpoint => endpoint.addresses?.join(','), + getter: endpoint => { + const addresses = endpoint.addresses?.join(','); + return addresses ? ( + {addresses} + ) : ( + '' + ); + }, }, { label: t('Conditions'), diff --git a/frontend/src/components/endpointSlices/List.tsx b/frontend/src/components/endpointSlices/List.tsx index a7fc1780f3b..410f1aadc21 100644 --- a/frontend/src/components/endpointSlices/List.tsx +++ b/frontend/src/components/endpointSlices/List.tsx @@ -18,6 +18,7 @@ import Box from '@mui/material/Box'; import { useTranslation } from 'react-i18next'; import EndpointSlice from '../../lib/k8s/endpointSlices'; import { LabelListItem } from '../common'; +import CopyLabel from '../common/CopyLabel'; import { StatusLabel } from '../common/Label'; import ResourceListView from '../common/Resource/ResourceListView'; @@ -27,12 +28,18 @@ function renderEndpoints(endpointSlice: EndpointSlice) { return null; } - return endpoints.map((endpoint: any) => { + return endpoints.map((endpoint: any, index: number) => { const { addresses, conditions } = endpoint; + const addressesStr = addresses.join(','); + return ( - + - {addresses.join(',')} + {addresses.length === 1 ? ( + {addresses[0]} + ) : ( + addressesStr + )} ); diff --git a/frontend/src/components/endpointSlices/__snapshots__/EndpointSliceList.Items.stories.storyshot b/frontend/src/components/endpointSlices/__snapshots__/EndpointSliceList.Items.stories.storyshot index adc0cb72824..f703a1684ba 100644 --- a/frontend/src/components/endpointSlices/__snapshots__/EndpointSliceList.Items.stories.storyshot +++ b/frontend/src/components/endpointSlices/__snapshots__/EndpointSliceList.Items.stories.storyshot @@ -722,7 +722,30 @@ - 127.0.0.1 +
+ + 127.0.0.1 + + + + +
diff --git a/frontend/src/components/endpoints/Details.tsx b/frontend/src/components/endpoints/Details.tsx index 9357f85527d..cd89f7dcf59 100644 --- a/frontend/src/components/endpoints/Details.tsx +++ b/frontend/src/components/endpoints/Details.tsx @@ -18,6 +18,7 @@ import { useTranslation } from 'react-i18next'; import { useLocation, useParams } from 'react-router-dom'; import { ResourceClasses } from '../../lib/k8s'; import Endpoints, { KubeEndpoint } from '../../lib/k8s/endpoints'; +import CopyLabel from '../common/CopyLabel'; import Empty from '../common/EmptyContent'; import Link from '../common/Link'; import { DetailsGrid } from '../common/Resource'; @@ -68,7 +69,12 @@ export default function EndpointDetails(props: { columns={[ { label: t('IP'), - getter: address => address.ip, + getter: address => + address.ip ? ( + {address.ip} + ) : ( + '' + ), }, { label: t('Hostname'), diff --git a/frontend/src/components/endpoints/List.tsx b/frontend/src/components/endpoints/List.tsx index eadb7073134..722b14953ff 100644 --- a/frontend/src/components/endpoints/List.tsx +++ b/frontend/src/components/endpoints/List.tsx @@ -17,6 +17,7 @@ import { useTranslation } from 'react-i18next'; import Endpoints from '../../lib/k8s/endpoints'; import { useFilterFunc } from '../../lib/util'; +import CopyLabel from '../common/CopyLabel'; import LabelListItem from '../common/LabelListItem'; import ResourceListView from '../common/Resource/ResourceListView'; @@ -41,7 +42,18 @@ export default function EndpointList() { id: 'addresses', label: t('translation|Addresses'), getValue: endpoint => endpoint.getAddresses().join(', '), - render: endpoint => , + render: endpoint => { + const addresses = endpoint.getAddresses(); + if (addresses.length === 0) return null; + + // If there's only one address, show it with copy button + if (addresses.length === 1) { + return {addresses[0]}; + } + + // If multiple addresses, show as list (the addresses themselves are IPs) + return ; + }, gridTemplate: 1.5, }, 'age', diff --git a/frontend/src/components/endpoints/__snapshots__/EndpointDetails.Default.stories.storyshot b/frontend/src/components/endpoints/__snapshots__/EndpointDetails.Default.stories.storyshot index f8101dae7e6..a7416a738ef 100644 --- a/frontend/src/components/endpoints/__snapshots__/EndpointDetails.Default.stories.storyshot +++ b/frontend/src/components/endpoints/__snapshots__/EndpointDetails.Default.stories.storyshot @@ -283,7 +283,30 @@ - 127.0.0.1 +
+ + 127.0.0.1 + + + + +
- 127.0.0.2 +
+ + 127.0.0.2 + + + + +
- - 127.0.01:8080 - + + 127.0.01:8080 + + + + + node.getInternalIP(), + render: node => { + const internalIP = node.getInternalIP(); + return internalIP ? {internalIP} : null; + }, }, { id: 'externalIP', label: t('External IP'), getValue: node => node.getExternalIP() || t('translation|None'), + render: node => { + const externalIP = node.getExternalIP(); + return externalIP ? ( + {externalIP} + ) : ( + t('translation|None') + ); + }, }, { id: 'version', diff --git a/frontend/src/components/pod/Details.tsx b/frontend/src/components/pod/Details.tsx index aaebcac3325..583c2559f8c 100644 --- a/frontend/src/components/pod/Details.tsx +++ b/frontend/src/components/pod/Details.tsx @@ -542,6 +542,7 @@ export default function PodDetails(props: PodDetailsProps) { { name: t('Host IP'), value: item.status.hostIP ?? '', + copyValue: item.status.hostIP ?? '', }, ]), // Always include Host IPs, but hide if empty @@ -550,6 +551,9 @@ export default function PodDetails(props: PodDetailsProps) { value: item.status.hostIPs ? item.status.hostIPs.map((ipObj: { ip: string }) => ipObj.ip).join(', ') : '', + copyValue: item.status.hostIPs + ? item.status.hostIPs.map((ipObj: { ip: string }) => ipObj.ip).join(', ') + : '', hideLabel: !item.status.hostIPs || item.status.hostIPs.length === 0, }, // Show Pod IP only if Pod IPs doesn't exist or is empty @@ -559,6 +563,7 @@ export default function PodDetails(props: PodDetailsProps) { { name: t('Pod IP'), value: item.status.podIP ?? '', + copyValue: item.status.podIP ?? '', }, ]), // Always include Pod IPs, but hide if empty @@ -567,6 +572,9 @@ export default function PodDetails(props: PodDetailsProps) { value: item.status.podIPs ? item.status.podIPs.map((ipObj: { ip: string }) => ipObj.ip).join(', ') : '', + copyValue: item.status.podIPs + ? item.status.podIPs.map((ipObj: { ip: string }) => ipObj.ip).join(', ') + : '', hideLabel: !item.status.podIPs || item.status.podIPs.length === 0, }, { diff --git a/frontend/src/components/pod/List.tsx b/frontend/src/components/pod/List.tsx index f0b61b1948a..21d2c1073d5 100644 --- a/frontend/src/components/pod/List.tsx +++ b/frontend/src/components/pod/List.tsx @@ -27,6 +27,7 @@ import { timeAgo } from '../../lib/util'; import { useNamespaces } from '../../redux/filterSlice'; import { HeadlampEventType, useEventCallback } from '../../redux/headlampEventSlice'; import { CreateResourceButton } from '../common'; +import CopyLabel from '../common/CopyLabel'; import { StatusLabel, StatusLabelProps } from '../common/Label'; import Link from '../common/Link'; import ResourceListView from '../common/Resource/ResourceListView'; @@ -346,6 +347,10 @@ export function PodListRenderer(props: PodListProps) { gridTemplate: 'min-content', label: t('glossary|IP'), getValue: pod => pod.status?.podIP ?? '', + render: pod => { + const podIP = pod.status?.podIP; + return podIP ? {podIP} : null; + }, }, { id: 'node', diff --git a/frontend/src/components/pod/__snapshots__/PodDetails.Error.stories.storyshot b/frontend/src/components/pod/__snapshots__/PodDetails.Error.stories.storyshot index a230fa61fe4..58a3d944791 100644 --- a/frontend/src/components/pod/__snapshots__/PodDetails.Error.stories.storyshot +++ b/frontend/src/components/pod/__snapshots__/PodDetails.Error.stories.storyshot @@ -284,11 +284,34 @@
- - 0.0.0.1 - + + + 0.0.0.1 + + + + + +
- - 0.0.0.2 - + + + 0.0.0.2 + + + + + +
- - 0.0.0.1 - + + + 0.0.0.1 + + + + + +
- - 0.0.0.2 - + + + 0.0.0.2 + + + + + +
- - 0.0.0.1 - + + + 0.0.0.1 + + + + + +
- - 0.0.0.2 - + + + 0.0.0.2 + + + + + +
- - 0.0.0.1 - + + + 0.0.0.1 + + + + + +
- - 0.0.0.2 - + + + 0.0.0.2 + + + + + +
- - 0.0.0.1 - + + + 0.0.0.1 + + + + + +
- - 0.0.0.2 - + + + 0.0.0.2 + + + + + +
- 0.0.0.2 +
+ + 0.0.0.2 + + + + +
- 0.0.0.2 +
+ + 0.0.0.2 + + + + +
- 0.0.0.2 +
+ + 0.0.0.2 + + + + +
- 0.0.0.2 +
+ + 0.0.0.2 + + + + +
- 0.0.0.2 +
+ + 0.0.0.2 + + + + +
- 0.0.0.2 +
+ + 0.0.0.2 + + + + +
- 0.0.0.2 +
+ + 0.0.0.2 + + + + +
service.spec.clusterIP, + render: service => { + const clusterIP = service.spec.clusterIP; + return clusterIP ? {clusterIP} : null; + }, }, { id: 'externalIP', label: t('External IP'), gridTemplate: 'min-content', getValue: service => service.getExternalAddresses(), + render: service => { + const externalIP = service.getExternalAddresses(); + return externalIP ? ( + {externalIP} + ) : null; + }, }, { id: 'ports', diff --git a/frontend/src/components/service/__snapshots__/ServiceDetails.Default.stories.storyshot b/frontend/src/components/service/__snapshots__/ServiceDetails.Default.stories.storyshot index 8d6d5d554d5..88c250527f7 100644 --- a/frontend/src/components/service/__snapshots__/ServiceDetails.Default.stories.storyshot +++ b/frontend/src/components/service/__snapshots__/ServiceDetails.Default.stories.storyshot @@ -199,11 +199,34 @@
- - 10.96.0.100 - + + + 10.96.0.100 + + + + + +
- - 34.123.45.67 - + + + 34.123.45.67 + + + + + +
- - 10.96.0.100 - + + + 10.96.0.100 + + + + + +
- - 34.123.45.67 - + + + 34.123.45.67 + + + + + +
- 10.96.0.100 +
+ + 10.96.0.100 + + + + +
- 34.123.45.67 +
+ + 34.123.45.67 + + + + +