Skip to content

Commit

Permalink
frontend: Add create resource UI
Browse files Browse the repository at this point in the history
These changes introduce a new UI feature that allows users to create
resources from the associated list view. Clicking the 'Create' button
opens up the EditorDialog used in the generic 'Create / Apply' button,
now accepting generic YAML/JSON text rather than explicitly expecting an
item that looks like a Kubernetes resource. The dialog box also includes
a generic template for each resource. The apply logic for this new
feature (as well as the original 'Create / Apply' button) has been
consolidated in EditorDialog, with a flag allowing external components
to utilize their own dispatch functionality.

Fixes: #1820

Signed-off-by: Evangelos Skopelitis <[email protected]>
  • Loading branch information
skoeva committed Sep 4, 2024
1 parent e0c67a1 commit e7d0024
Show file tree
Hide file tree
Showing 36 changed files with 1,577 additions and 225 deletions.
51 changes: 50 additions & 1 deletion .github/workflows/app-artifacts-mac.yml
Original file line number Diff line number Diff line change
Expand Up @@ -139,13 +139,62 @@ jobs:
if-no-files-found: error
overwrite: true
retention-days: 2
stapler:
verify-notarization:
runs-on: macos-latest
needs: notarize
permissions:
actions: write # for downloading and uploading artifacts
contents: read
if: ${{ inputs.signBinaries }}
strategy:
matrix:
arch: [x86, arm64]
steps:
- name: Download artifact
uses: actions/download-artifact@65a9edc5881444af0b9093a5e628f2fe47ea3b2e # v4.1.7
with:
name: dmgs
path: ./dmgs
- name: Verify Notarization
run: |
cd ./dmgs
# Map x86 to x64
ARCH=${{ matrix.arch }}
if [ "$ARCH" = "x86" ]; then
ARCH="x64"
fi
echo "Verifying notarization of the app: $(ls ./Headlamp*${ARCH}*.dmg)"
MOUNT_OUTPUT="$(hdiutil attach ./Headlamp*${ARCH}*.dmg)"
VOLUME_NAME="$(echo "$MOUNT_OUTPUT" | grep -o '/Volumes/[^\s]*')"
# Check if the app is notarized
echo "Checking volume: $VOLUME_NAME"
spctl -a -v "$VOLUME_NAME/Headlamp.app/Contents/MacOS/Headlamp"
echo "Checking symlinks..."
# Check if the app has symlinks
SYMLINKS=$(find "$VOLUME_NAME" -type l -ls || true)
NODE_MODULES_AS_SYMLINKS=$(echo "$SYMLINKS" | grep node_modules || true)
if [ -n "$NODE_MODULES_AS_SYMLINKS" ]; then
echo "Symlinks found in the DMG:"
echo "$NODE_MODULES_AS_SYMLINKS"
exit 1
else
echo "No symlinks found in the DMG"
fi
echo "Detaching volume"
hdiutil detach "$VOLUME_NAME" || true
exit 0
stapler:
runs-on: macos-latest
needs: verify-notarization
permissions:
actions: write # for downloading and uploading artifacts
contents: read
if: ${{ inputs.signBinaries }}
steps:
- name: Download artifact
uses: actions/download-artifact@65a9edc5881444af0b9093a5e628f2fe47ea3b2e # v4.1.7
Expand Down
20 changes: 18 additions & 2 deletions app/electron/plugin-management.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,22 @@ interface PluginData {
artifacthubVersion: string;
}

/**
* Move directories from currentPath to newPath by copying.
* @param currentPath from this path
* @param newPath to this path
*/
function moveDirs(currentPath: string, newPath: string) {
try {
fs.cpSync(currentPath, newPath, { recursive: true, force: true });
fs.rmSync(currentPath, { recursive: true });
console.log(`Moved directory from ${currentPath} to ${newPath}`);
} catch (err) {
console.error(`Error moving directory from ${currentPath} to ${newPath}:`, err);
throw err;
}
}

