diff --git a/frontend/src/components/common/Resource/Resource.tsx b/frontend/src/components/common/Resource/Resource.tsx index 9602f75de55..cedcf3e7210 100644 --- a/frontend/src/components/common/Resource/Resource.tsx +++ b/frontend/src/components/common/Resource/Resource.tsx @@ -28,7 +28,10 @@ import { BaseTextFieldProps } from '@mui/material/TextField'; import Typography from '@mui/material/Typography'; import { useTheme } from '@mui/system'; import { Location } from 'history'; -import _, { has } from 'lodash'; +import { Base64 } from 'js-base64'; +import { JSONPath } from 'jsonpath-plus'; +import _, { ceil, has } from 'lodash'; +import { useSnackbar } from 'notistack'; import React, { PropsWithChildren, ReactNode } from 'react'; import { useTranslation } from 'react-i18next'; import { generatePath, NavLinkProps, useLocation } from 'react-router-dom'; @@ -36,15 +39,18 @@ import YAML from 'yaml'; import { labelSelectorToQuery, ResourceClasses, useCluster } from '../../../lib/k8s'; import { ApiError } from '../../../lib/k8s/api/v2/ApiError'; import { KubeCondition, KubeContainer, KubeContainerStatus } from '../../../lib/k8s/cluster'; +import ConfigMap from '../../../lib/k8s/configMap'; import { KubeEvent } from '../../../lib/k8s/event'; import { KubeObject } from '../../../lib/k8s/KubeObject'; import { KubeObjectInterface } from '../../../lib/k8s/KubeObject'; import { KubeObjectClass } from '../../../lib/k8s/KubeObject'; import Pod, { KubePod, KubeVolume } from '../../../lib/k8s/pod'; import { METRIC_REFETCH_INTERVAL_MS, PodMetrics } from '../../../lib/k8s/PodMetrics'; +import Secret from '../../../lib/k8s/secret'; import { RouteURLProps } from '../../../lib/router'; import { createRouteURL } from '../../../lib/router/createRouteURL'; import { getThemeName } from '../../../lib/themes'; +import { divideK8sResources } from '../../../lib/units'; import { localeDate, useId } from '../../../lib/util'; import { HeadlampEventType, useEventCallback } from '../../../redux/headlampEventSlice'; import { useTypedSelector } from '../../../redux/hooks'; @@ -521,7 +527,8 @@ export function SecretField(props: SecretFieldProps) { } try { - await navigator.clipboard.writeText(secret); + // Copy the decoded value + await navigator.clipboard.writeText(Base64.decode(secret)); setCopied(true); setTimeout(() => setCopied(false), 1200); } catch (err) { @@ -544,12 +551,12 @@ export function SecretField(props: SecretFieldProps) { ); return ( - + {!!secret ? {copyButton} : copyButton} event.preventDefault()} size="medium" @@ -565,7 +572,7 @@ export function SecretField(props: SecretFieldProps) { fullWidth multiline={showPassword} maxRows="20" - value={showPassword ? (value as string) : '******'} + value={showPassword ? Base64.decode(value as string) : '••••••••'} {...other} /> @@ -638,6 +645,614 @@ export function ConditionsTable(props: ConditionsTableProps) { ); } +// Types for environment variables feature +export interface EnvironmentVariablesProps { + pod?: KubePod; + container?: KubeContainer; +} + +interface EnvVarReference { + name: string; + type: 'secret' | 'configMap' | 'secretRef' | 'configMapRef' | 'field' | 'resourceField' | 'value'; + resourceName?: string; + key?: string; + optional?: boolean; + prefix?: string; + fieldPath?: string; + resource?: string; + containerName?: string; + divisor?: string; + value?: string; +} + +interface EnvironmentVariable { + key: string; + from?: KubeObject | string | null; + value: string; + isError: boolean; + isSecret: boolean; + isOutOfSync: boolean; +} + +interface FetchedResource { + resource: KubeObject | null; + error: ApiError | null; +} + +/** + * Extracts all environment variable references from a container spec. + * This is a pure function with no hooks. + */ +function extractEnvVarReferences(container: KubeContainer): EnvVarReference[] { + const refs: EnvVarReference[] = []; + + // Process env variables + container?.env?.forEach(item => { + if (item.value) { + refs.push({ name: item.name, type: 'value', value: item.value }); + } else if (item.valueFrom) { + const vf = item.valueFrom; + if (vf.secretKeyRef) { + refs.push({ + name: item.name, + type: 'secret', + resourceName: vf.secretKeyRef.name, + key: vf.secretKeyRef.key, + optional: vf.secretKeyRef.optional, + }); + } else if (vf.configMapKeyRef) { + refs.push({ + name: item.name, + type: 'configMap', + resourceName: vf.configMapKeyRef.name, + key: vf.configMapKeyRef.key, + optional: vf.configMapKeyRef.optional, + }); + } else if (vf.fieldRef) { + refs.push({ + name: item.name, + type: 'field', + fieldPath: vf.fieldRef.fieldPath, + }); + } else if (vf.resourceFieldRef) { + refs.push({ + name: item.name, + type: 'resourceField', + resource: vf.resourceFieldRef.resource, + containerName: vf.resourceFieldRef.containerName, + divisor: vf.resourceFieldRef.divisor, + }); + } + } + }); + + // Process envFrom + container?.envFrom?.forEach(item => { + if (item.secretRef) { + refs.push({ + name: item.secretRef.name, + type: 'secretRef', + resourceName: item.secretRef.name, + optional: item.secretRef.optional, + prefix: item.prefix, + }); + } else if (item.configMapRef) { + refs.push({ + name: item.configMapRef.name, + type: 'configMapRef', + resourceName: item.configMapRef.name, + optional: item.configMapRef.optional, + prefix: item.prefix, + }); + } + }); + + return refs; +} + +/** + * Component that fetches a Secret and reports the result. + * This properly calls hooks at the top level. + */ +function SecretFetcher(props: { + name: string; + namespace: string; + onResult: (name: string, resource: KubeObject | null, error: ApiError | null) => void; +}) { + const { name, namespace, onResult } = props; + const [secret, error] = Secret.useGet(name, namespace); + + React.useEffect(() => { + // Only call onResult when we have a definitive result (either data or error) + if (secret || error) { + onResult(name, secret, error); + } + }, [secret, error, name, onResult]); + + return null; +} + +/** + * Component that fetches a ConfigMap and reports the result. + * This properly calls hooks at the top level. + */ +function ConfigMapFetcher(props: { + name: string; + namespace: string; + onResult: (name: string, resource: KubeObject | null, error: ApiError | null) => void; +}) { + const { name, namespace, onResult } = props; + const [configMap, error] = ConfigMap.useGet(name, namespace); + + React.useEffect(() => { + if (configMap || error) { + onResult(name, configMap, error); + } + }, [configMap, error, name, onResult]); + + return null; +} + +/** + * Builds environment variables from references and fetched resources. + * This is a pure function with no hooks. + */ +function buildEnvironmentVariables( + references: EnvVarReference[], + fetchedSecrets: Map, + fetchedConfigMaps: Map, + pod: KubePod, + container: KubeContainer, + containerStartTimestamp: string | undefined +): EnvironmentVariable[] { + const variables = new Map>(); + + // Helper to compare timestamps + const isOutOfSync = (resourceTimestamp: string | undefined): boolean => { + if (!resourceTimestamp || !containerStartTimestamp) return false; + return new Date(resourceTimestamp).getTime() > new Date(containerStartTimestamp).getTime(); + }; + + references.forEach(ref => { + switch (ref.type) { + case 'value': + variables.set(ref.name, { + value: ref.value!, + from: 'manifest', + isSecret: false, + isError: false, + isOutOfSync: false, + }); + break; + + case 'secret': { + const fetched = fetchedSecrets.get(ref.resourceName!); + if (!fetched) break; // Still loading + + const { resource: secret, error } = fetched; + if (error) { + if (error.status === 404 && ref.optional) break; + variables.set(ref.name, { + value: error.message, + from: secret, + isError: true, + isSecret: false, + isOutOfSync: false, + }); + } else if (secret) { + const secretData = (secret as any).data || {}; + const value = secretData[ref.key!] ? atob(secretData[ref.key!]) : ''; + variables.set(ref.name, { + value, + from: secret, + isError: false, + isSecret: true, + isOutOfSync: isOutOfSync(secret.metadata?.creationTimestamp), + }); + } + break; + } + + case 'configMap': { + const fetched = fetchedConfigMaps.get(ref.resourceName!); + if (!fetched) break; + + const { resource: configMap, error } = fetched; + if (error) { + if (error.status === 404 && ref.optional) break; + variables.set(ref.name, { + value: error.message, + from: configMap, + isError: true, + isSecret: false, + isOutOfSync: false, + }); + } else if (configMap) { + const configMapData = (configMap as any).data || {}; + variables.set(ref.name, { + value: configMapData[ref.key!] || '', + from: configMap, + isError: false, + isSecret: false, + isOutOfSync: isOutOfSync(configMap.metadata?.creationTimestamp), + }); + } + break; + } + + case 'secretRef': { + const fetched = fetchedSecrets.get(ref.resourceName!); + if (!fetched) break; + + const { resource: secret, error } = fetched; + const prefix = ref.prefix || ''; + if (error) { + if (error.status === 404 && ref.optional) break; + variables.set(`${prefix}${ref.resourceName}`, { + value: error.message, + from: secret, + isError: true, + isSecret: false, + isOutOfSync: false, + }); + } else if (secret) { + const secretData = (secret as any).data || {}; + const outOfSync = isOutOfSync(secret.metadata?.creationTimestamp); + Object.entries(secretData).forEach(([key, value]) => { + variables.set(`${prefix}${key}`, { + value: atob(value as string), + from: secret, + isError: false, + isSecret: true, + isOutOfSync: outOfSync, + }); + }); + } + break; + } + + case 'configMapRef': { + const fetched = fetchedConfigMaps.get(ref.resourceName!); + if (!fetched) break; + + const { resource: configMap, error } = fetched; + const prefix = ref.prefix || ''; + if (error) { + if (error.status === 404 && ref.optional) break; + variables.set(`${prefix}${ref.resourceName}`, { + value: error.message, + from: configMap, + isError: true, + isSecret: false, + isOutOfSync: false, + }); + } else if (configMap) { + const configMapData = (configMap as any).data || {}; + const outOfSync = isOutOfSync(configMap.metadata?.creationTimestamp); + Object.entries(configMapData).forEach(([key, value]) => { + variables.set(`${prefix}${key}`, { + value: value as string, + from: configMap, + isError: false, + isSecret: false, + isOutOfSync: outOfSync, + }); + }); + } + break; + } + + case 'field': { + let value: string; + let isError = false; + try { + const result = JSONPath({ path: '$.' + ref.fieldPath, json: pod }); + value = Array.isArray(result) ? result[0] : result; + if (value === undefined) { + value = ''; + } else if (typeof value !== 'string') { + value = JSON.stringify(value); + } + } catch (err) { + isError = true; + value = err instanceof Error ? `Error: ${err.message}` : 'Unknown error'; + } + variables.set(ref.name, { + value, + from: `fieldRef: ${ref.fieldPath}`, + isSecret: false, + isError, + isOutOfSync: false, + }); + break; + } + + case 'resourceField': { + let value = ''; + let isError = false; + const containerName = ref.containerName || container.name; + const resourceType = ref.resource!; + let divisor = ref.divisor || '1'; + if (divisor === '0') { + divisor = '1'; + } + + try { + const targetContainer = pod.spec?.containers?.find(c => c.name === containerName); + if (!targetContainer) { + throw new Error(`Container ${containerName} not found`); + } + + const [category, type] = resourceType.split('.'); + const resourceValue = + targetContainer.resources?.[category as 'requests' | 'limits']?.[ + type as 'cpu' | 'memory' + ]; + if (!resourceValue) { + throw new Error(`Resource ${resourceType} not found for container ${containerName}`); + } + + value = `${ceil(divideK8sResources(resourceValue, divisor, type as 'cpu' | 'memory'))}`; + } catch (err) { + isError = true; + if (err instanceof Error) { + value = err.message; + } else { + value = 'Unknown error occurred.'; + } + } + + variables.set(ref.name, { + value, + from: `resourceFieldRef: ${containerName}.${resourceType} / ${divisor}`, + isSecret: false, + isError, + isOutOfSync: false, + }); + break; + } + } + }); + + return Array.from(variables.entries()) + .map(([key, value]) => ({ key, ...value })) + .sort((a, b) => a.key.localeCompare(b.key)); +} + +/** + * Displays environment variables for a container, fetching values from + * Secrets and ConfigMaps as needed. + */ +export function ContainerEnvironmentVariables(props: EnvironmentVariablesProps) { + const { pod, container } = props; + const { t } = useTranslation(); + const { enqueueSnackbar } = useSnackbar(); + + // State to store fetched resources + const [fetchedSecrets, setFetchedSecrets] = React.useState>( + new Map() + ); + const [fetchedConfigMaps, setFetchedConfigMaps] = React.useState>( + new Map() + ); + + // Early return if no env vars + if ( + (!container?.env && !container?.envFrom) || + !pod?.status?.containerStatuses || + !pod?.metadata?.namespace + ) { + return null; + } + + const namespace = pod.metadata.namespace; + const containerStartTimestamp = (() => { + let timestamp = pod.metadata?.creationTimestamp; + const containerStatus = pod.status?.containerStatuses?.find(c => c.name === container?.name); + if (containerStatus?.started && containerStatus.state?.running?.startedAt) { + timestamp = containerStatus.state.running.startedAt; + } + return timestamp; + })(); + + // Extract all references upfront (pure function, no hooks) + const references = extractEnvVarReferences(container); + + // Get unique resource names to fetch + const secretsToFetch = React.useMemo(() => { + const secrets = new Set(); + references.forEach(ref => { + if ((ref.type === 'secret' || ref.type === 'secretRef') && ref.resourceName) { + secrets.add(ref.resourceName); + } + }); + return Array.from(secrets); + }, [references]); + + const configMapsToFetch = React.useMemo(() => { + const configMaps = new Set(); + references.forEach(ref => { + if ((ref.type === 'configMap' || ref.type === 'configMapRef') && ref.resourceName) { + configMaps.add(ref.resourceName); + } + }); + return Array.from(configMaps); + }, [references]); + + // Callbacks to handle fetched resources + const handleSecretFetched = React.useCallback( + (name: string, resource: KubeObject | null, error: ApiError | null) => { + setFetchedSecrets(prev => { + const next = new Map(prev); + next.set(name, { resource, error }); + return next; + }); + }, + [] + ); + + const handleConfigMapFetched = React.useCallback( + (name: string, resource: KubeObject | null, error: ApiError | null) => { + setFetchedConfigMaps(prev => { + const next = new Map(prev); + next.set(name, { resource, error }); + return next; + }); + }, + [] + ); + + // Copy handler using notistack + const handleCopy = React.useCallback( + (text: string) => { + navigator.clipboard.writeText(text).then( + () => enqueueSnackbar(t('translation|Copied'), { variant: 'success' }), + err => console.error('Failed to copy: ', err) + ); + }, + [enqueueSnackbar, t] + ); + + // Build variables from fetched resources + const variables = buildEnvironmentVariables( + references, + fetchedSecrets, + fetchedConfigMaps, + pod, + container, + containerStartTimestamp + ); + + // Define columns for the table + const columns = [ + { + label: t('translation|Name'), + getter: (data: EnvironmentVariable) => { + return ( + + + {data.key} + + {data.isOutOfSync && ( + + { + if (typeof data.from === 'object' && data.from !== null) { + const o = data.from as KubeObject; + return `${o.kind} ${o.metadata.name}`; + } + return 'the referenced object'; + })(), + } + )} + /> + + )} + + ); + }, + }, + { + label: t('translation|Value'), + getter: (data: EnvironmentVariable) => { + if (data.isError) { + return ( + + + + {data.value} + + + ); + } + + return ( + + {data.isSecret ? ( + + ) : ( + <> + handleCopy(data.value)} + onMouseDown={event => event.preventDefault()} + size="medium" + > + + + {data.value} + + )} + + ); + }, + }, + { + label: t('translation|From'), + getter: (data: EnvironmentVariable) => { + if (typeof data.from === 'object' && data.from !== null) { + let routeName; + try { + routeName = + ResourceClasses[data.from?.kind as keyof typeof ResourceClasses].detailsRoute; + } catch (e) { + console.error(`Error getting routeName for ${data.from?.kind}`, e); + return null; + } + return ( + + {`${data.from?.kind}: ${data.from?.metadata?.name}`} + + ); + } else if (typeof data.from === 'string') { + return data.from; + } + return null; + }, + }, + ]; + + return ( + <> + {/* Render fetcher components - these call hooks properly at top level */} + {secretsToFetch.map(name => ( + + ))} + {configMapsToFetch.map(name => ( + + ))} + + + ); +} + export interface VolumeMountsProps { mounts?: { mountPath: string; @@ -858,23 +1473,6 @@ export function ContainerInfo(props: ContainerInfoProps) { } function containerRows() { - const env: { [name: string]: string } = {}; - (container.env || []).forEach(envVar => { - let value = ''; - - if (envVar.value) { - value = envVar.value; - } else if (envVar.valueFrom) { - if (envVar.valueFrom.fieldRef) { - value = envVar.valueFrom.fieldRef.fieldPath; - } else if (envVar.valueFrom.secretKeyRef) { - value = envVar.valueFrom.secretKeyRef.key; - } - } - - env[envVar.name] = value; - }); - return [ { name: container.name, @@ -980,9 +1578,9 @@ export function ContainerInfo(props: ContainerInfoProps) { hide: !container.command, }, { - name: t('Environment'), - value: , - hide: _.isEmpty(env), + name: t('glossary|Environment'), + value: , + hide: _.isEmpty(container?.env) && _.isEmpty(container?.envFrom), }, { name: t('Liveness Probes'), diff --git a/frontend/src/components/secret/Details.tsx b/frontend/src/components/secret/Details.tsx index 7b362667c2d..fcc4fc083d4 100644 --- a/frontend/src/components/secret/Details.tsx +++ b/frontend/src/components/secret/Details.tsx @@ -60,12 +60,13 @@ export default function SecretDetails(props: { { id: 'headlamp.secrets-data', section: () => { - const initialData = _.mapValues(item.data, (v: string) => Base64.decode(v)); + // Keep data in base64 format - SecretField handles decoding for display + const initialData = item.data || {}; const [data, setData] = React.useState(initialData); const lastDataRef = React.useRef(initialData); React.useEffect(() => { - const newData = _.mapValues(item.data, (v: string) => Base64.decode(v)); + const newData = item.data || {}; if (!_.isEqual(newData, lastDataRef.current)) { if (_.isEqual(data, lastDataRef.current)) { setData(newData); @@ -75,12 +76,13 @@ export default function SecretDetails(props: { }, [item.data]); const handleFieldChange = (key: string, newValue: string) => { - setData(prev => ({ ...prev, [key]: newValue })); + // User edits in plaintext, encode back to base64 for storage + setData(prev => ({ ...prev, [key]: Base64.encode(newValue) })); }; const handleSave = () => { - const encodedData = _.mapValues(data, (v: string) => Base64.encode(v)); - const updatedSecret = { ...item.jsonData, data: encodedData }; + // Data is already base64 encoded + const updatedSecret = { ...item.jsonData, data }; dispatch( clusterAction(() => item.update(updatedSecret), { startMessage: t('translation|Applying changes to {{ itemName }}…', { diff --git a/frontend/src/components/secret/__snapshots__/Details.WithBase.stories.storyshot b/frontend/src/components/secret/__snapshots__/Details.WithBase.stories.storyshot index 6979bc34388..ffe346edf7b 100644 --- a/frontend/src/components/secret/__snapshots__/Details.WithBase.stories.storyshot +++ b/frontend/src/components/secret/__snapshots__/Details.WithBase.stories.storyshot @@ -280,7 +280,7 @@ class="MuiInputBase-input MuiInput-input Mui-readOnly MuiInputBase-readOnly css-1x51dt5-MuiInputBase-input-MuiInput-input" readonly="" type="password" - value="******" + value="••••••••" /> @@ -334,7 +334,7 @@ class="MuiInputBase-input MuiInput-input Mui-readOnly MuiInputBase-readOnly css-1x51dt5-MuiInputBase-input-MuiInput-input" readonly="" type="password" - value="******" + value="••••••••" /> @@ -388,7 +388,7 @@ class="MuiInputBase-input MuiInput-input Mui-readOnly MuiInputBase-readOnly css-1x51dt5-MuiInputBase-input-MuiInput-input" readonly="" type="password" - value="******" + value="••••••••" /> diff --git a/frontend/src/components/webhookconfiguration/__snapshots__/MutatingWebhookConfigDetails.WithService.stories.storyshot b/frontend/src/components/webhookconfiguration/__snapshots__/MutatingWebhookConfigDetails.WithService.stories.storyshot index 0bea882e251..254331d486e 100644 --- a/frontend/src/components/webhookconfiguration/__snapshots__/MutatingWebhookConfigDetails.WithService.stories.storyshot +++ b/frontend/src/components/webhookconfiguration/__snapshots__/MutatingWebhookConfigDetails.WithService.stories.storyshot @@ -334,7 +334,7 @@ class="MuiInputBase-input MuiInput-input Mui-readOnly MuiInputBase-readOnly css-1x51dt5-MuiInputBase-input-MuiInput-input" readonly="" type="password" - value="******" + value="••••••••" /> diff --git a/frontend/src/components/webhookconfiguration/__snapshots__/MutatingWebhookConfigDetails.WithURL.stories.storyshot b/frontend/src/components/webhookconfiguration/__snapshots__/MutatingWebhookConfigDetails.WithURL.stories.storyshot index 284fa71740f..5be2fc65ce6 100644 --- a/frontend/src/components/webhookconfiguration/__snapshots__/MutatingWebhookConfigDetails.WithURL.stories.storyshot +++ b/frontend/src/components/webhookconfiguration/__snapshots__/MutatingWebhookConfigDetails.WithURL.stories.storyshot @@ -331,7 +331,7 @@ class="MuiInputBase-input MuiInput-input Mui-readOnly MuiInputBase-readOnly css-1x51dt5-MuiInputBase-input-MuiInput-input" readonly="" type="password" - value="******" + value="••••••••" /> diff --git a/frontend/src/components/webhookconfiguration/__snapshots__/ValidatingWebhookConfigDetails.WithService.stories.storyshot b/frontend/src/components/webhookconfiguration/__snapshots__/ValidatingWebhookConfigDetails.WithService.stories.storyshot index 393f0193236..6073865727a 100644 --- a/frontend/src/components/webhookconfiguration/__snapshots__/ValidatingWebhookConfigDetails.WithService.stories.storyshot +++ b/frontend/src/components/webhookconfiguration/__snapshots__/ValidatingWebhookConfigDetails.WithService.stories.storyshot @@ -334,7 +334,7 @@ class="MuiInputBase-input MuiInput-input Mui-readOnly MuiInputBase-readOnly css-1x51dt5-MuiInputBase-input-MuiInput-input" readonly="" type="password" - value="******" + value="••••••••" /> diff --git a/frontend/src/components/webhookconfiguration/__snapshots__/ValidatingWebhookConfigDetails.WithURL.stories.storyshot b/frontend/src/components/webhookconfiguration/__snapshots__/ValidatingWebhookConfigDetails.WithURL.stories.storyshot index 408e043e1a8..9aff4518418 100644 --- a/frontend/src/components/webhookconfiguration/__snapshots__/ValidatingWebhookConfigDetails.WithURL.stories.storyshot +++ b/frontend/src/components/webhookconfiguration/__snapshots__/ValidatingWebhookConfigDetails.WithURL.stories.storyshot @@ -331,7 +331,7 @@ class="MuiInputBase-input MuiInput-input Mui-readOnly MuiInputBase-readOnly css-1x51dt5-MuiInputBase-input-MuiInput-input" readonly="" type="password" - value="******" + value="••••••••" /> diff --git a/frontend/src/i18n/locales/de/translation.json b/frontend/src/i18n/locales/de/translation.json index 4cbcc2b74b7..03da2104b84 100644 --- a/frontend/src/i18n/locales/de/translation.json +++ b/frontend/src/i18n/locales/de/translation.json @@ -365,6 +365,8 @@ "Delete port forward": "Portweiterleitung löschen", "Stop port forward": "Portweiterleitung stoppen", "Copied": "", + "This value may differ in the container, since the pod is older than {{from}}": "Dieser Wert kann im Container abweichen, da der Pod älter ist als {{from}}", + "Value": "Wert", "Mount Path": "Mount Path", "from": "von", "I/O": "I/O", @@ -481,7 +483,6 @@ "Suspend": "Pausieren", "Key": "Schlüssel", "Operator": "Betreiber", - "Value": "Wert", "Effect": "Wirkung", "Current": "Aktuell", "Desired//context:pods": "Gewünscht", diff --git a/frontend/src/i18n/locales/en/translation.json b/frontend/src/i18n/locales/en/translation.json index 3080eb262b6..67a6afe5a7a 100644 --- a/frontend/src/i18n/locales/en/translation.json +++ b/frontend/src/i18n/locales/en/translation.json @@ -365,6 +365,8 @@ "Delete port forward": "Delete port forward", "Stop port forward": "Stop port forward", "Copied": "Copied", + "This value may differ in the container, since the pod is older than {{from}}": "This value may differ in the container, since the pod is older than {{from}}", + "Value": "Value", "Mount Path": "Mount Path", "from": "from", "I/O": "I/O", @@ -481,7 +483,6 @@ "Suspend": "Suspend", "Key": "Key", "Operator": "Operator", - "Value": "Value", "Effect": "Effect", "Current": "Current", "Desired//context:pods": "Desired", diff --git a/frontend/src/i18n/locales/es/translation.json b/frontend/src/i18n/locales/es/translation.json index e55420c97b2..74aeda32a94 100644 --- a/frontend/src/i18n/locales/es/translation.json +++ b/frontend/src/i18n/locales/es/translation.json @@ -367,6 +367,8 @@ "Delete port forward": "Eliminar redireccionamiento de puerto", "Stop port forward": "Parar redireccionamiento de puerto", "Copied": "", + "This value may differ in the container, since the pod is older than {{from}}": "Este valor puede diferir en el contenedor, ya que el pod es más antiguo que {{from}}", + "Value": "Valor", "Mount Path": "Camino de montaje", "from": "desde", "I/O": "I/O", @@ -484,7 +486,6 @@ "Suspend": "Suspender", "Key": "Clave", "Operator": "Operador", - "Value": "Valor", "Effect": "Efecto", "Current": "Actual", "Desired//context:pods": "Deseados", diff --git a/frontend/src/i18n/locales/fr/translation.json b/frontend/src/i18n/locales/fr/translation.json index d2fc32de3c3..173b43a4d1e 100644 --- a/frontend/src/i18n/locales/fr/translation.json +++ b/frontend/src/i18n/locales/fr/translation.json @@ -367,6 +367,8 @@ "Delete port forward": "Supprimer le transfert de port", "Stop port forward": "Arrêter le transfert de port", "Copied": "Copié", + "This value may differ in the container, since the pod is older than {{from}}": "Cette valeur peut différer dans le conteneur, car le pod est plus ancien que {{from}}", + "Value": "Valeur", "Mount Path": "Chemin de montage", "from": "de", "I/O": "Entrées/Sorties", @@ -484,7 +486,6 @@ "Suspend": "Suspendu", "Key": "Key", "Operator": "Opérateur", - "Value": "Valeur", "Effect": "Effet", "Current": "Actuel", "Desired//context:pods": "Actuels", diff --git a/frontend/src/i18n/locales/hi/translation.json b/frontend/src/i18n/locales/hi/translation.json index 05e5c5e6687..41c4d677bb5 100644 --- a/frontend/src/i18n/locales/hi/translation.json +++ b/frontend/src/i18n/locales/hi/translation.json @@ -365,6 +365,8 @@ "Delete port forward": "पोर्ट फॉरवर्ड हटाएँ", "Stop port forward": "पोर्ट फॉरवर्ड रोकें", "Copied": "", + "This value may differ in the container, since the pod is older than {{from}}": "", + "Value": "मान", "Mount Path": "माउंट पाथ", "from": "से", "I/O": "I/O", @@ -481,7 +483,6 @@ "Suspend": "निलंबित करें", "Key": "कुंजी", "Operator": "ऑपरेटर", - "Value": "मान", "Effect": "प्रभाव", "Current": "वर्तमान", "Desired//context:pods": "वांछित", diff --git a/frontend/src/i18n/locales/it/translation.json b/frontend/src/i18n/locales/it/translation.json index 85c03236274..c50b7c019e3 100644 --- a/frontend/src/i18n/locales/it/translation.json +++ b/frontend/src/i18n/locales/it/translation.json @@ -367,6 +367,8 @@ "Delete port forward": "Elimina port forwarding", "Stop port forward": "Ferma port forwarding", "Copied": "", + "This value may differ in the container, since the pod is older than {{from}}": "", + "Value": "Valore", "Mount Path": "Percorso di montaggio", "from": "da", "I/O": "I/O", @@ -484,7 +486,6 @@ "Suspend": "Sospendi", "Key": "Chiave", "Operator": "Operatore", - "Value": "Valore", "Effect": "Effetto", "Current": "Attuale", "Desired//context:pods": "Desiderato//context:pods", diff --git a/frontend/src/i18n/locales/ja/translation.json b/frontend/src/i18n/locales/ja/translation.json index 0937d3497e2..95092a1ea33 100644 --- a/frontend/src/i18n/locales/ja/translation.json +++ b/frontend/src/i18n/locales/ja/translation.json @@ -363,6 +363,8 @@ "Delete port forward": "ポートフォワードを削除", "Stop port forward": "ポートフォワードを停止", "Copied": "", + "This value may differ in the container, since the pod is older than {{from}}": "", + "Value": "値", "Mount Path": "マウントパス", "from": "から", "I/O": "I/O", @@ -478,7 +480,6 @@ "Suspend": "一時停止", "Key": "キー", "Operator": "演算子", - "Value": "値", "Effect": "効果", "Current": "現在", "Desired//context:pods": "希望", diff --git a/frontend/src/i18n/locales/ko/translation.json b/frontend/src/i18n/locales/ko/translation.json index b362de8cd77..966592925d5 100644 --- a/frontend/src/i18n/locales/ko/translation.json +++ b/frontend/src/i18n/locales/ko/translation.json @@ -363,6 +363,8 @@ "Delete port forward": "포트 포워딩 삭제", "Stop port forward": "포트 포워딩 중지", "Copied": "", + "This value may differ in the container, since the pod is older than {{from}}": "", + "Value": "값", "Mount Path": "마운트 경로", "from": "from", "I/O": "I/O", @@ -478,7 +480,6 @@ "Suspend": "일시중지", "Key": "키", "Operator": "Operator", - "Value": "값", "Effect": "효과", "Current": "Current", "Desired//context:pods": "Desired", diff --git a/frontend/src/i18n/locales/pt/translation.json b/frontend/src/i18n/locales/pt/translation.json index b5b5240ecb0..5ad6f62b762 100644 --- a/frontend/src/i18n/locales/pt/translation.json +++ b/frontend/src/i18n/locales/pt/translation.json @@ -367,6 +367,8 @@ "Delete port forward": "Eliminar redir. de porta", "Stop port forward": "Parar redir. de porta", "Copied": "", + "This value may differ in the container, since the pod is older than {{from}}": "Este valor pode diferir no contentor, pois o pod é mais antigo que {{from}}", + "Value": "Valor", "Mount Path": "Cam. de montagem", "from": "desde", "I/O": "I/O", @@ -484,7 +486,6 @@ "Suspend": "Suspender", "Key": "Chave", "Operator": "Operador", - "Value": "Valor", "Effect": "Efeito", "Current": "Actual", "Desired//context:pods": "Desejados", diff --git a/frontend/src/i18n/locales/ta/translation.json b/frontend/src/i18n/locales/ta/translation.json index 115b819ce9b..1e2a3afe67e 100644 --- a/frontend/src/i18n/locales/ta/translation.json +++ b/frontend/src/i18n/locales/ta/translation.json @@ -365,6 +365,8 @@ "Delete port forward": "போர்ட் ஃபார்வர்ட் அழி", "Stop port forward": "போர்ட் ஃபார்வர்ட் நிறுத்து", "Copied": "", + "This value may differ in the container, since the pod is older than {{from}}": "", + "Value": "வேல்யூ", "Mount Path": "மவுண்ட் பாத்", "from": "இலிருந்து", "I/O": "I/O", @@ -481,7 +483,6 @@ "Suspend": "இடைநிறுத்து", "Key": "கீ", "Operator": "ஆபரேட்டர்", - "Value": "வேல்யூ", "Effect": "எஃபெக்ட்", "Current": "தற்போதைய", "Desired//context:pods": "விரும்பியவை", diff --git a/frontend/src/i18n/locales/zh-tw/translation.json b/frontend/src/i18n/locales/zh-tw/translation.json index ad04819ca6b..693eb90c41e 100644 --- a/frontend/src/i18n/locales/zh-tw/translation.json +++ b/frontend/src/i18n/locales/zh-tw/translation.json @@ -363,6 +363,8 @@ "Delete port forward": "刪除連接埠轉發", "Stop port forward": "停止連接埠轉發", "Copied": "已複製", + "This value may differ in the container, since the pod is older than {{from}}": "", + "Value": "值", "Mount Path": "掛載路徑", "from": "來自", "I/O": "I/O", @@ -478,7 +480,6 @@ "Suspend": "暫停", "Key": "鍵", "Operator": "運算子", - "Value": "值", "Effect": "效果", "Current": "當前", "Desired//context:pods": "期望", diff --git a/frontend/src/i18n/locales/zh/translation.json b/frontend/src/i18n/locales/zh/translation.json index 6e88b93a524..f9dcbb4dc6f 100644 --- a/frontend/src/i18n/locales/zh/translation.json +++ b/frontend/src/i18n/locales/zh/translation.json @@ -363,6 +363,8 @@ "Delete port forward": "删除端口转发", "Stop port forward": "停止端口转发", "Copied": "已复制", + "This value may differ in the container, since the pod is older than {{from}}": "", + "Value": "值", "Mount Path": "挂载路径", "from": "来自", "I/O": "I/O", @@ -478,7 +480,6 @@ "Suspend": "暂停", "Key": "键", "Operator": "操作", - "Value": "值", "Effect": "效果", "Current": "当前", "Desired//context:pods": "期望", diff --git a/frontend/src/lib/units.test.ts b/frontend/src/lib/units.test.ts index d138ffea250..536e25255ce 100644 --- a/frontend/src/lib/units.test.ts +++ b/frontend/src/lib/units.test.ts @@ -15,7 +15,7 @@ */ import * as fc from 'fast-check'; -import { parseCpu, parseRam, unparseCpu, unparseRam } from './units'; +import { divideK8sResources, parseCpu, parseRam, unparseCpu, unparseRam } from './units'; describe('parseRam', () => { it('should parse simple numbers', () => { @@ -169,3 +169,52 @@ describe('unparseCpu', () => { expect(unparseCpu('1333333')).toEqual({ value: 1.33, unit: 'm' }); }); }); + +describe('divideK8sResources', () => { + it('should divide two resource quantities with binary units', () => { + expect(divideK8sResources('1Gi', '1Mi')).toBe(1024); + expect(divideK8sResources('2Gi', '1Gi')).toBe(2); + expect(divideK8sResources('1Mi', '1Ki')).toBe(1024); + }); + + it('should handle plain numbers', () => { + expect(divideK8sResources('1000', '100')).toBe(10); + expect(divideK8sResources('1', '1')).toBe(1); + }); + + it('should handle decimal units', () => { + expect(divideK8sResources('1M', '1K')).toBe(1000); + expect(divideK8sResources('1G', '1M')).toBe(1000); + }); + + it('should handle mixed units', () => { + // 1Gi = 1073741824 bytes, 1G = 1000000000 bytes + expect(divideK8sResources('1Gi', '1G')).toBeCloseTo(1.073741824, 5); + }); + + it('should handle CPU units when resourceType is cpu', () => { + // 500m (millicores) / 1 (core) = 0.5 + expect(divideK8sResources('500m', '1', 'cpu')).toBe(0.5); + // 1 core / 1m (millicore) = 1000 + expect(divideK8sResources('1', '1m', 'cpu')).toBe(1000); + // 2 cores / 1 core = 2 + expect(divideK8sResources('2', '1', 'cpu')).toBe(2); + // 100m / 100m = 1 + expect(divideK8sResources('100m', '100m', 'cpu')).toBe(1); + }); + + it('should handle CPU with nano and micro units', () => { + // 1000n / 1n = 1000 + expect(divideK8sResources('1000n', '1n', 'cpu')).toBe(1000); + // 1u / 1n = 1000 + expect(divideK8sResources('1u', '1n', 'cpu')).toBe(1000); + // 1m / 1u = 1000 + expect(divideK8sResources('1m', '1u', 'cpu')).toBe(1000); + }); + + it('should default to memory parsing when resourceType is not specified', () => { + // These should still work as before (memory) + expect(divideK8sResources('1Gi', '1Mi')).toBe(1024); + expect(divideK8sResources('1M', '1K')).toBe(1000); + }); +}); diff --git a/frontend/src/lib/units.ts b/frontend/src/lib/units.ts index 4e466a40778..3e929e86785 100644 --- a/frontend/src/lib/units.ts +++ b/frontend/src/lib/units.ts @@ -93,3 +93,22 @@ export function unparseCpu(value: string) { unit: 'm', }; } + +/** + * Divides two Kubernetes resource quantities. + * Useful for computing resource field references with divisors. + * @param a - The dividend resource string (e.g., "1Gi", "500m") + * @param b - The divisor resource string (e.g., "1Mi", "1") + * @param resourceType - The type of resource ('cpu' or 'memory'). Defaults to 'memory'. + * @returns The result of dividing a by b + */ +export function divideK8sResources( + a: string, + b: string, + resourceType: 'cpu' | 'memory' = 'memory' +): number { + if (resourceType === 'cpu') { + return parseCpu(a) / parseCpu(b); + } + return parseUnitsOfBytes(a) / parseUnitsOfBytes(b); +} diff --git a/frontend/src/plugin/__snapshots__/pluginLib.snapshot b/frontend/src/plugin/__snapshots__/pluginLib.snapshot index 1c6ec3ec74c..0dcc472e00d 100644 --- a/frontend/src/plugin/__snapshots__/pluginLib.snapshot +++ b/frontend/src/plugin/__snapshots__/pluginLib.snapshot @@ -43,6 +43,7 @@ "ConditionsTable": [Function], "ConfirmButton": [Function], "ConfirmDialog": [Function], + "ContainerEnvironmentVariables": [Function], "ContainerInfo": [Function], "ContainersSection": [Function], "CopyButton": [Function], @@ -94,6 +95,7 @@ "CircularChart": [Function], "ConditionsSection": [Function], "ConditionsTable": [Function], + "ContainerEnvironmentVariables": [Function], "ContainerInfo": [Function], "ContainersSection": [Function], "CopyButton": [Function], @@ -472,6 +474,7 @@ "TO_GB": 1073741824, "TO_ONE_CPU": 1000000000, "TO_ONE_M_CPU": 1000000, + "divideK8sResources": [Function], "parseCpu": [Function], "parseDiskSpace": [Function], "parseRam": [Function],