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 @@