UNPKG

@kinvolk/headlamp-plugin

Version:

The needed infrastructure for building Headlamp plugins.

264 lines (263 loc) 14.9 kB
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 }); }