@kinvolk/headlamp-plugin
Version:
The needed infrastructure for building Headlamp plugins.
259 lines (258 loc) • 17 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, InlineIcon } from '@iconify/react';
import { Autocomplete, Box, Button, CircularProgress, DialogActions, DialogContent, FormControl, Grid, Tab, Tabs, TextField, Tooltip, Typography, } from '@mui/material';
import { loadAll } from 'js-yaml';
import { useMemo, useState } from 'react';
import { useDropzone } from 'react-dropzone';
import { Trans, useTranslation } from 'react-i18next';
import { Redirect, 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 { ViewYaml } from '../advancedSearch/ResourceSearch';
import { DropZoneBox } from '../common/DropZoneBox';
import Table from '../common/Table';
import { KubeIcon } from '../resourceMap/kubeIcon/KubeIcon';
import { PROJECT_ID_LABEL, toKubernetesName } from './projectUtils';
async function createProjectFromYaml({ items, k8sName, cluster, setCreationState, }) {
const itemsToCreate = structuredClone(items);
itemsToCreate.forEach(item => {
item.metadata.namespace = k8sName;
});
const namespace = {
kind: 'Namespace',
apiVersion: 'v1',
metadata: {
name: k8sName,
labels: {
[PROJECT_ID_LABEL]: k8sName,
},
},
};
setCreationState({
stage: 'creating',
createdResources: [],
creatingResource: namespace,
});
await apply(namespace, cluster);
for (const item of itemsToCreate) {
setCreationState(state => ({
stage: 'creating',
createdResources: state.stage === 'creating' ? [...state.createdResources, state.creatingResource] : [],
creatingResource: item,
}));
await apply(item, cluster);
}
setCreationState({
stage: 'success',
name: k8sName,
});
}
export function CreateNew() {
const { t } = useTranslation();
const [items, setItems] = useState([]);
const [name, setName] = useState('');
const allClusters = useClustersConf();
const [selectedClusters, setSelectedClusters] = useState(null);
const k8sName = toKubernetesName(name);
const history = useHistory();
const [creationState, setCreationState] = useState({
stage: 'form',
});
const [errors, setErrors] = useState({});
const { items: allProjectNamespaces } = Namespace.useList({
clusters: allClusters ? Object.keys(allClusters) : [],
labelSelector: PROJECT_ID_LABEL,
});
const existingProjectNames = useMemo(() => {
const result = new Set();
if (!allProjectNamespaces) {
return result;
}
for (const ns of allProjectNamespaces) {
const labelValue = ns.metadata.labels?.[PROJECT_ID_LABEL];
if (!labelValue) {
continue;
}
// Store both the raw label and its Kubernetes-normalized form so that
// duplicate detection works regardless of how PROJECT_ID_LABEL was set.
result.add(labelValue);
result.add(toKubernetesName(labelValue));
}
return result;
}, [allProjectNamespaces]);
const projectNameExists = k8sName.length > 0 && existingProjectNames.has(k8sName);
// New state for URL and tab management
const [currentTab, setCurrentTab] = useState(0);
const [yamlUrl, setYamlUrl] = useState('');
const [isLoadingFromUrl, setIsLoadingFromUrl] = useState(false);
// Function to load YAML from URL
const loadFromUrl = async () => {
if (!yamlUrl.trim()) {
setErrors(prev => ({ ...prev, url: t('URL is required') }));
return;
}
setIsLoadingFromUrl(true);
setErrors(prev => ({ ...prev, url: '' }));
try {
const response = await fetch(yamlUrl);
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const content = await response.text();
const docs = loadAll(content);
const validDocs = docs.filter(doc => !!doc);
setItems(validDocs);
setErrors(prev => ({ ...prev, items: '' }));
}
catch (error) {
setErrors(prev => ({
...prev,
url: t('Failed to load from URL: {{error}}', {
error: error.message,
}),
}));
}
finally {
setIsLoadingFromUrl(false);
}
};
// File drop functionality
const onDrop = (acceptedFiles) => {
setErrors(prev => ({ ...prev, items: '' }));
const promises = acceptedFiles.map(file => {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => {
const content = reader.result;
try {
const docs = loadAll(content);
const validDocs = docs.filter(doc => !!doc);
resolve({ docs: validDocs });
}
catch (err) {
console.error('Error parsing YAML file:', file.name, err);
// Resolve with empty array for failed files
resolve({ docs: [] });
}
};
reader.onerror = err => {
console.error('Error reading file:', file.name, err);
reject(err);
};
reader.readAsText(file);
});
});
Promise.all(promises)
.then(results => {
const newDocs = results.flatMap(result => result.docs);
setItems(prevItems => [...prevItems, ...newDocs]);
})
.catch(err => {
console.error('An error occurred while processing files.', err);
setErrors(prev => ({
...prev,
items: t('Error processing files: {{error}}', {
error: err.message,
}),
}));
});
};
const { getRootProps, getInputProps, open } = useDropzone({
onDrop,
accept: {
'application/x-yaml': ['.yaml', '.yml'],
'text/yaml': ['.yaml', '.yml'],
'text/plain': ['.yaml', '.yml'],
},
multiple: true,
});
const handleCreate = async (e) => {
e.preventDefault();
const errors = {};
if (!name.trim()) {
errors.name = t('Name is required');
}
if (projectNameExists) {
errors.name = t('A project with this name already exists');
}
if (!selectedClusters) {
errors.clusters = t('Cluster is required');
}
if (items.length === 0) {
errors.items = t('No resources have been uploaded');
}
if (Object.keys(errors).length > 0) {
setErrors(errors);
return;
}
else {
setErrors({});
}
try {
await createProjectFromYaml({
items,
k8sName,
cluster: selectedClusters,
setCreationState,
});
history.push(createRouteURL('projectDetails', { name: k8sName }));
}
catch (e) {
setCreationState({
stage: 'error',
error: e,
});
}
};
return (_jsx(_Fragment, { children: _jsxs(DialogContent, { children: [creationState.stage === 'form' && (_jsx(_Fragment, { children: _jsxs("form", { onSubmit: handleCreate, children: [_jsx(Typography, { variant: "h1", sx: { mb: 3 }, children: t('Create new Project from YAML') }), _jsxs(Grid, { container: true, spacing: 4, children: [_jsxs(Grid, { item: true, xs: 3, children: [_jsx(Typography, { children: _jsx(Trans, { children: "Project name" }) }), _jsx(Typography, { variant: "body2", color: "text.secondary", sx: { mb: 1 }, children: _jsx(Trans, { children: "Give your project a descriptive name" }) })] }), _jsx(Grid, { item: true, xs: 9, children: _jsx(TextField, { required: true, label: t('Project Name'), placeholder: t('Enter a name'), variant: "outlined", size: "small", sx: { minWidth: 400 }, value: name, onChange: e => setName(e.target.value), error: !!errors.name || projectNameExists, helperText: errors.name ||
(projectNameExists ? t('A project with this name already exists') : undefined) }) }), _jsxs(Grid, { item: true, xs: 3, children: [_jsx(Typography, { children: _jsx(Trans, { children: "Cluster" }) }), _jsx(Typography, { variant: "body2", color: "text.secondary", sx: { mb: 1 }, children: _jsx(Trans, { children: "Select cluster for this project" }) })] }), _jsx(Grid, { item: true, xs: 9, children: _jsx(Autocomplete, { options: allClusters ? Object.keys(allClusters) : [], value: selectedClusters, onChange: (e, newValue) => {
setSelectedClusters(newValue);
}, renderInput: params => (_jsx(TextField, { ...params, label: t('Clusters'), variant: "outlined", size: "small", sx: { maxWidth: 400 }, required: true })), noOptionsText: t('No available clusters'), disabled: !allClusters || Object.keys(allClusters).length === 0 }) }), _jsxs(Grid, { item: true, xs: 3, children: [_jsx(Typography, { children: t('Load resources') }), _jsx(Typography, { variant: "body2", color: "text.secondary", sx: { mb: 1 }, children: t('Upload files or load from URL') })] }), _jsxs(Grid, { item: true, xs: 9, children: [errors.items && _jsx(Typography, { color: "error", children: errors.items }), _jsxs(Box, { sx: { width: '100%' }, children: [_jsx(Box, { sx: { borderBottom: 1, borderColor: 'divider' }, children: _jsxs(Tabs, { value: currentTab, onChange: (_, newValue) => setCurrentTab(newValue), children: [_jsx(Tab, { label: t('Upload Files') }), _jsx(Tab, { label: t('Load from URL') })] }) }), currentTab === 0 && (_jsx(Box, { sx: { pt: 2 }, children: _jsxs(DropZoneBox, { border: 1, borderColor: "secondary.main", ...getRootProps(), children: [_jsxs(FormControl, { children: [_jsx("input", { ...getInputProps(), "aria-label": t('Choose Files') }), _jsx(Tooltip, { title: t('Drag & drop YAML files here or click to choose files'), placement: "top", children: _jsx(Button, { variant: "contained", onClick: open, startIcon: _jsx(InlineIcon, { icon: "mdi:upload", width: 24 }), children: t('Choose Files') }) })] }), _jsx(Typography, { variant: "body2", color: "text.secondary", sx: { mt: 1 }, children: t('Supports .yaml and .yml files') })] }) })), currentTab === 1 && (_jsxs(Box, { sx: { pt: 2 }, children: [_jsxs(Box, { sx: { display: 'flex', gap: 2, alignItems: 'flex-start' }, children: [_jsx(TextField, { fullWidth: true, label: t('YAML URL'), placeholder: t('Enter URL to YAML file'), variant: "outlined", size: "small", value: yamlUrl, onChange: e => setYamlUrl(e.target.value), error: !!errors.url, helperText: errors.url, disabled: isLoadingFromUrl }), _jsx(Button, { variant: "contained", onClick: loadFromUrl, disabled: isLoadingFromUrl || !yamlUrl.trim(), startIcon: isLoadingFromUrl ? (_jsx(CircularProgress, { size: 16 })) : (_jsx(InlineIcon, { icon: "mdi:download", width: 24 })), children: isLoadingFromUrl ? t('Loading...') : t('Load') })] }), _jsx(Typography, { variant: "body2", color: "text.secondary", sx: { mt: 1 }, children: t('Load YAML resources from a remote URL') })] }))] })] })] }), items.length > 0 && (_jsxs(Box, { sx: { display: 'flex', flexDirection: 'column', gap: 3, mt: 3 }, children: [_jsxs(Box, { sx: { display: 'flex', justifyContent: 'space-between', alignItems: 'center' }, children: [_jsx(Typography, { children: t('Loaded Resources ({{count}})', { count: items.length }) }), _jsx(Button, { variant: "outlined", size: "small", onClick: () => setItems([]), startIcon: _jsx(InlineIcon, { icon: "mdi:delete", width: 16 }), children: t('Clear All') })] }), _jsx(Box, { sx: {
display: 'flex',
flexDirection: 'column',
}, children: _jsx(Table, { data: items, columns: [
{
id: 'kind',
header: t('Kind'),
accessorFn: item => item.kind,
Cell: ({ row: { original: item } }) => (_jsxs(Box, { sx: { display: 'flex', alignItems: 'center', gap: 1 }, children: [_jsx(KubeIcon, { kind: item.kind, width: "24px", height: "24px" }), _jsx(Typography, { variant: "body2", color: "text.secondary", children: item.kind })] })),
gridTemplate: 'min-content',
},
{
id: 'name',
header: t('Name'),
accessorFn: item => item.metadata.name,
},
{
id: 'apiVersion',
header: t('API Version'),
accessorFn: item => item.apiVersion,
},
{
id: 'actions',
header: t('Actions'),
gridTemplate: 'min-content',
accessorFn: item => item.metadata.uid,
Cell: ({ row: { original: item } }) => (_jsx(ViewYaml, { item: { ...item, jsonData: item } })),
},
] }) })] })), _jsxs(DialogActions, { children: [_jsx(Button, { variant: "contained", color: "secondary", onClick: () => {
history.push(createRouteURL('chooser'));
}, children: _jsx(Trans, { children: "Cancel" }) }), _jsx(Button, { variant: "contained", type: "submit", children: _jsx(Trans, { children: "Create" }) })] })] }) })), creationState.stage === 'creating' && (_jsxs(Box, { children: [_jsx(Typography, { variant: "h1", children: t('Creating project') }), _jsx(Typography, { variant: "body2", color: "text.secondary", sx: { mt: 2 }, children: t('Creating following resources in this project:') }), _jsxs(Box, { sx: { display: 'flex', flexDirection: 'column', gap: 1, mt: 1 }, children: [creationState.createdResources.map((resource, index) => (_jsxs(Box, { sx: { display: 'flex', alignItems: 'center', gap: 1 }, children: [_jsx(KubeIcon, { kind: resource.kind, width: "24px", height: "24px" }), _jsx(Box, { children: resource.metadata.name }), _jsx(Box, { sx: theme => ({ color: theme.palette.success.main }), children: _jsx(Icon, { icon: "mdi:check" }) })] }, `created-${resource.kind}-${resource.metadata.name}-${index}`))), _jsxs(Box, { sx: { display: 'flex', alignItems: 'center', gap: 1 }, children: [_jsx(KubeIcon, { kind: creationState.creatingResource.kind, width: "24px", height: "24px" }), _jsx(Box, { children: creationState.creatingResource.metadata.name }), _jsx(CircularProgress, { size: "1rem" })] })] })] })), creationState.stage === 'error' && (_jsxs(Box, { children: [_jsx(Box, { children: t('Something went wrong') }), _jsx(Box, { children: creationState.error.message })] })), creationState.stage === 'success' && (_jsx(Redirect, { to: createRouteURL('projectDetails', { name: creationState.name }) }))] }) }));
}