@kinvolk/headlamp-plugin
Version:
The needed infrastructure for building Headlamp plugins.
264 lines (263 loc) • 14.9 kB
JavaScript
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
/*
* 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 Autocomplete from '@mui/material/Autocomplete';
import Box from '@mui/material/Box';
import Button from '@mui/material/Button';
import IconButton from '@mui/material/IconButton';
import MenuItem from '@mui/material/MenuItem';
import TextField from '@mui/material/TextField';
import Typography from '@mui/material/Typography';
import * as yaml from 'js-yaml';
import _ from 'lodash';
import React from 'react';
import { useTranslation } from 'react-i18next';
import Namespace from '../../../lib/k8s/namespace';
import { useId } from '../../../lib/util';
/** Data-driven resource creation form. Renders labelled sections of typed
* fields (text, labels, containers, namespace, select) from a declarative
* descriptor and keeps a plain JS resource object in sync via `onChange`. */
export default function CreateResourceForm(props) {
const { sections, resource, onChange } = props;
const { t } = useTranslation(['translation']);
function handleFieldChange(path, value) {
const updated = _.cloneDeep(resource);
_.set(updated, path, value);
onChange(updated);
}
function renderField(field) {
const value = _.get(resource, field.path);
switch (field.type) {
case 'labels':
return (_jsx(LabelTextField, { label: field.label, value: value ?? {}, onChange: labels => handleFieldChange(field.path, labels) }));
case 'containers':
return (_jsx(ContainerTextField, { label: field.label, value: value ?? [], onChange: containers => handleFieldChange(field.path, containers) }));
case 'namespace':
return (_jsx(NamespaceTextField, { label: field.label, value: value ?? '', onChange: ns => handleFieldChange(field.path, ns), required: field.required, helperText: field.helperText }));
case 'select':
return (_jsx(FormTextField, { label: field.label, value: value ?? '', onChange: e => handleFieldChange(field.path, e.target.value), required: field.required, helperText: field.helperText, select: true, children: (field.options ?? []).map(opt => (_jsx(MenuItem, { value: opt.value, children: opt.label }, opt.value))) }));
default:
return (_jsx(FormTextField, { label: field.label, value: value ?? '', onChange: e => handleFieldChange(field.path, e.target.value), required: field.required, helperText: field.helperText }));
}
}
return (_jsx(Box, { sx: {
height: '100%',
overflowY: 'auto',
width: '100%',
}, children: _jsx(Box, { "aria-label": t('translation|Resource form'), sx: {
display: 'flex',
flexDirection: 'column',
alignSelf: 'center',
gap: 3,
pt: 2,
marginLeft: 5,
maxWidth: '80%',
}, children: sections.map(section => (_jsxs(Box, { component: "fieldset", sx: { border: 'none', m: 0, p: 0 }, children: [_jsx(Typography, { component: "legend", variant: "subtitle1", sx: { fontWeight: 'bold', mb: 1 }, children: section.title }), _jsx(Box, { sx: { display: 'flex', flexDirection: 'column', gap: 2, pl: 1 }, children: section.fields.map(field => (_jsx(Box, { sx: field.spacingTop ? { mt: field.spacingTop } : undefined, children: renderField(field) }, field.key))) })] }, section.title))) }) }));
}
/** Pre-styled outlined TextField matching the app's search input style. */
export function FormTextField(props) {
return (_jsx(TextField, { variant: "outlined", size: "small", fullWidth: true, InputProps: {
sx: theme => ({ background: theme.palette.background.default }),
}, ...props }));
}
/** Autocomplete namespace selector that fetches existing namespaces from the cluster. */
export function NamespaceTextField(props) {
const { value, onChange, label, required, helperText } = props;
const [namespaces] = Namespace.useList();
const options = React.useMemo(() => (namespaces ?? []).map(ns => ns.metadata.name).sort(), [namespaces]);
return (_jsx(Autocomplete, { freeSolo: true, options: options, value: value, onChange: (_event, newValue) => {
onChange(newValue ?? '');
}, onInputChange: (_event, newInputValue, reason) => {
if (reason === 'input') {
onChange(newInputValue);
}
}, renderInput: params => (_jsx(TextField, { ...params, variant: "outlined", size: "small", fullWidth: true, label: label, required: required, helperText: helperText, InputProps: {
...params.InputProps,
sx: theme => ({ background: theme.palette.background.default }),
} })) }));
}
/** Editable key/value rows for Kubernetes labels.
* Existing labels are shown as editable rows. Click + New Label to add a placeholder row. */
export function LabelTextField(props) {
const { value, onChange, label } = props;
const { t } = useTranslation(['translation']);
const groupLabelId = useId('label-group-');
const entries = Object.entries(value);
const [rowIds, setRowIds] = React.useState(() => entries.map(() => nextLabelRowId()));
// Keep row IDs in sync when value changes externally (e.g. YAML editor) or
// when rows are added/removed locally. Renames keep the existing id at the
// same index, so the input doesn't remount and lose focus mid-edit.
React.useEffect(() => {
setRowIds(prev => {
if (prev.length === entries.length)
return prev;
if (prev.length < entries.length) {
const next = [...prev];
while (next.length < entries.length) {
next.push(nextLabelRowId());
}
return next;
}
return prev.slice(0, entries.length);
});
}, [entries.length]);
function addLabel() {
const baseKey = 'new-label';
let nextKey = baseKey;
let idx = 1;
while (Object.prototype.hasOwnProperty.call(value, nextKey)) {
idx += 1;
nextKey = `${baseKey}-${idx}`;
}
onChange({ ...value, [nextKey]: '' });
}
function handleDelete(labelKey) {
const next = { ...value };
delete next[labelKey];
onChange(next);
}
function handleKeyEdit(oldKey, newKeyVal) {
if (newKeyVal === oldKey) {
return;
}
// Block renames that would collide with another existing key (which would
// silently overwrite its value). An empty key is allowed mid-edit so the
// user can clear the field and type a replacement.
if (newKeyVal && Object.prototype.hasOwnProperty.call(value, newKeyVal)) {
return;
}
const entries = Object.entries(value);
const result = {};
for (const [k, v] of entries) {
if (k === oldKey) {
result[newKeyVal] = v;
}
else {
result[k] = v;
}
}
onChange(result);
}
function handleValueEdit(labelKey, newValue) {
onChange({ ...value, [labelKey]: newValue });
}
return (_jsxs(Box, { role: "group", "aria-labelledby": groupLabelId, children: [label && (_jsx(Typography, { id: groupLabelId, variant: "body2", sx: { mb: 0.5 }, children: label })), entries.map(([k, v], index) => (_jsxs(Box, { sx: { display: 'flex', gap: 1, alignItems: 'center', mt: 2, mb: 2 }, children: [_jsx(FormTextField, { label: t('translation|Key'), value: k, onChange: e => handleKeyEdit(k, e.target.value), inputProps: { 'aria-label': t('translation|Label key') } }), _jsx(FormTextField, { label: t('translation|Value'), value: v, onChange: e => handleValueEdit(k, e.target.value), inputProps: { 'aria-label': t('translation|Label value') } }), _jsx(IconButton, { onClick: () => handleDelete(k), color: "default", size: "small", "aria-label": t('translation|Remove label {{ label }}', { label: `${k}=${v}` }), children: _jsx(Icon, { icon: "mdi:close-circle", width: 24, height: 24 }) })] }, rowIds[index] ?? `label-${index}`))), _jsx(Box, { sx: { mt: 2 }, children: _jsxs(Button, { onClick: addLabel, color: "primary", size: "small", "aria-label": t('translation|Add label'), children: [_jsx(Icon, { icon: "mdi:plus-circle", width: 24, height: 24 }), _jsx(Typography, { variant: "body2", sx: { ml: 0.5 }, children: t('translation|New Label') })] }) })] }));
}
const IMAGE_PULL_POLICIES = ['Always', 'IfNotPresent', 'Never'];
let containerRowIdCounter = 0;
const nextContainerRowId = () => `container-${++containerRowIdCounter}`;
let labelRowIdCounter = 0;
const nextLabelRowId = () => `label-${++labelRowIdCounter}`;
/** Editable list of containers (name + image + pull policy per row).
* Fill in the bottom inputs and click + to add. Existing containers appear
* above as editable rows with × to remove. */
export function ContainerTextField(props) {
const { value, onChange, label } = props;
const { t } = useTranslation(['translation']);
const groupLabelId = useId('container-group-');
const [rowIds, setRowIds] = React.useState(() => value.map(() => nextContainerRowId()));
// Keep row IDs in sync when value changes externally (e.g. YAML editor).
React.useEffect(() => {
setRowIds(prev => {
if (prev.length === value.length)
return prev;
if (prev.length < value.length) {
const next = [...prev];
while (next.length < value.length) {
next.push(nextContainerRowId());
}
return next;
}
return prev.slice(0, value.length);
});
}, [value.length]);
function handleAdd() {
setRowIds(prev => [...prev, nextContainerRowId()]);
onChange([
...value,
{ name: '', image: '', ports: [{ containerPort: 80 }], imagePullPolicy: 'Always' },
]);
}
function handleRemove(index) {
setRowIds(prev => prev.filter((_, i) => i !== index));
onChange(value.filter((_, i) => i !== index));
}
function handleEdit(index, field, newVal) {
onChange(value.map((c, i) => {
if (i !== index)
return c;
if (field === 'containerPort') {
const portNum = parseInt(newVal, 10);
const rest = _.omit(c, ['containerPort', 'ports']);
if (!isNaN(portNum) && portNum > 0) {
return { ...rest, ports: [{ containerPort: portNum }] };
}
return rest;
}
return { ...c, [field]: newVal };
}));
}
function getPort(container) {
const port = container?.ports?.[0]?.containerPort ?? container.containerPort;
return port !== null && port !== undefined ? String(port) : '';
}
return (_jsxs(Box, { role: "group", "aria-labelledby": groupLabelId, children: [label && (_jsx(Typography, { id: groupLabelId, variant: "body2", sx: { mb: 0.5 }, children: label })), value.map((container, index) => (_jsxs(Box, { sx: { display: 'flex', gap: 1, alignItems: 'center', mt: 2, mb: 2 }, children: [_jsx(FormTextField, { label: t('translation|Name'), value: container.name ?? '', onChange: e => handleEdit(index, 'name', e.target.value), inputProps: { 'aria-label': t('translation|Container name') } }), _jsx(FormTextField, { label: t('translation|Image'), value: container.image ?? '', onChange: e => handleEdit(index, 'image', e.target.value), inputProps: { 'aria-label': t('translation|Container image') } }), _jsx(FormTextField, { label: t('translation|Container Port'), value: getPort(container), onChange: e => handleEdit(index, 'containerPort', e.target.value), inputProps: { 'aria-label': t('translation|Container port') }, type: "number" }), _jsx(FormTextField, { label: t('translation|Pull Policy'), value: container.imagePullPolicy ?? 'Always', onChange: e => handleEdit(index, 'imagePullPolicy', e.target.value), inputProps: { 'aria-label': t('translation|Image pull policy') }, select: true, children: IMAGE_PULL_POLICIES.map(p => (_jsx(MenuItem, { value: p, children: p }, p))) }), _jsx(IconButton, { onClick: () => handleRemove(index), color: "default", size: "small", "aria-label": t('translation|Remove container {{ name }}', {
name: container.name ?? index,
}), children: _jsx(Icon, { icon: "mdi:close-circle", width: 24, height: 24 }) })] }, rowIds[index] ?? `container-${index}`))), _jsx(Box, { sx: { mt: 2 }, children: _jsxs(Button, { onClick: handleAdd, color: "primary", size: "small", "aria-label": t('translation|Add container'), children: [_jsx(Icon, { icon: "mdi:plus-circle", width: 24, height: 24 }), _jsx(Typography, { variant: "body2", sx: { ml: 0.5 }, children: t('translation|New Container') })] }) })] }));
}
/** Safely parse a YAML string into an object, returning {} on failure. */
export function parseYaml(input) {
if (!input)
return {};
try {
const parsed = yaml.load(input);
return parsed && typeof parsed === 'object' ? parsed : {};
}
catch {
return {};
}
}
/** Convert a labels map to a display string, e.g. `{a: "1", b: "2"}` → `"a=1, b=2"`. */
export function labelsToString(labels) {
if (!labels || typeof labels !== 'object')
return '';
return Object.entries(labels)
.map(([k, v]) => `${k}=${v}`)
.join(', ');
}
/** Parse a comma-separated `key=value` string into a labels map. */
export function parseLabelsString(input) {
const result = {};
if (!input.trim())
return result;
input.split(',').forEach(pair => {
const [key, ...rest] = pair.split('=');
const k = key.trim();
const v = rest.join('=').trim();
if (k) {
result[k] = v;
}
});
return result;
}
/** Serialize a JS value to a YAML string using js-yaml. */
export function toYamlString(obj) {
if (obj === null || obj === undefined) {
return 'null';
}
return yaml.dump(obj, { indent: 2, lineWidth: -1, noRefs: true });
}