Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
122 changes: 122 additions & 0 deletions frontend/src/components/common/Resource/EnvVarGrid.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
/*
* 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 { EnvVarGrid } from './EnvVarGrid';

export default {
title: 'Resource/EnvVarGrid',
component: EnvVarGrid,
argTypes: {
namespace: { control: 'text' },
cluster: { control: 'text' },
},
decorators: [
Story => (
<TestContext>
<Story />
</TestContext>
),
],
} as Meta;

const Template: StoryFn<React.ComponentProps<typeof EnvVarGrid>> = args => <EnvVarGrid {...args} />;

export const Default = Template.bind({});
Default.args = {
namespace: 'default',
cluster: 'minikube',
envVars: [
{ name: 'NODE_ENV', value: 'production' },
{ name: 'DEBUG', value: 'true' },
{ name: 'PLAINTEXT', value: '127.0.0.1' },
],
};

export const ComplexReferences = Template.bind({});
ComplexReferences.args = {
namespace: 'default',
cluster: 'minikube',
envVars: [
{
name: 'API_KEY',
valueFrom: {
secretKeyRef: { name: 'my-secret', key: 'api-key' },
},
},
{
name: 'APP_CONFIG',
valueFrom: {
configMapKeyRef: { name: 'app-config', key: 'config.json' },
},
},
{
name: 'MY_POD_IP',
valueFrom: {
fieldRef: { fieldPath: 'status.podIP', apiVersion: 'v1' },
},
},
{
name: 'CPU_LIMIT',
valueFrom: {
resourceFieldRef: { resource: 'limits.cpu' },
},
},
],
};

export const EdgeCases = Template.bind({});
EdgeCases.args = {
namespace: 'default',
cluster: 'minikube',
envVars: [
{
name: 'MISSING_SECRET_NAME',
valueFrom: {
// @ts-ignore - Testing resilience against missing data
secretKeyRef: { key: 'some-key' },
},
},
{
name: 'MISSING_CONFIG_MAP',
valueFrom: {
// @ts-ignore - Testing resilience against missing data
configMapKeyRef: { key: 'my-config' },
},
},
{
name: 'EMPTY_VALUE_FROM',
valueFrom: {},
},
{
name: 'MALFORMED_FIELD_REF',
valueFrom: {
fieldRef: {} as any,
},
},
],
};

export const ManyVariables = Template.bind({});
ManyVariables.args = {
namespace: 'default',
cluster: 'minikube',
envVars: Array.from({ length: 35 }, (_, i) => ({
name: `VAR_${i}`,
value: `value-${i}`,
})),
};
179 changes: 179 additions & 0 deletions frontend/src/components/common/Resource/EnvVarGrid.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
/*
* 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 { Icon } from '@iconify/react';
import Box from '@mui/material/Box';
import Button from '@mui/material/Button';
import Typography, { TypographyProps } from '@mui/material/Typography';
import React from 'react';
import { useTranslation } from 'react-i18next';
import { KubeContainer } from '../../../lib/k8s/cluster';
import Link from '../Link';

interface EnvVarGridProps {
envVars: NonNullable<KubeContainer['env']>;
namespace: string;
cluster: string;
}

export function EnvVarGrid(props: EnvVarGridProps) {
const { envVars = [], namespace, cluster } = props;
const { t } = useTranslation();
const [expanded, setExpanded] = React.useState(false);
const defaultNumShown = 20;

const validEnvVars = envVars.filter(
env => !!env && typeof env.name === 'string' && env.name.trim() !== ''
);

const EnvEntry = React.forwardRef((props: TypographyProps, ref: React.Ref<HTMLElement>) => {
return (
<Typography
{...props}
sx={theme => ({
color: theme.palette.text.primary,
borderRadius: theme.shape.borderRadius + 'px',
backgroundColor: theme.palette.background.muted,
border: '1px solid',
borderColor: theme.palette.divider,
fontSize: theme.typography.pxToRem(14),
padding: '4px 8px',
marginRight: theme.spacing(1),
whiteSpace: 'nowrap',
display: 'inline-block',
maxWidth: '400px',
overflow: 'hidden',
textOverflow: 'ellipsis',
})}
ref={ref}
/>
);
});
EnvEntry.displayName = 'EnvEntry';

const renderEnvVar = (envVar: NonNullable<KubeContainer['env']>[number]) => {
// Fallback:
if (!envVar.name) {
return null;
}
// Secret Key:
if (envVar.valueFrom?.secretKeyRef) {
const { name: secretName, key: secretKey } = envVar.valueFrom.secretKeyRef;

if (!secretName) {
return (
<EnvEntry component="span" key={envVar.name}>
{envVar.name}: {t('translation|Invalid Secret Reference')}
</EnvEntry>
);
}

const secretUrl = `/c/${encodeURIComponent(cluster)}/secrets/${encodeURIComponent(
namespace
)}/${encodeURIComponent(secretName)}`;

return (
<EnvEntry component="span" key={envVar.name}>
{envVar.name}:{' '}
<Link to={secretUrl} style={{ textDecoration: 'underline', fontWeight: 'bold' }}>
Secret: {secretName} {secretKey ? `(Key: ${secretKey})` : ''}
</Link>
</EnvEntry>
);
}

// Config Map:
if (envVar.valueFrom?.configMapKeyRef) {
const { name: cmName, key: cmKey } = envVar.valueFrom.configMapKeyRef;

if (!cmName) {
return (
<EnvEntry component="span" key={envVar.name}>
{envVar.name}: {t('translation|Invalid Config Map Reference')}
</EnvEntry>
);
}

const configMapUrl = `/c/${encodeURIComponent(cluster)}/configmaps/${encodeURIComponent(
namespace
)}/${encodeURIComponent(cmName)}`;
return (
<EnvEntry component="span" key={envVar.name}>
{envVar.name}:{' '}
<Link to={configMapUrl} style={{ textDecoration: 'underline', fontWeight: 'bold' }}>
ConfigMap: {cmName} {cmKey ? `(Key: ${cmKey})` : ''}
</Link>
</EnvEntry>
);
}

// FieldRef:
if (envVar.valueFrom?.fieldRef) {
const { fieldPath } = envVar.valueFrom.fieldRef;
return (
<EnvEntry component="span" key={envVar.name}>
{envVar.name}: FieldRef {fieldPath ? `(${fieldPath})` : t('translation|Invalid fieldRef')}
</EnvEntry>
);
}

// ResourceFieldRef:
if (envVar.valueFrom?.resourceFieldRef) {
const { resource } = envVar.valueFrom.resourceFieldRef;
return (
<EnvEntry component="span" key={envVar.name}>
{envVar.name}: ResourceField{' '}
{resource ? `(${resource})` : t('translation|Invalid resourceFieldRef')}
</EnvEntry>
);
}

// Plaintext
return (
<EnvEntry component="span" key={envVar.name}>
{envVar.name}: {(envVar.value || '').trim()}
</EnvEntry>
);
};

return (
<Box>
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5 }}>
{validEnvVars
.slice(0, expanded ? validEnvVars.length : defaultNumShown)
.map(env => renderEnvVar(env))}
</Box>
{validEnvVars.length > defaultNumShown && (
<Button
onClick={() => setExpanded(!expanded)}
size="small"
aria-expanded={expanded}
aria-label={
expanded ? t('translation|Show fewer') : t('translation|Show all environment variables')
}
startIcon={<Icon icon={expanded ? 'mdi:menu-up' : 'mdi:menu-down'} />}
sx={{ mt: 1, mb: 1 }}
>
{!expanded
? t('translation|Show all environment variables (+{{count}} more)', {
count: validEnvVars.length - defaultNumShown,
})
: t('translation|Show fewer')}
</Button>
)}
</Box>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
<body>
<div>
<div
class="MuiBox-root css-0"
>
<div
class="MuiBox-root css-yi3mkw"
>
<span
class="MuiTypography-root MuiTypography-body1 css-yecqev-MuiTypography-root"
>
API_KEY
:

<a
class="MuiTypography-root MuiTypography-inherit MuiLink-root MuiLink-underlineHover css-2ugbm1-MuiTypography-root-MuiLink-root"
href="/c/minikube/secrets/default/my-secret"
style="text-decoration: underline; font-weight: bold;"
>
Secret:
my-secret

(Key: api-key)
</a>
</span>
<span
class="MuiTypography-root MuiTypography-body1 css-yecqev-MuiTypography-root"
>
APP_CONFIG
:

<a
class="MuiTypography-root MuiTypography-inherit MuiLink-root MuiLink-underlineHover css-2ugbm1-MuiTypography-root-MuiLink-root"
href="/c/minikube/configmaps/default/app-config"
style="text-decoration: underline; font-weight: bold;"
>
ConfigMap:
app-config

(Key: config.json)
</a>
</span>
<span
class="MuiTypography-root MuiTypography-body1 css-yecqev-MuiTypography-root"
>
MY_POD_IP
: FieldRef
(status.podIP)
</span>
<span
class="MuiTypography-root MuiTypography-body1 css-yecqev-MuiTypography-root"
>
CPU_LIMIT
: ResourceField

(limits.cpu)
</span>
</div>
</div>
</div>
</body>
Loading
Loading