@kinvolk/headlamp-plugin
Version:
The needed infrastructure for building Headlamp plugins.
225 lines (224 loc) • 14.1 kB
JavaScript
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } 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 { Alert, Autocomplete, Box, Button, Dialog, DialogActions, DialogContent, DialogTitle, TextField, Typography, useTheme, } from '@mui/material';
import { uniq } from 'lodash';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { Trans, useTranslation } from 'react-i18next';
import { useHistory } from 'react-router';
import { useClustersConf } from '../../lib/k8s';
import { apply } from '../../lib/k8s/api/v1/apply';
import Namespace from '../../lib/k8s/namespace';
import { createRouteURL } from '../../lib/router/createRouteURL';
import { useTypedSelector } from '../../redux/hooks';
import { PROJECT_ID_LABEL, toKubernetesName } from './projectUtils';
/**
* A styled button for selecting a project type.
*/
function ProjectTypeButton({ icon, title, description, index, onClick, }) {
return (_jsxs(Button, { onClick: onClick, sx: {
display: 'flex',
justifyContent: 'flex-start',
gap: 2,
textAlign: 'start',
border: '1px solid',
borderColor: 'divider',
alignItems: 'flex-start',
padding: 3,
py: 2,
animationName: 'reveal',
animationDuration: '0.25s',
animationFillMode: 'both',
animationDelay: 0.1 + index * 0.05 + 's',
flex: '1',
'@keyframes reveal': {
from: {
opacity: 0,
transform: 'translateY(10px)',
},
to: {
opacity: 1,
transform: 'translateY(0)',
},
},
}, children: [_jsx(Box, { sx: { width: '52px', height: '52px', alignSelf: 'center' }, children: icon }), _jsxs(Box, { children: [_jsx(Typography, { variant: "h6", component: "span", sx: { display: 'flex' }, children: title }), _jsx(Typography, { variant: "body2", color: "text.secondary", children: description })] })] }));
}
/**
* Popup content for creating a new Project from existing or new namespace
*/
function ProjectFromExistingNamespace({ onBack }) {
const { t } = useTranslation();
const history = useHistory();
const [projectName, setProjectName] = useState('');
const [selectedClusters, setSelectedClusters] = useState([]);
const [selectedNamespace, setSelectedNamespace] = useState();
const [typedNamespace, setTypedNamespace] = useState('');
const [isCreating, setIsCreating] = useState(false);
const [error, setError] = useState();
const clusters = Object.values(useClustersConf() ?? {});
const { items: namespaces } = Namespace.useList({
clusters: selectedClusters,
});
const existingProjectNames = useMemo(() => {
if (!namespaces)
return new Set();
const result = new Set();
for (const ns of namespaces) {
const labelValue = ns.metadata.labels?.[PROJECT_ID_LABEL];
if (!labelValue) {
continue;
}
result.add(labelValue);
result.add(toKubernetesName(labelValue));
}
return result;
}, [namespaces]);
// Check if project name already exists (using normalized form to match existing entries)
const projectNameExists = projectName.length > 0 && existingProjectNames.has(toKubernetesName(projectName));
const namespaceToProjectMap = useMemo(() => {
const map = new Map();
if (!namespaces)
return map;
namespaces.forEach(ns => {
const projectId = ns.metadata?.labels?.[PROJECT_ID_LABEL];
if (projectId) {
map.set(ns.metadata.name, projectId);
}
});
return map;
}, [namespaces]);
const effectiveNamespace = selectedNamespace || toKubernetesName(typedNamespace);
const isNamespaceAlreadyAssigned = effectiveNamespace
? namespaceToProjectMap.has(effectiveNamespace)
: false;
const isReadyToCreate = selectedClusters.length &&
(selectedNamespace || typedNamespace) &&
projectName &&
!projectNameExists &&
!isNamespaceAlreadyAssigned;
useEffect(() => {
if (selectedNamespace && namespaceToProjectMap.has(selectedNamespace)) {
setSelectedNamespace(undefined);
}
}, [selectedNamespace, namespaceToProjectMap]);
/**
* Creates or updates namespaces for the project
*/
const handleCreate = async () => {
if (!isReadyToCreate || isCreating)
return;
setIsCreating(true);
try {
const existingNamespaces = namespaces?.filter(it => it.metadata.name === selectedNamespace);
const clustersWithExistingNamespace = existingNamespaces?.map(it => it.cluster) ?? [];
if (existingNamespaces && existingNamespaces.length > 0) {
// Update all existing namespaces with the same name across selected clusters
await Promise.all(existingNamespaces.map(namespace => namespace.patch({
metadata: {
labels: {
[PROJECT_ID_LABEL]: projectName,
},
},
})));
}
// Create new namespace in all selected clusters that don't already have it
const clustersWithoutNamespace = selectedClusters.filter(it => !clustersWithExistingNamespace.includes(it));
for (const cluster of clustersWithoutNamespace) {
const namespace = {
kind: 'Namespace',
apiVersion: 'v1',
metadata: {
name: toKubernetesName(typedNamespace),
labels: {
[PROJECT_ID_LABEL]: projectName,
},
},
};
await apply(namespace, cluster);
}
history.push(createRouteURL('projectDetails', { name: projectName }));
}
catch (e) {
setError(e);
}
finally {
setIsCreating(false);
}
};
return (_jsxs(_Fragment, { children: [_jsxs(DialogTitle, { sx: { display: 'flex', gap: 1, alignItems: 'center' }, children: [_jsx(Icon, { icon: "mdi:folder-add" }), t('Create new project')] }), _jsxs(DialogContent, { sx: {
p: 3,
minWidth: '25rem',
display: 'flex',
flexDirection: 'column',
gap: 3,
minHeight: '20rem',
}, children: [_jsx(Typography, { variant: "body2", color: "text.secondary", sx: { maxWidth: '25rem' }, children: _jsx(Trans, { children: "To create a new project pick which clusters you want to include and then select existing or create a new namespace" }) }), _jsx(TextField, { label: t('translation|Project Name'), value: projectName, onChange: event => {
const inputValue = event.target.value.toLowerCase();
setProjectName(inputValue);
}, onBlur: event => {
// Convert to Kubernetes name when user finishes typing (loses focus)
const converted = toKubernetesName(event.target.value);
setProjectName(converted);
}, onKeyDown: event => {
// Convert spaces to dashes immediately when space is pressed
if (event.key === ' ') {
event.preventDefault();
const target = event.target;
const start = target.selectionStart || 0;
const end = target.selectionEnd || 0;
const currentValue = projectName;
const newValue = currentValue.substring(0, start) + '-' + currentValue.substring(end);
setProjectName(newValue);
// Set cursor position after the inserted dash
setTimeout(() => {
target.setSelectionRange(start + 1, start + 1);
}, 0);
}
}, error: projectNameExists, helperText: projectNameExists
? t('A project with this name already exists')
: t('translation|Enter a name for your new project.'), autoComplete: "off", fullWidth: true }), _jsx(Autocomplete, { fullWidth: true, multiple: true, options: clusters.map(it => it.name), value: selectedClusters, onChange: (e, newValue) => {
setSelectedClusters(newValue);
}, renderInput: params => (_jsx(TextField, { ...params, label: t('Clusters'), variant: "outlined", size: "small", helperText: t('Select one or more clusters for this project') })), noOptionsText: t('No available clusters'), disabled: clusters.length === 0 }), _jsx(Autocomplete, { fullWidth: true, freeSolo: true, options: uniq(namespaces?.map(it => it.metadata.name)) ?? [], value: selectedNamespace, onChange: (event, newValue) => {
setSelectedNamespace(newValue ?? undefined);
}, onInputChange: (e, v) => {
setTypedNamespace(v);
}, getOptionDisabled: option => namespaceToProjectMap.has(option), renderInput: params => (_jsx(TextField, { ...params, label: t('Namespace'), placeholder: t('Type or select a namespace'), helperText: t('Select existing or type to create a new namespace'), variant: "outlined", size: "small" })), noOptionsText: t('No available namespaces - you can type a custom name') }), error && (_jsx(Alert, { severity: "error", sx: { maxWidth: '25rem' }, children: error?.message }))] }), _jsxs(DialogActions, { children: [_jsx(Button, { variant: "contained", color: "secondary", onClick: onBack, children: _jsx(Trans, { children: "Cancel" }) }), _jsx(Button, { variant: "contained", onClick: handleCreate, disabled: isCreating || !isReadyToCreate, children: isCreating ? _jsx(Trans, { children: "Creating" }) : _jsx(Trans, { children: "Create" }) })] })] }));
}
/**
* A dialog for creating a new project.
* It provides several options for creating a project, such as from a namespace,
* auto-detection, from YAML, or a custom project.
*/
export function NewProjectPopup({ open, onClose }) {
const history = useHistory();
const theme = useTheme();
const { t } = useTranslation();
const customCreateProject = Object.values(useTypedSelector(state => state.projects.customCreateProject));
const [projectStep, setProjectStep] = useState();
const selectedCustomProject = customCreateProject.find(it => it.id === projectStep);
const handleBack = useCallback(() => {
setProjectStep(undefined);
}, []);
// Keep track of buttons
let index = 0;
return (_jsxs(Dialog, { open: open, maxWidth: false, onClose: onClose, children: [projectStep === undefined && (_jsxs(_Fragment, { children: [_jsx(DialogTitle, { component: "h1", sx: { display: 'flex' }, children: t('Create a Project') }), _jsxs(DialogContent, { sx: { maxWidth: '540px' }, children: [_jsx(Typography, { variant: "body2", color: "text.secondary", sx: { mb: 3 }, children: _jsx(Trans, { children: "Project is a collection of Kubernetes resources. You can use projects to organize your resources, for example, by environment, team, or application." }) }), _jsxs(Box, { sx: { display: 'flex', flexDirection: 'column', gap: 1 }, children: [_jsx(ProjectTypeButton, { index: index++, icon: _jsx(Icon, { icon: "mdi:folder-add", width: "100%", height: "100%", color: theme.palette.text.secondary }), title: _jsx(Trans, { children: "New Project" }), description: _jsx(Trans, { children: "Create a new project" }), onClick: () => {
setProjectStep('new-project');
} }), _jsx(ProjectTypeButton, { index: index++, icon: _jsx(Icon, { icon: "mdi:file-document-add", width: "100%", height: "100%", color: theme.palette.text.secondary }), title: _jsx(Trans, { children: "New Project from YAML" }), description: _jsx(Trans, { children: "Deploy a new application from YAML" }), onClick: () => {
onClose();
history.push(createRouteURL('projectCreateYaml'));
} }), customCreateProject.map(it => (_jsx(ProjectTypeButton, { index: index++, icon: typeof it.icon === 'string' ? (_jsx(Icon, { icon: it.icon, width: "100%", height: "100%", color: theme.palette.text.secondary })) : (_jsx(it.icon, {})), title: it.name, description: it.description, onClick: () => setProjectStep(it.id) })))] })] }), _jsx(DialogActions, { children: _jsx(Button, { variant: "contained", color: "secondary", onClick: onClose, children: t('Cancel') }) })] })), projectStep === 'new-project' && _jsx(ProjectFromExistingNamespace, { onBack: handleBack }), selectedCustomProject && _jsx(selectedCustomProject.component, { onBack: handleBack })] }));
}