export class PluginManager {
/**
* Installs a plugin from the specified URL.
Expand Down Expand Up @@ -84,7 +100,7 @@ export class PluginManager {
fs.mkdirSync(destinationFolder, { recursive: true });
}
// move the plugin to the destination folder
fs.renameSync(tempFolder, path.join(destinationFolder, path.basename(name)));
moveDirs(tempFolder, path.join(destinationFolder, path.basename(name)));
if (progressCallback) {
progressCallback({ type: 'success', message: 'Plugin Installed' });
}
Expand Down Expand Up @@ -162,7 +178,7 @@ export class PluginManager {
fs.mkdirSync(pluginDir, { recursive: true });

// move the plugin to the destination folder
fs.renameSync(tempFolder, pluginDir);
moveDirs(tempFolder, pluginDir);
if (progressCallback) {
progressCallback({ type: 'success', message: 'Plugin Updated' });
}
Expand Down
2 changes: 2 additions & 0 deletions backend/cmd/headlamp.go
Original file line number Diff line number Diff line change
Expand Up @@ -1475,6 +1475,8 @@ func (c *HeadlampConfig) renameCluster(w http.ResponseWriter, r *http.Request) {
if reqBody.Stateless {
// For stateless clusters we just need to remove cluster from cache
c.handleStatelessClusterRename(w, r, clusterName)

return
}

// Get path of kubeconfig from source
Expand Down
62 changes: 44 additions & 18 deletions frontend/src/components/cluster/KubeConfigLoader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { useDropzone } from 'react-dropzone';
import { useTranslation } from 'react-i18next';
import { useDispatch } from 'react-redux';
import { useHistory } from 'react-router-dom';
import { useClustersConf } from '../../lib/k8s';
import { setCluster } from '../../lib/k8s/apiProxy';
import { setStatelessConfig } from '../../redux/configSlice';
import { DialogTitle } from '../common/Dialog';
Expand Down Expand Up @@ -102,18 +103,10 @@ const WideButton = styled(Button)({
maxWidth: '300px',
});

const BlackButton = styled(WideButton)(({ theme }) => ({
backgroundColor: theme.palette.sidebarBg,
color: theme.palette.primaryColor,
'&:hover': {
opacity: '0.8',
backgroundColor: theme.palette.sidebarBg,
},
}));

const enum Step {
LoadKubeConfig,
SelectClusters,
ValidateKubeConfig,
ConfigureClusters,
Success,
}
Expand All @@ -129,6 +122,7 @@ function KubeConfigLoader() {
currentContext: '',
});
const [selectedClusters, setSelectedClusters] = useState<string[]>([]);
const configuredClusters = useClustersConf(); // Get already configured clusters

useEffect(() => {
if (fileContent.contexts.length > 0) {
Expand All @@ -139,9 +133,27 @@ function KubeConfigLoader() {
}, [fileContent]);

useEffect(() => {
if (state === Step.ValidateKubeConfig) {
const alreadyConfiguredClusters = selectedClusters.filter(
clusterName => configuredClusters && configuredClusters[clusterName]
);

if (alreadyConfiguredClusters.length > 0) {
setError(
t(
'translation|Duplicate cluster: {{ clusterNames }} in the list. Please edit the context name.',
{
clusterNames: alreadyConfiguredClusters.join(', '),
}
)
);
setState(Step.SelectClusters);
} else {
setState(Step.ConfigureClusters);
}
}
if (state === Step.ConfigureClusters) {
function loadClusters() {
//@todo: We need to check if the cluster is already configured.
const selectedClusterConfig = configWithSelectedClusters(fileContent, selectedClusters);
setCluster({ kubeconfig: btoa(yaml.dump(selectedClusterConfig)) })
.then(res => {
Expand Down Expand Up @@ -176,12 +188,19 @@ function KubeConfigLoader() {
...new Uint8Array(reader.result as ArrayBuffer),
]);
const doc = yaml.load(data) as kubeconfig;
if (!doc.clusters || !doc.contexts) {
throw new Error('Invalid kubeconfig file');
if (!doc.clusters) {
throw new Error(t('translation|No clusters found!'));
}
if (!doc.contexts) {
throw new Error(t('translation|No contexts found!'));
}
setFileContent(doc);
} catch (err) {
setError(t('translation|Load a valid kubeconfig'));
setError(
t(`translation|Invalid kubeconfig file: {{ errorMessage }}`, {
errorMessage: (err as Error).message,
})
);
return;
}
};
Expand Down Expand Up @@ -293,16 +312,16 @@ function KubeConfigLoader() {
alignItems="stretch"
>
<Grid item>
<BlackButton
<WideButton
variant="contained"
color="primary"
onClick={() => {
setState(Step.ConfigureClusters);
setState(Step.ValidateKubeConfig);
}}
disabled={selectedClusters.length === 0}
>
{t('translation|Next')}
</BlackButton>
</WideButton>
</Grid>
<Grid item>
<WideButton
Expand All @@ -320,6 +339,13 @@ function KubeConfigLoader() {
) : null}
</Box>
);
case Step.ValidateKubeConfig:
return (
<Box style={{ textAlign: 'center' }}>
<Typography>{t('translation|Validating selected clusters')}</Typography>
<Loader title={t('translation|Validating selected clusters')} />
</Box>
);
case Step.ConfigureClusters:
return (
<Box style={{ textAlign: 'center' }}>
Expand All @@ -341,9 +367,9 @@ function KubeConfigLoader() {
<Box style={{ padding: '32px' }}>
<Typography>{t('translation|Clusters successfully set up!')}</Typography>
</Box>
<BlackButton variant="contained" onClick={() => history.replace('/')}>
<WideButton variant="contained" onClick={() => history.replace('/')}>
{t('translation|Finish')}
</BlackButton>
</WideButton>
</Box>
);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -483,39 +483,7 @@
</h1>
<div
class="MuiBox-root css-ldp2l3"
>
<label
class="MuiFormControlLabel-root MuiFormControlLabel-labelPlacementEnd css-j204z7-MuiFormControlLabel-root"
>
<span
class="MuiSwitch-root MuiSwitch-sizeMedium css-julti5-MuiSwitch-root"
>
<span
class="MuiButtonBase-root MuiSwitch-switchBase MuiSwitch-colorPrimary Mui-checked PrivateSwitchBase-root MuiSwitch-switchBase MuiSwitch-colorPrimary Mui-checked Mui-checked css-1nsozxe-MuiButtonBase-root-MuiSwitch-switchBase"
>
<input
checked=""
class="PrivateSwitchBase-input MuiSwitch-input css-1m9pwf3"
type="checkbox"
/>
<span
class="MuiSwitch-thumb css-jsexje-MuiSwitch-thumb"
/>
<span
class="MuiTouchRipple-root css-8je8zh-MuiTouchRipple-root"
/>
</span>
<span
class="MuiSwitch-track css-1yjjitx-MuiSwitch-track"
/>
</span>
<span
class="MuiTypography-root MuiTypography-body1 MuiFormControlLabel-label css-1ezega9-MuiTypography-root"
>
Only warnings (0)
</span>
</label>
</div>
/>
</div>
</div>
<div
Expand Down
97 changes: 97 additions & 0 deletions frontend/src/components/common/CreateResourceButton.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import { Meta, StoryObj } from '@storybook/react';
import { expect, userEvent, waitFor } from '@storybook/test';
import { screen } from '@testing-library/react';
import React from 'react';
import { Provider } from 'react-redux';
import ConfigMap from '../../lib/k8s/configMap';
import store from '../../redux/stores/store';
import { TestContext } from '../../test';
import { CreateResourceButton, CreateResourceButtonProps } from './CreateResourceButton';

export default {
title: 'CreateResourceButton',
component: CreateResourceButton,
parameters: {
storyshots: {
disable: true,
},
},
decorators: [
Story => {
return (
<Provider store={store}>
<TestContext>
<Story />
</TestContext>
</Provider>
);
},
],
} as Meta;

type Story = StoryObj<CreateResourceButtonProps>;

export const ValidResource: Story = {
args: { resourceClass: ConfigMap },

play: async ({ args }) => {
await userEvent.click(
screen.getByRole('button', {
name: `Create ${(args.resourceClass as typeof ConfigMap).kind}`,
})
);

await waitFor(() => expect(screen.getByRole('textbox')).toBeVisible());

await userEvent.click(screen.getByRole('textbox'));

await userEvent.keyboard('{Control>}a{/Control} {Backspace}');
await userEvent.keyboard(`apiVersion: v1{Enter}`);
await userEvent.keyboard(`kind: ConfigMap{Enter}`);
await userEvent.keyboard(`metadata:{Enter}`);
await userEvent.keyboard(` name: base-configmap`);

const button = await screen.findByRole('button', { name: 'Apply' });
expect(button).toBeVisible();
},
};

export const InvalidResource: Story = {
args: { resourceClass: ConfigMap },

play: async ({ args }) => {
await userEvent.click(
screen.getByRole('button', {
name: `Create ${(args.resourceClass as typeof ConfigMap).kind}`,
})
);

await waitFor(() => expect(screen.getByRole('textbox')).toBeVisible());

await userEvent.click(screen.getByRole('textbox'));

await userEvent.keyboard('{Control>}a{/Control}');
await userEvent.keyboard(`apiVersion: v1{Enter}`);
await userEvent.keyboard(`kind: ConfigMap{Enter}`);
await userEvent.keyboard(`metadata:{Enter}`);
await userEvent.keyboard(` name: base-configmap{Enter}`);
await userEvent.keyboard(`creationTimestamp: ''`);

const button = await screen.findByRole('button', { name: 'Apply' });
expect(button).toBeVisible();

await userEvent.click(button);

await waitFor(() =>
userEvent.click(
screen.getByRole('button', {
name: `Create ${(args.resourceClass as typeof ConfigMap).kind}`,
})
)
);

await waitFor(() => expect(screen.getByText(/Failed/)).toBeVisible(), {
timeout: 15000,
});
},
};
Loading

0 comments on commit e7d0024

Please sign in to comment.