feat: add config maps and secrets to k8s (renderer code) (#8042)

* feat: add config maps and secrets to k8s (renderer code)

### What does this PR do?

* Adds ConfigMaps and Secrets to Kubernetes
* Ability to delete / view / edit both configmap and secrets

### Screenshot / video of UI

<!-- If this PR is changing UI, please include
screenshots or screencasts showing the difference -->
![Screenshot 2024-06-17 at 4 24 23 PM](https://github.com/containers/podman-desktop/assets/6422176/b348e49c-142e-429e-ad25-08a6fe389cf6)

https://github.com/containers/podman-desktop/assets/6422176/41108199-7e2b-4cc2-81f6-2b267b1c887a

### What issues does this PR fix or reference?

<!-- Include any related issues from Podman Desktop
repository (or from another issue tracker). -->

Closes https://github.com/containers/podman-desktop/issues/7342
Closes https://github.com/containers/podman-desktop/issues/7190 (its the last part of it)

### How to test this PR?

<!-- Please explain steps to verify the functionality,
do not forget to provide unit/component tests -->

- [X] Tests are covering the bug fix or the new feature

1. Create a ConfigMap and Secrets on your k8s cluster.
2. Delete / Edit / View them

Signed-off-by: Charlie Drage <charlie@charliedrage.com>

* update based on review

Signed-off-by: Charlie Drage <charlie@charliedrage.com>

* update namespace column and order in app navigation

Signed-off-by: Charlie Drage <charlie@charliedrage.com>

* change search to lowercase

Signed-off-by: Charlie Drage <charlie@charliedrage.com>

---------

Signed-off-by: Charlie Drage <charlie@charliedrage.com>
This commit is contained in:
Charlie Drage 2024-07-12 16:12:12 -04:00 committed by GitHub
parent 041f011996
commit 7e577d877c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
31 changed files with 1592 additions and 0 deletions

View file

@ -10,6 +10,9 @@ import type { NavigationRequest } from '/@api/navigation-request';
import AppNavigation from './AppNavigation.svelte';
import Appearance from './lib/appearance/Appearance.svelte';
import ComposeDetails from './lib/compose/ComposeDetails.svelte';
import ConfigMapDetails from './lib/configmaps-secrets/ConfigMapDetails.svelte';
import ConfigMapSecretList from './lib/configmaps-secrets/ConfigMapSecretList.svelte';
import SecretDetails from './lib/configmaps-secrets/SecretDetails.svelte';
import ContainerDetails from './lib/container/ContainerDetails.svelte';
import ContainerExport from './lib/container/ContainerExport.svelte';
import ContainerList from './lib/container/ContainerList.svelte';
@ -244,6 +247,23 @@ window.events?.receive('navigate', (navigationRequest: unknown) => {
navigationHint="details">
<IngressDetails name="{decodeURI(meta.params.name)}" namespace="{decodeURI(meta.params.namespace)}" />
</Route>
<Route path="/configmapsSecrets" breadcrumb="ConfigMaps & Secrets" navigationHint="root">
<ConfigMapSecretList />
</Route>
<Route
path="/configmapsSecrets/configmap/:name/:namespace/*"
breadcrumb="ConfigMap Details"
let:meta
navigationHint="details">
<ConfigMapDetails name="{decodeURI(meta.params.name)}" namespace="{decodeURI(meta.params.namespace)}" />
</Route>
<Route
path="/configmapsSecrets/secret/:name/:namespace/*"
breadcrumb="Secret Details"
let:meta
navigationHint="details">
<SecretDetails name="{decodeURI(meta.params.name)}" namespace="{decodeURI(meta.params.namespace)}" />
</Route>
<Route
path="/ingressesRoutes/route/:name/:namespace/*"
breadcrumb="Route Details"

View file

@ -52,6 +52,8 @@ test('Test rendering of the navigation bar with empty items', () => {
vi.mocked(kubeContextStore).kubernetesCurrentContextIngresses = readable<KubernetesObject[]>([]);
vi.mocked(kubeContextStore).kubernetesCurrentContextRoutes = readable<KubernetesObject[]>([]);
vi.mocked(kubeContextStore).kubernetesCurrentContextNodes = readable<KubernetesObject[]>([]);
vi.mocked(kubeContextStore).kubernetesCurrentContextConfigMaps = readable<KubernetesObject[]>([]);
vi.mocked(kubeContextStore).kubernetesCurrentContextSecrets = readable<KubernetesObject[]>([]);
vi.mocked(kubeContextStore).kubernetesCurrentContextPersistentVolumeClaims = readable<KubernetesObject[]>([]);
render(AppNavigation, {

View file

@ -13,6 +13,7 @@ import type { ImageInfo } from '/@api/image-info';
import { CommandRegistry } from './lib/CommandRegistry';
import NewContentOnDashboardBadge from './lib/dashboard/NewContentOnDashboardBadge.svelte';
import { ImageUtils } from './lib/image/image-utils';
import ConfigMapSecretIcon from './lib/images/ConfigMapSecretIcon.svelte';
import DashboardIcon from './lib/images/DashboardIcon.svelte';
import DeploymentIcon from './lib/images/DeploymentIcon.svelte';
import ExtensionIcon from './lib/images/ExtensionIcon.svelte';
@ -34,11 +35,13 @@ import { contributions } from './stores/contribs';
import { imagesInfos } from './stores/images';
import { kubernetesContexts } from './stores/kubernetes-contexts';
import {
kubernetesCurrentContextConfigMaps,
kubernetesCurrentContextDeployments,
kubernetesCurrentContextIngresses,
kubernetesCurrentContextNodes,
kubernetesCurrentContextPersistentVolumeClaims,
kubernetesCurrentContextRoutes,
kubernetesCurrentContextSecrets,
kubernetesCurrentContextServices,
} from './stores/kubernetes-contexts-state';
import { podsInfos } from './stores/pods';
@ -55,12 +58,17 @@ let persistentVolumeClaimsSubscribe: Unsubscriber;
let servicesSubscribe: Unsubscriber;
let ingressesSubscribe: Unsubscriber;
let routesSubscribe: Unsubscriber;
let configmapsSubscribe: Unsubscriber;
let secretsSubscribe: Unsubscriber;
let combinedInstalledExtensionsSubscribe: Unsubscriber;
let podCount = '';
let containerCount = '';
let imageCount = '';
let volumeCount = '';
let configmapsCount = 0;
let secretsCount = 0;
let configmapSecretsCount = '';
let persistentVolumeClaimsCount = '';
let contextCount = 0;
let deploymentCount = '';
@ -145,6 +153,15 @@ onMount(async () => {
routesCount = value.length;
updateIngressesRoutesCount(ingressesCount + routesCount);
});
configmapsSubscribe = kubernetesCurrentContextConfigMaps.subscribe(value => {
configmapsCount = value.length;
updateConfigMapSecretsCount(configmapsCount + secretsCount);
});
secretsSubscribe = kubernetesCurrentContextSecrets.subscribe(value => {
secretsCount = value.length;
updateConfigMapSecretsCount(configmapsCount + secretsCount);
});
contextsSubscribe = kubernetesContexts.subscribe(value => {
contextCount = value.length;
});
@ -185,8 +202,16 @@ onDestroy(() => {
if (servicesSubscribe) {
servicesSubscribe();
}
if (configmapsSubscribe) {
configmapsSubscribe();
}
if (secretsSubscribe) {
secretsSubscribe();
}
ingressesSubscribe?.();
routesSubscribe?.();
configmapsSubscribe?.();
secretsSubscribe?.();
combinedInstalledExtensionsSubscribe?.();
});
@ -198,6 +223,14 @@ function updateIngressesRoutesCount(count: number) {
}
}
function updateConfigMapSecretsCount(count: number) {
if (count > 0) {
configmapSecretsCount = ' (' + count + ')';
} else {
configmapSecretsCount = '';
}
}
function clickSettings(b: boolean) {
if (b) {
exitSettingsCallback();
@ -262,6 +295,13 @@ export let meta: TinroRouteMeta;
bind:meta="{meta}">
<PVCIcon size="{iconSize}" />
</NavItem>
<NavItem
href="/configmapsSecrets"
tooltip="ConfigMaps & Secrets{configmapSecretsCount}"
ariaLabel="ConfigMaps & Secrets"
bind:meta="{meta}">
<ConfigMapSecretIcon size="{iconSize}" />
</NavItem>
</NavSection>
{/if}

View file

@ -0,0 +1,57 @@
/**********************************************************************
* Copyright (C) 2024 Red Hat, Inc.
*
* 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.
*
* SPDX-License-Identifier: Apache-2.0
***********************************************************************/
import '@testing-library/jest-dom/vitest';
import type { KubernetesObject, V1ConfigMap } from '@kubernetes/client-node';
import { render, screen } from '@testing-library/svelte';
import { writable } from 'svelte/store';
import { beforeAll, expect, test, vi } from 'vitest';
import * as kubeContextStore from '/@/stores/kubernetes-contexts-state';
import ConfigMapDetails from './ConfigMapDetails.svelte';
const configMap: V1ConfigMap = {
metadata: {
name: 'my-configmap',
namespace: 'default',
},
data: {},
};
vi.mock('/@/stores/kubernetes-contexts-state', async () => {
return {
kubernetesCurrentContextConfigMaps: vi.fn(),
};
});
beforeAll(() => {
(window as any).kubernetesReadNamespacedConfigMap = vi.fn();
});
test('Confirm renders configmap details', async () => {
// mock object store
const configMaps = writable<KubernetesObject[]>([configMap]);
vi.mocked(kubeContextStore).kubernetesCurrentContextConfigMaps = configMaps;
render(ConfigMapDetails, { name: 'my-configmap', namespace: 'default' });
expect(screen.getByText('my-configmap')).toBeInTheDocument();
expect(screen.getByText('default')).toBeInTheDocument();
});

View file

@ -0,0 +1,96 @@
<script lang="ts">
import type { V1ConfigMap } from '@kubernetes/client-node';
import { StatusIcon, Tab } from '@podman-desktop/ui-svelte';
import { onMount } from 'svelte';
import { router } from 'tinro';
import { stringify } from 'yaml';
import { kubernetesCurrentContextConfigMaps } from '/@/stores/kubernetes-contexts-state';
import Route from '../../Route.svelte';
import MonacoEditor from '../editor/MonacoEditor.svelte';
import ConfigMapIcon from '../images/ConfigMapSecretIcon.svelte';
import KubeEditYAML from '../kube/KubeEditYAML.svelte';
import DetailsPage from '../ui/DetailsPage.svelte';
import StateChange from '../ui/StateChange.svelte';
import { getTabUrl, isTabSelected } from '../ui/Util';
import { ConfigMapSecretUtils } from './configmap-secret-utils';
import ConfigMapDetailsSummary from './ConfigMapDetailsSummary.svelte';
import ConfigMapSecretActions from './ConfigMapSecretActions.svelte';
import type { ConfigMapSecretUI } from './ConfigMapSecretUI';
export let name: string;
export let namespace: string;
let configMap: ConfigMapSecretUI;
let detailsPage: DetailsPage;
let kubeConfigMap: V1ConfigMap | undefined;
let kubeError: string;
onMount(() => {
const configMapUtils = new ConfigMapSecretUtils();
// loading configMap info
return kubernetesCurrentContextConfigMaps.subscribe(configMaps => {
const matchingConfigMap = configMaps.find(
configMap => configMap.metadata?.name === name && configMap.metadata?.namespace === namespace,
);
if (matchingConfigMap) {
try {
configMap = configMapUtils.getConfigMapSecretUI(matchingConfigMap);
loadDetails();
} catch (err) {
console.error(err);
}
} else if (detailsPage) {
// the configMap has been deleted
detailsPage.close();
}
});
});
async function loadDetails() {
const getKubeConfigMap = await window.kubernetesReadNamespacedConfigMap(configMap.name, namespace);
if (getKubeConfigMap) {
kubeConfigMap = getKubeConfigMap;
} else {
kubeError = `Unable to retrieve Kubernetes details for ${configMap.name}`;
}
}
</script>
{#if configMap}
<DetailsPage title="{configMap.name}" subtitle="{configMap.namespace}" bind:this="{detailsPage}">
<StatusIcon slot="icon" icon="{ConfigMapIcon}" size="{24}" status="{configMap.status}" />
<svelte:fragment slot="actions">
<ConfigMapSecretActions
configMapSecret="{configMap}"
detailed="{true}"
on:update="{() => (configMap = configMap)}" />
</svelte:fragment>
<div slot="detail" class="flex py-2 w-full justify-end text-sm text-gray-700">
<StateChange state="{configMap.status}" />
</div>
<svelte:fragment slot="tabs">
<Tab
title="Summary"
selected="{isTabSelected($router.path, 'summary')}"
url="{getTabUrl($router.path, 'summary')}" />
<Tab
title="Inspect"
selected="{isTabSelected($router.path, 'inspect')}"
url="{getTabUrl($router.path, 'inspect')}" />
<Tab title="Kube" selected="{isTabSelected($router.path, 'kube')}" url="{getTabUrl($router.path, 'kube')}" />
</svelte:fragment>
<svelte:fragment slot="content">
<Route path="/summary" breadcrumb="Summary" navigationHint="tab">
<ConfigMapDetailsSummary configMap="{kubeConfigMap}" kubeError="{kubeError}" />
</Route>
<Route path="/inspect" breadcrumb="Inspect" navigationHint="tab">
<MonacoEditor content="{JSON.stringify(kubeConfigMap, undefined, 2)}" language="json" />
</Route>
<Route path="/kube" breadcrumb="Kube" navigationHint="tab">
<KubeEditYAML content="{stringify(kubeConfigMap)}" />
</Route>
</svelte:fragment>
</DetailsPage>
{/if}

View file

@ -0,0 +1,72 @@
/**********************************************************************
* Copyright (C) 2024 Red Hat, Inc.
*
* 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.
*
* SPDX-License-Identifier: Apache-2.0
***********************************************************************/
import '@testing-library/jest-dom/vitest';
import type { V1ConfigMap } from '@kubernetes/client-node';
import { render, screen } from '@testing-library/svelte';
import { beforeEach, expect, test, vi } from 'vitest';
import ConfigMapDetailsSummary from './ConfigMapDetailsSummary.svelte';
const configMap: V1ConfigMap = {
metadata: {
name: 'my-configmap',
namespace: 'default',
},
data: {
key1: 'value1',
key2: 'value2',
},
binaryData: {
key3: 'value3',
},
};
const kubeError = 'Error retrieving node details';
beforeEach(() => {
vi.resetAllMocks();
vi.clearAllMocks();
});
test('Confirm renders configmap details summary', async () => {
render(ConfigMapDetailsSummary, { configMap });
expect(screen.getByText('my-configmap')).toBeInTheDocument();
expect(screen.getByText('default')).toBeInTheDocument();
expect(screen.getByText('key1')).toBeInTheDocument();
expect(screen.getByText('value1')).toBeInTheDocument();
expect(screen.getByText('key2')).toBeInTheDocument();
expect(screen.getByText('value2')).toBeInTheDocument();
// binary data just shows the key and size, not the data
expect(screen.getByText('key3: 6 bytes')).toBeInTheDocument();
});
test('Expect to show loading if there is no data present', async () => {
render(ConfigMapDetailsSummary, {});
const loadingMessage = screen.getByText('Loading ...');
expect(loadingMessage).toBeInTheDocument();
});
test('Expect to show error message when there is a kube error', async () => {
render(ConfigMapDetailsSummary, { configMap, kubeError: kubeError });
const errorMessage = screen.getByText(kubeError);
expect(errorMessage).toBeInTheDocument();
});

View file

@ -0,0 +1,27 @@
<script lang="ts">
import type { V1ConfigMap } from '@kubernetes/client-node';
import { ErrorMessage } from '@podman-desktop/ui-svelte';
import Table from '/@/lib/details/DetailsTable.svelte';
import KubeConfigMapArtifact from '../kube/details/KubeConfigMapArtifact.svelte';
import KubeObjectMetaArtifact from '../kube/details/KubeObjectMetaArtifact.svelte';
export let configMap: V1ConfigMap | undefined;
export let kubeError: string | undefined = undefined;
</script>
<!-- Show the kube error if we're unable to retrieve the data correctly, but we still want to show the
basic information -->
{#if kubeError}
<ErrorMessage error="{kubeError}" />
{/if}
<Table>
{#if configMap}
<KubeObjectMetaArtifact artifact="{configMap.metadata}" />
<KubeConfigMapArtifact artifact="{configMap}" />
{:else}
<p class="text-purple-500 font-medium">Loading ...</p>
{/if}
</Table>

View file

@ -0,0 +1,82 @@
/**********************************************************************
* Copyright (C) 2024 Red Hat, Inc.
*
* 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.
*
* SPDX-License-Identifier: Apache-2.0
***********************************************************************/
import '@testing-library/jest-dom/vitest';
import { fireEvent, render, screen, waitFor } from '@testing-library/svelte';
import { afterEach, beforeEach, expect, test, vi } from 'vitest';
import ConfigMapSecretActions from './ConfigMapSecretActions.svelte';
import type { ConfigMapSecretUI } from './ConfigMapSecretUI';
const updateMock = vi.fn();
const deleteMock = vi.fn();
const showMessageBoxMock = vi.fn();
const fakeConfigMap: ConfigMapSecretUI = {
name: 'my-configmap',
namespace: '',
selected: false,
type: 'ConfigMap',
status: '',
keys: [],
};
const fakeSecret: ConfigMapSecretUI = {
name: 'my-secret',
namespace: '',
selected: false,
type: 'Secret',
status: '',
keys: [],
};
beforeEach(() => {
(window as any).showMessageBox = showMessageBoxMock;
(window as any).kubernetesDeleteConfigMap = deleteMock;
(window as any).kubernetesDeleteSecret = deleteMock;
});
afterEach(() => {
vi.resetAllMocks();
vi.clearAllMocks();
});
test('Expect no error when deleting configmap', async () => {
showMessageBoxMock.mockResolvedValue({ response: 0 });
render(ConfigMapSecretActions, { configMapSecret: fakeConfigMap, onUpdate: updateMock });
// click on delete button
const deleteButton = screen.getByRole('button', { name: 'Delete ConfigMap' });
await fireEvent.click(deleteButton);
// wait for the delete function to be called
await waitFor(() => expect(deleteMock).toHaveBeenCalled());
});
test('Expect no error when deleting secret', async () => {
showMessageBoxMock.mockResolvedValue({ response: 0 });
render(ConfigMapSecretActions, { configMapSecret: fakeSecret, onUpdate: updateMock });
// click on delete button
const deleteButton = screen.getByRole('button', { name: 'Delete Secret' });
await fireEvent.click(deleteButton);
// wait for the delete function to be called
await waitFor(() => expect(deleteMock).toHaveBeenCalled());
});

View file

@ -0,0 +1,41 @@
<script lang="ts">
import { faTrash } from '@fortawesome/free-solid-svg-icons';
import { createEventDispatcher } from 'svelte';
import { withConfirmation } from '/@/lib/dialogs/messagebox-utils';
import ListItemButtonIcon from '../ui/ListItemButtonIcon.svelte';
import { ConfigMapSecretUtils } from './configmap-secret-utils';
import type { ConfigMapSecretUI } from './ConfigMapSecretUI';
export let configMapSecret: ConfigMapSecretUI;
export let detailed = false;
const dispatch = createEventDispatcher<{ update: ConfigMapSecretUI }>();
export let onUpdate: (update: ConfigMapSecretUI) => void = update => {
dispatch('update', update);
};
const configmapSecretUtils = new ConfigMapSecretUtils();
async function deleteConfigMapSecret(): Promise<void> {
configMapSecret.status = 'DELETING';
onUpdate(configMapSecret);
if (configmapSecretUtils.isSecret(configMapSecret)) {
await window.kubernetesDeleteSecret(configMapSecret.name);
} else {
await window.kubernetesDeleteConfigMap((configMapSecret as ConfigMapSecretUI).name);
}
}
</script>
<ListItemButtonIcon
title="{`Delete ${configmapSecretUtils.isSecret(configMapSecret) ? 'Secret' : 'ConfigMap'}`}"
onClick="{() =>
withConfirmation(
deleteConfigMapSecret,
`delete ${configmapSecretUtils.isSecret(configMapSecret) ? 'secret' : 'configmap'} ${configMapSecret.name}`,
)}"
detailed="{detailed}"
icon="{faTrash}" />

View file

@ -0,0 +1,41 @@
/**********************************************************************
* Copyright (C) 2024 Red Hat, Inc.
*
* 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.
*
* SPDX-License-Identifier: Apache-2.0
***********************************************************************/
import '@testing-library/jest-dom/vitest';
import { render, screen } from '@testing-library/svelte';
import { expect, test } from 'vitest';
import ConfigMapSecretColumnActions from './ConfigMapSecretColumnActions.svelte';
import type { ConfigMapSecretUI } from './ConfigMapSecretUI';
test('Expect action buttons', async () => {
const configMap: ConfigMapSecretUI = {
name: 'my-configmap',
namespace: '',
selected: false,
type: 'ConfigMap',
status: '',
keys: [],
};
render(ConfigMapSecretColumnActions, { object: configMap });
const buttons = await screen.findAllByRole('button');
expect(buttons).toHaveLength(1);
});

View file

@ -0,0 +1,8 @@
<script lang="ts">
import ConfigmapSecretActions from './ConfigMapSecretActions.svelte';
import type { ConfigMapSecretUI } from './ConfigMapSecretUI';
export let object: ConfigMapSecretUI;
</script>
<ConfigmapSecretActions configMapSecret="{object}" on:update />

View file

@ -0,0 +1,88 @@
/**********************************************************************
* Copyright (C) 2024 Red Hat, Inc.
*
* 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.
*
* SPDX-License-Identifier: Apache-2.0
***********************************************************************/
import '@testing-library/jest-dom/vitest';
import { fireEvent, render, screen } from '@testing-library/svelte';
import { router } from 'tinro';
import { expect, test, vi } from 'vitest';
import ConfigMapSecretColumnName from './ConfigMapSecretColumnName.svelte';
import type { ConfigMapSecretUI } from './ConfigMapSecretUI';
const configMap: ConfigMapSecretUI = {
name: 'my-configmap',
namespace: 'default',
selected: false,
type: 'ConfigMap',
status: '',
keys: [],
};
const secret: ConfigMapSecretUI = {
name: 'my-secret',
namespace: 'default',
selected: false,
type: 'Secret',
status: '',
keys: [],
};
test('Expect simple column styling', async () => {
render(ConfigMapSecretColumnName, { object: configMap });
const text = screen.getByText(configMap.name);
expect(text).toBeInTheDocument();
expect(text).toHaveClass('text-sm');
expect(text).toHaveClass('text-[var(--pd-table-body-text-highlight)]');
});
test('Configmap: Expect clicking works', async () => {
render(ConfigMapSecretColumnName, { object: configMap });
const text = screen.getByText(configMap.name);
expect(text).toBeInTheDocument();
// test click
const routerGotoSpy = vi.spyOn(router, 'goto');
fireEvent.click(text);
expect(routerGotoSpy).toBeCalledWith('/configmapsSecrets/configmap/my-configmap/default/summary');
});
test('Secret: Expect clicking works', async () => {
render(ConfigMapSecretColumnName, { object: secret });
const text = screen.getByText(secret.name);
expect(text).toBeInTheDocument();
// test click
const routerGotoSpy = vi.spyOn(router, 'goto');
fireEvent.click(text);
expect(routerGotoSpy).toBeCalledWith('/configmapsSecrets/secret/my-secret/default/summary');
});
test('Expect namespace in column', async () => {
render(ConfigMapSecretColumnName, { object: configMap });
const text = screen.getByText(configMap.namespace);
expect(text).toBeInTheDocument();
});

View file

@ -0,0 +1,30 @@
<script lang="ts">
import { router } from 'tinro';
import { ConfigMapSecretUtils } from './configmap-secret-utils';
import type { ConfigMapSecretUI } from './ConfigMapSecretUI';
export let object: ConfigMapSecretUI;
function openDetails() {
const configmapSecretUtils = new ConfigMapSecretUtils();
if (configmapSecretUtils.isSecret(object)) {
router.goto(`/configmapsSecrets/secret/${encodeURI(object.name)}/${encodeURI(object.namespace)}/summary`);
}
if (configmapSecretUtils.isConfigMap(object)) {
router.goto(`/configmapsSecrets/configmap/${encodeURI(object.name)}/${encodeURI(object.namespace)}/summary`);
}
}
</script>
<button class="hover:cursor-pointer flex flex-col max-w-full" on:click="{() => openDetails()}">
<div class="text-sm text-[var(--pd-table-body-text-highlight)] max-w-full overflow-hidden text-ellipsis">
{object.name}
</div>
<div class="flex flex-row text-sm gap-1">
{#if object.namespace}
<div class="font-extra-light text-[var(--pd-table-body-text)]">{object.namespace}</div>
{/if}
</div>
</button>

View file

@ -0,0 +1,42 @@
/**********************************************************************
* Copyright (C) 2024 Red Hat, Inc.
*
* 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.
*
* SPDX-License-Identifier: Apache-2.0
***********************************************************************/
import '@testing-library/jest-dom/vitest';
import { render, screen } from '@testing-library/svelte';
import { expect, test } from 'vitest';
import ConfigMapSecretColumnStatus from './ConfigMapSecretColumnStatus.svelte';
import type { ConfigMapSecretUI } from './ConfigMapSecretUI';
test('Expect status styling for running', async () => {
const configMap: ConfigMapSecretUI = {
name: 'my-configmap',
namespace: '',
selected: false,
type: 'ConfigMap',
status: 'RUNNING',
keys: [],
};
render(ConfigMapSecretColumnStatus, { object: configMap });
const text = screen.getByRole('status');
expect(text).toBeInTheDocument();
expect(text).toHaveClass('bg-[var(--pd-status-running)]');
});

View file

@ -0,0 +1,10 @@
<script lang="ts">
import { StatusIcon } from '@podman-desktop/ui-svelte';
import ConfigMapSecretIcon from '../images/ConfigMapSecretIcon.svelte';
import type { ConfigMapSecretUI } from './ConfigMapSecretUI';
export let object: ConfigMapSecretUI;
</script>
<StatusIcon icon="{ConfigMapSecretIcon}" status="{object.status}" />

View file

@ -0,0 +1,63 @@
/**********************************************************************
* Copyright (C) 2024 Red Hat, Inc.
*
* 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.
*
* SPDX-License-Identifier: Apache-2.0
***********************************************************************/
import '@testing-library/jest-dom/vitest';
import { render, screen } from '@testing-library/svelte';
import { expect, test } from 'vitest';
import ConfigMapSecretColumnType from './ConfigMapSecretColumnType.svelte';
import type { ConfigMapSecretUI } from './ConfigMapSecretUI';
test('Expect type display for ConfigMap', async () => {
const configMap: ConfigMapSecretUI = {
name: 'my-configmap',
namespace: '',
selected: false,
type: 'ConfigMap',
status: '',
keys: [],
};
render(ConfigMapSecretColumnType, { object: configMap });
const text = screen.getByText('ConfigMap');
expect(text).toBeInTheDocument();
const svg = text.parentElement?.querySelector('svg');
expect(svg).toBeInTheDocument();
expect(svg).toHaveClass('text-[var(--pd-status-running)]');
});
test('Expect type display for Secret', async () => {
const secret: ConfigMapSecretUI = {
name: 'my-secret',
namespace: '',
selected: false,
type: 'Secret',
status: '',
keys: [],
};
render(ConfigMapSecretColumnType, { object: secret });
const text = screen.getByText('Secret');
expect(text).toBeInTheDocument();
const svg = text.parentElement?.querySelector('svg');
expect(svg).toBeInTheDocument();
expect(svg).toHaveClass('text-[var(--pd-status-running)]');
});

View file

@ -0,0 +1,24 @@
<script lang="ts">
import { faFileAlt, faKey } from '@fortawesome/free-solid-svg-icons';
import Fa from 'svelte-fa';
import Label from '../ui/Label.svelte';
import type { ConfigMapSecretUI } from './ConfigMapSecretUI';
export let object: ConfigMapSecretUI;
// Determine the icon and color based on the type
function getTypeAttributes(type: string) {
const isConfigMap = type === 'ConfigMap';
return {
color: 'text-[var(--pd-status-running)]',
icon: isConfigMap ? faFileAlt : faKey,
};
}
</script>
<div class="flex flex-row gap-1">
<Label name="{object.type}" capitalize="{false}">
<Fa size="1x" icon="{getTypeAttributes(object.type).icon}" class="{getTypeAttributes(object.type).color}" />
</Label>
</div>

View file

@ -0,0 +1,32 @@
/**********************************************************************
* Copyright (C) 2024 Red Hat, Inc.
*
* 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.
*
* SPDX-License-Identifier: Apache-2.0
***********************************************************************/
/* eslint-disable @typescript-eslint/no-explicit-any */
import '@testing-library/jest-dom/vitest';
import { render, screen } from '@testing-library/svelte';
import { expect, test } from 'vitest';
import ConfigMapSecretEmptyScreen from './ConfigMapSecretEmptyScreen.svelte';
test('Expect configmap empty screen', async () => {
render(ConfigMapSecretEmptyScreen);
const noNodes = screen.getByRole('heading', { name: 'No configmaps or secrets' });
expect(noNodes).toBeInTheDocument();
});

View file

@ -0,0 +1,10 @@
<script lang="ts">
import { EmptyScreen } from '@podman-desktop/ui-svelte';
import ConfigMapSecretIcon from '../images/ConfigMapSecretIcon.svelte';
</script>
<EmptyScreen
icon="{ConfigMapSecretIcon}"
title="No configmaps or secrets"
message="Try switching to a different context or namespace" />

View file

@ -0,0 +1,105 @@
/**********************************************************************
* Copyright (C) 2024 Red Hat, Inc.
*
* 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.
*
* SPDX-License-Identifier: Apache-2.0
***********************************************************************/
/* eslint-disable @typescript-eslint/no-explicit-any */
import '@testing-library/jest-dom/vitest';
import type { V1ConfigMap, V1Secret } from '@kubernetes/client-node';
import { render, screen } from '@testing-library/svelte';
/* eslint-disable import/no-duplicates */
import { tick } from 'svelte';
import { get } from 'svelte/store';
/* eslint-enable import/no-duplicates */
import { beforeAll, beforeEach, expect, test, vi } from 'vitest';
import { kubernetesCurrentContextConfigMaps } from '/@/stores/kubernetes-contexts-state';
import ConfigMapSecretList from './ConfigMapSecretList.svelte';
const kubernetesRegisterGetCurrentContextResourcesMock = vi.fn();
beforeAll(() => {
(window as any).kubernetesRegisterGetCurrentContextResources = kubernetesRegisterGetCurrentContextResourcesMock;
});
beforeEach(() => {
vi.resetAllMocks();
vi.clearAllMocks();
(window as any).kubernetesGetContextsGeneralState = () => Promise.resolve(new Map());
(window as any).kubernetesGetCurrentContextGeneralState = () => Promise.resolve({});
(window as any).window.kubernetesUnregisterGetCurrentContextResources = () => Promise.resolve(undefined);
});
async function waitRender(customProperties: object): Promise<void> {
render(ConfigMapSecretList, { ...customProperties });
await tick();
}
test('Expect configmap empty screen', async () => {
kubernetesRegisterGetCurrentContextResourcesMock.mockResolvedValue([]);
render(ConfigMapSecretList);
const noNodes = screen.getByRole('heading', { name: 'No configmaps or secrets' });
expect(noNodes).toBeInTheDocument();
});
test('Expect configmap and secrets list', async () => {
const configMap: V1ConfigMap = {
metadata: {
name: 'my-configmap',
namespace: 'my-namespace',
},
data: {
key1: 'value1',
key2: 'value2',
},
};
const secret: V1Secret = {
metadata: {
name: 'my-secret',
namespace: 'my-namespace',
},
data: {
secretkey1: 'value1',
secretkey2: 'value2',
},
type: 'Opaque',
};
kubernetesRegisterGetCurrentContextResourcesMock.mockResolvedValue([configMap, secret]);
// wait while store is populated
while (get(kubernetesCurrentContextConfigMaps).length === 0) {
await new Promise(resolve => setTimeout(resolve, 500));
}
await waitRender({});
const configMapName = screen.getByRole('cell', { name: 'my-configmap my-namespace' });
expect(configMapName).toBeInTheDocument();
// Expect ConfigMap type
const configMapType = screen.getByRole('cell', { name: 'ConfigMap' });
expect(configMapType).toBeInTheDocument();
const secretName = screen.getByRole('cell', { name: 'my-secret my-namespace' });
expect(secretName).toBeInTheDocument();
// Expect Opaque type
const secretType = screen.getByRole('cell', { name: 'Opaque' });
expect(secretType).toBeInTheDocument();
});

View file

@ -0,0 +1,190 @@
<script lang="ts">
import { faTrash } from '@fortawesome/free-solid-svg-icons';
import {
Button,
FilteredEmptyScreen,
NavPage,
Table,
TableColumn,
TableDurationColumn,
TableRow,
TableSimpleColumn,
} from '@podman-desktop/ui-svelte';
import moment from 'moment';
import { onDestroy, onMount } from 'svelte';
import type { Unsubscriber } from 'svelte/store';
import KubernetesCurrentContextConnectionBadge from '/@/lib/ui/KubernetesCurrentContextConnectionBadge.svelte';
import {
configmapSearchPattern,
kubernetesCurrentContextConfigMapsFiltered,
kubernetesCurrentContextSecretsFiltered,
secretSearchPattern,
} from '/@/stores/kubernetes-contexts-state';
import ConfigMapSecretIcon from '../images/ConfigMapSecretIcon.svelte';
import KubeApplyYamlButton from '../kube/KubeApplyYAMLButton.svelte';
import { ConfigMapSecretUtils } from './configmap-secret-utils';
import ConfigMapSecretColumnActions from './ConfigMapSecretColumnActions.svelte';
import ConfigMapSecretColumnName from './ConfigMapSecretColumnName.svelte';
import ConfigMapSecretColumnStatus from './ConfigMapSecretColumnStatus.svelte';
import ConfigMapSecretColumnType from './ConfigMapSecretColumnType.svelte';
import ConfigMapSecretEmptyScreen from './ConfigMapSecretEmptyScreen.svelte';
import type { ConfigMapSecretUI } from './ConfigMapSecretUI';
export let searchTerm = '';
$: secretSearchPattern.set(searchTerm);
$: configmapSearchPattern.set(searchTerm);
let configmapsUI: ConfigMapSecretUI[] = [];
let secretsUI: ConfigMapSecretUI[] = [];
let configmapsSecretsUI: ConfigMapSecretUI[] = [];
const configmapSecretUtils = new ConfigMapSecretUtils();
let configmapsUnsubscribe: Unsubscriber;
let secretsUnsubscribe: Unsubscriber;
onMount(() => {
configmapsUnsubscribe = kubernetesCurrentContextConfigMapsFiltered.subscribe(value => {
configmapsUI = value.map(configmap => configmapSecretUtils.getConfigMapSecretUI(configmap));
configmapsSecretsUI = [...configmapsUI, ...secretsUI];
});
secretsUnsubscribe = kubernetesCurrentContextSecretsFiltered.subscribe(value => {
secretsUI = value.map(secret => configmapSecretUtils.getConfigMapSecretUI(secret));
configmapsSecretsUI = [...configmapsUI, ...secretsUI];
});
});
onDestroy(() => {
// unsubscribe from the store
configmapsUnsubscribe?.();
secretsUnsubscribe?.();
});
// delete the items selected in the list
let bulkDeleteInProgress = false;
async function deleteSelectedConfigMapsSecrets() {
const selectedConfigMapsSecrets = configmapsSecretsUI.filter(configmapsSecretsUI => configmapsSecretsUI.selected);
if (selectedConfigMapsSecrets.length === 0) {
return;
}
// mark configmap or secret for deletion
bulkDeleteInProgress = true;
selectedConfigMapsSecrets.forEach(configmapSecret => (configmapSecret.status = 'DELETING'));
configmapsSecretsUI = configmapsSecretsUI;
if (selectedConfigMapsSecrets.length > 0) {
bulkDeleteInProgress = true;
await Promise.all(
selectedConfigMapsSecrets.map(async configmapSecret => {
try {
if (configmapSecretUtils.isSecret(configmapSecret)) {
await window.kubernetesDeleteSecret(configmapSecret.name);
}
// Separate the delete logic (cannot have in else if) or else you need to infer the type of configmapSecret
// using (configmapSecret as ConfigMapSecretUI)
if (configmapSecretUtils.isConfigMap(configmapSecret)) {
await window.kubernetesDeleteConfigMap(configmapSecret.name);
}
} catch (e) {
console.error(
`error while deleting ${configmapSecretUtils.isSecret(configmapSecret) ? 'secret' : 'configmap'}`,
e,
);
}
}),
);
bulkDeleteInProgress = false;
}
}
let selectedItemsNumber: number;
let table: Table;
let statusColumn = new TableColumn<ConfigMapSecretUI>('Status', {
align: 'center',
width: '70px',
renderer: ConfigMapSecretColumnStatus,
comparator: (a, b) => a.status.localeCompare(b.status),
});
let nameColumn = new TableColumn<ConfigMapSecretUI>('Name', {
width: '1.3fr',
renderer: ConfigMapSecretColumnName,
comparator: (a, b) => a.name.localeCompare(b.name),
});
let ageColumn = new TableColumn<ConfigMapSecretUI, Date | undefined>('Age', {
renderMapping: configmapSecret => configmapSecret.created,
renderer: TableDurationColumn,
comparator: (a, b) => moment(b.created).diff(moment(a.created)),
});
let keysColumn = new TableColumn<ConfigMapSecretUI, string>('Keys', {
renderMapping: config => config.keys.length.toString(),
renderer: TableSimpleColumn,
comparator: (a, b) => a.keys.length - b.keys.length,
});
let typeColumn = new TableColumn<ConfigMapSecretUI>('Type', {
overflow: true,
width: '2fr',
renderer: ConfigMapSecretColumnType,
comparator: (a, b) => a.type.localeCompare(b.type),
});
const columns = [
statusColumn,
nameColumn,
typeColumn,
keysColumn,
ageColumn,
new TableColumn<ConfigMapSecretUI>('Actions', { align: 'right', renderer: ConfigMapSecretColumnActions }),
];
const row = new TableRow<ConfigMapSecretUI>({ selectable: _configmapSecret => true });
</script>
<NavPage bind:searchTerm="{searchTerm}" title="configmaps & secrets">
<svelte:fragment slot="additional-actions">
<KubeApplyYamlButton />
</svelte:fragment>
<svelte:fragment slot="bottom-additional-actions">
{#if selectedItemsNumber > 0}
<Button
on:click="{() => deleteSelectedConfigMapsSecrets()}"
title="Delete {selectedItemsNumber} selected items"
inProgress="{bulkDeleteInProgress}"
icon="{faTrash}" />
<span>On {selectedItemsNumber} selected items.</span>
{/if}
<div class="flex grow justify-end">
<KubernetesCurrentContextConnectionBadge />
</div>
</svelte:fragment>
<div class="flex min-w-full h-full" slot="content">
<Table
kind="configmap & secret"
bind:this="{table}"
bind:selectedItemsNumber="{selectedItemsNumber}"
data="{configmapsSecretsUI}"
columns="{columns}"
row="{row}"
defaultSortColumn="Name"
on:update="{() => (configmapsSecretsUI = configmapsSecretsUI)}">
</Table>
{#if $kubernetesCurrentContextConfigMapsFiltered.length === 0 && $kubernetesCurrentContextSecretsFiltered.length === 0}
{#if searchTerm}
<FilteredEmptyScreen icon="{ConfigMapSecretIcon}" kind="configmaps or secrets" bind:searchTerm="{searchTerm}" />
{:else}
<ConfigMapSecretEmptyScreen />
{/if}
{/if}
</div>
</NavPage>

View file

@ -0,0 +1,27 @@
/**********************************************************************
* Copyright (C) 2024 Red Hat, Inc.
*
* 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.
*
* SPDX-License-Identifier: Apache-2.0
***********************************************************************/
export interface ConfigMapSecretUI {
name: string;
namespace: string;
status: string;
keys: string[];
selected: boolean;
type: string;
created?: Date;
}

View file

@ -0,0 +1,57 @@
/**********************************************************************
* Copyright (C) 2024 Red Hat, Inc.
*
* 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.
*
* SPDX-License-Identifier: Apache-2.0
***********************************************************************/
import '@testing-library/jest-dom/vitest';
import type { KubernetesObject, V1Secret } from '@kubernetes/client-node';
import { render, screen } from '@testing-library/svelte';
import { writable } from 'svelte/store';
import { beforeAll, expect, test, vi } from 'vitest';
import * as kubeContextStore from '/@/stores/kubernetes-contexts-state';
import SecretDetails from './SecretDetails.svelte';
const secret: V1Secret = {
metadata: {
name: 'my-secret',
namespace: 'default',
},
data: {},
};
vi.mock('/@/stores/kubernetes-contexts-state', async () => {
return {
kubernetesCurrentContextSecrets: vi.fn(),
};
});
beforeAll(() => {
(window as any).kubernetesReadNamespacedSecret = vi.fn();
});
test('Confirm renders secret details', async () => {
// mock object store
const secrets = writable<KubernetesObject[]>([secret]);
vi.mocked(kubeContextStore).kubernetesCurrentContextSecrets = secrets;
render(SecretDetails, { name: 'my-secret', namespace: 'default' });
expect(screen.getByText('my-secret')).toBeInTheDocument();
expect(screen.getByText('default')).toBeInTheDocument();
});

View file

@ -0,0 +1,93 @@
<script lang="ts">
import type { V1Secret } from '@kubernetes/client-node';
import { StatusIcon, Tab } from '@podman-desktop/ui-svelte';
import { onMount } from 'svelte';
import { router } from 'tinro';
import { stringify } from 'yaml';
import { kubernetesCurrentContextSecrets } from '/@/stores/kubernetes-contexts-state';
import Route from '../../Route.svelte';
import MonacoEditor from '../editor/MonacoEditor.svelte';
import SecretIcon from '../images/ConfigMapSecretIcon.svelte';
import KubeEditYAML from '../kube/KubeEditYAML.svelte';
import DetailsPage from '../ui/DetailsPage.svelte';
import StateChange from '../ui/StateChange.svelte';
import { getTabUrl, isTabSelected } from '../ui/Util';
import { ConfigMapSecretUtils } from './configmap-secret-utils';
import ConfigMapSecretActions from './ConfigMapSecretActions.svelte';
import type { ConfigMapSecretUI } from './ConfigMapSecretUI';
import SecretDetailsSummary from './SecretDetailsSummary.svelte';
export let name: string;
export let namespace: string;
let secret: ConfigMapSecretUI;
let detailsPage: DetailsPage;
let kubeSecret: V1Secret | undefined;
let kubeError: string;
onMount(() => {
const secretUtils = new ConfigMapSecretUtils();
// loading secret info
return kubernetesCurrentContextSecrets.subscribe(secrets => {
const matchingSecret = secrets.find(
secret => secret.metadata?.name === name && secret.metadata?.namespace === namespace,
);
if (matchingSecret) {
try {
secret = secretUtils.getConfigMapSecretUI(matchingSecret);
loadDetails();
} catch (err) {
console.error(err);
}
} else if (detailsPage) {
// the secret has been deleted
detailsPage.close();
}
});
});
async function loadDetails() {
const getKubeSecret = await window.kubernetesReadNamespacedSecret(secret.name, namespace);
if (getKubeSecret) {
kubeSecret = getKubeSecret;
} else {
kubeError = `Unable to retrieve Kubernetes details for ${secret.name}`;
}
}
</script>
{#if secret}
<DetailsPage title="{secret.name}" subtitle="{secret.namespace}" bind:this="{detailsPage}">
<StatusIcon slot="icon" icon="{SecretIcon}" size="{24}" status="{secret.status}" />
<svelte:fragment slot="actions">
<ConfigMapSecretActions configMapSecret="{secret}" detailed="{true}" on:update="{() => (secret = secret)}" />
</svelte:fragment>
<div slot="detail" class="flex py-2 w-full justify-end text-sm text-gray-700">
<StateChange state="{secret.status}" />
</div>
<svelte:fragment slot="tabs">
<Tab
title="Summary"
selected="{isTabSelected($router.path, 'summary')}"
url="{getTabUrl($router.path, 'summary')}" />
<Tab
title="Inspect"
selected="{isTabSelected($router.path, 'inspect')}"
url="{getTabUrl($router.path, 'inspect')}" />
<Tab title="Kube" selected="{isTabSelected($router.path, 'kube')}" url="{getTabUrl($router.path, 'kube')}" />
</svelte:fragment>
<svelte:fragment slot="content">
<Route path="/summary" breadcrumb="Summary" navigationHint="tab">
<SecretDetailsSummary secret="{kubeSecret}" kubeError="{kubeError}" />
</Route>
<Route path="/inspect" breadcrumb="Inspect" navigationHint="tab">
<MonacoEditor content="{JSON.stringify(kubeSecret, undefined, 2)}" language="json" />
</Route>
<Route path="/kube" breadcrumb="Kube" navigationHint="tab">
<KubeEditYAML content="{stringify(kubeSecret)}" />
</Route>
</svelte:fragment>
</DetailsPage>
{/if}

View file

@ -0,0 +1,71 @@
/**********************************************************************
* Copyright (C) 2024 Red Hat, Inc.
*
* 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.
*
* SPDX-License-Identifier: Apache-2.0
***********************************************************************/
import '@testing-library/jest-dom/vitest';
import type { V1Secret } from '@kubernetes/client-node';
import { render, screen } from '@testing-library/svelte';
import { beforeEach, expect, test, vi } from 'vitest';
import SecretDetailsSummary from './SecretDetailsSummary.svelte';
const secret: V1Secret = {
metadata: {
name: 'my-secret',
namespace: 'default',
},
data: {
key1: 'value1',
key2: 'value2',
},
type: 'Opaque',
};
const kubeError = 'Error retrieving node details';
beforeEach(() => {
vi.resetAllMocks();
vi.clearAllMocks();
});
test('Confirm renders secret details summary', async () => {
render(SecretDetailsSummary, { secret });
expect(screen.getByText('my-secret')).toBeInTheDocument();
expect(screen.getByText('default')).toBeInTheDocument();
expect(screen.getByText('key1')).toBeInTheDocument();
expect(screen.getByText('value1')).toBeInTheDocument();
expect(screen.getByText('key2')).toBeInTheDocument();
expect(screen.getByText('value2')).toBeInTheDocument();
// expect type to be shown
expect(screen.getByText('Opaque')).toBeInTheDocument();
});
test('Expect to show loading if there is no data present', async () => {
render(SecretDetailsSummary, {});
const loadingMessage = screen.getByText('Loading ...');
expect(loadingMessage).toBeInTheDocument();
});
test('Expect to show error message when there is a kube error', async () => {
render(SecretDetailsSummary, { secret, kubeError: kubeError });
const errorMessage = screen.getByText(kubeError);
expect(errorMessage).toBeInTheDocument();
});

View file

@ -0,0 +1,27 @@
<script lang="ts">
import type { V1Secret } from '@kubernetes/client-node';
import { ErrorMessage } from '@podman-desktop/ui-svelte';
import Table from '/@/lib/details/DetailsTable.svelte';
import KubeObjectMetaArtifact from '../kube/details/KubeObjectMetaArtifact.svelte';
import KubeSecretArtifact from '../kube/details/KubeSecretArtifact.svelte';
export let secret: V1Secret | undefined;
export let kubeError: string | undefined = undefined;
</script>
<!-- Show the kube error if we're unable to retrieve the data correctly, but we still want to show the
basic information -->
{#if kubeError}
<ErrorMessage error="{kubeError}" />
{/if}
<Table>
{#if secret}
<KubeObjectMetaArtifact artifact="{secret.metadata}" />
<KubeSecretArtifact artifact="{secret}" />
{:else}
<p class="text-purple-500 font-medium">Loading ...</p>
{/if}
</Table>

View file

@ -0,0 +1,65 @@
/**********************************************************************
* Copyright (C) 2024 Red Hat, Inc.
*
* 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.
*
* SPDX-License-Identifier: Apache-2.0
***********************************************************************/
import type { V1ConfigMap, V1Secret } from '@kubernetes/client-node';
import { beforeEach, expect, test, vi } from 'vitest';
import { ConfigMapSecretUtils } from './configmap-secret-utils';
let configMapSecretUtils: ConfigMapSecretUtils;
beforeEach(() => {
vi.clearAllMocks();
configMapSecretUtils = new ConfigMapSecretUtils();
});
test('expect configmap UI conversion', async () => {
const configMap = {
metadata: {
name: 'my-configmap',
namespace: 'test-namespace',
},
data: {
key1: 'value1',
key2: 'value2',
},
} as V1ConfigMap;
const configMapUI = configMapSecretUtils.getConfigMapSecretUI(configMap);
expect(configMapUI.name).toEqual('my-configmap');
expect(configMapUI.namespace).toEqual('test-namespace');
expect(configMapUI.keys).toEqual(['key1', 'key2']);
});
test('expect secret UI conversion', async () => {
const secret = {
metadata: {
name: 'my-secret',
namespace: 'test-namespace',
},
data: {
key1: 'value1',
key2: 'value2',
},
type: 'Opaque',
} as V1Secret;
const secretUI = configMapSecretUtils.getConfigMapSecretUI(secret);
expect(secretUI.name).toEqual('my-secret');
expect(secretUI.namespace).toEqual('test-namespace');
expect(secretUI.keys).toEqual(['key1', 'key2']);
expect(secretUI.type).toEqual('Opaque');
});

View file

@ -0,0 +1,55 @@
/**********************************************************************
* Copyright (C) 2024 Red Hat, Inc.
*
* 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.
*
* SPDX-License-Identifier: Apache-2.0
***********************************************************************/
import type { V1ConfigMap, V1Secret } from '@kubernetes/client-node';
import type { ConfigMapSecretUI } from './ConfigMapSecretUI';
export class ConfigMapSecretUtils {
// If it is a secret, then it will have a type property, as well as NO binaryData property
isSecret(storage: V1ConfigMap | V1Secret): storage is V1Secret {
return 'type' in storage && storage.type !== 'ConfigMap' && !('binaryData' in storage);
}
// If it is a configMap, then the type property will either be undefined, or it will be 'ConfigMap'
isConfigMap(storage: V1ConfigMap | V1Secret): storage is V1ConfigMap {
return ('type' in storage && storage.type === 'ConfigMap') || 'type' in storage === undefined;
}
getConfigMapSecretUI(storage: V1ConfigMap | V1Secret): ConfigMapSecretUI {
const created = storage.metadata?.creationTimestamp;
const keys = Object.keys(storage.data ?? {});
// If storage.type does not exist, it's V1ConfigMap and just set the type as 'ConfigMap'
let type = 'ConfigMap';
// If storage.type exists, it's V1Secret and set the type as storage.type
if ('type' in storage && storage.type) {
type = storage.type;
}
return {
name: storage.metadata?.name ?? '',
namespace: storage.metadata?.namespace ?? '',
status: 'RUNNING',
keys,
selected: false,
type,
created,
};
}
}

View file

@ -0,0 +1,41 @@
<script lang="ts">
import type { V1ConfigMap } from '@kubernetes/client-node';
import Cell from '/@/lib/details/DetailsCell.svelte';
import Title from '/@/lib/details/DetailsTitle.svelte';
import Subtitle from '../../details/DetailsSubtitle.svelte';
export let artifact: V1ConfigMap | undefined;
</script>
{#if artifact}
<tr>
<Title>Details</Title>
</tr>
<tr>
<Cell>Immutable</Cell>
<Cell>{artifact.immutable ? 'Yes' : 'No'}</Cell>
</tr>
{#if artifact.binaryData}
<tr>
<Cell>Binary Data</Cell>
<Cell>
{#each Object.entries(artifact.binaryData) as [key, value]}
<div>{key}: {value.length} bytes</div>
{/each}
</Cell>
</tr>
{/if}
{#if artifact.data}
<tr>
<Subtitle>Data</Subtitle>
</tr>
{#each Object.entries(artifact.data) as [key, value]}
<tr>
<Cell>{key}</Cell>
<Cell>{value}</Cell>
</tr>
{/each}
{/if}
{/if}

View file

@ -0,0 +1,35 @@
<script lang="ts">
import type { V1Secret } from '@kubernetes/client-node';
import Cell from '/@/lib/details/DetailsCell.svelte';
import Title from '/@/lib/details/DetailsTitle.svelte';
import Subtitle from '../../details/DetailsSubtitle.svelte';
export let artifact: V1Secret | undefined;
</script>
{#if artifact}
<tr>
<Title>Details</Title>
</tr>
<tr>
<Cell>Type</Cell>
<Cell>{artifact.type}</Cell>
</tr>
<tr>
<Cell>Immutable</Cell>
<Cell>{artifact.immutable ? 'Yes' : 'No'}</Cell>
</tr>
{#if artifact.data}
<tr>
<Subtitle>Data</Subtitle>
</tr>
{#each Object.entries(artifact.data) as [key, value]}
<tr>
<Cell>{key}</Cell>
<Cell>{value}</Cell>
</tr>
{/each}
{/if}
{/if}

View file

@ -165,6 +165,47 @@ export const kubernetesCurrentContextRoutes = readable<KubernetesObject[]>([], s
export const routeSearchPattern = writable('');
// ConfigMaps
export const kubernetesCurrentContextConfigMaps = readable<KubernetesObject[]>([], set => {
window.kubernetesRegisterGetCurrentContextResources('configmaps').then(value => set(value));
window.events?.receive('kubernetes-current-context-configmaps-update', (value: unknown) => {
set(value as KubernetesObject[]);
});
return () => {
window.kubernetesUnregisterGetCurrentContextResources('configmaps');
};
});
export const configmapSearchPattern = writable('');
// The configmaps in the current context, filtered with `configmapSearchPattern`
export const kubernetesCurrentContextConfigMapsFiltered = derived(
[configmapSearchPattern, kubernetesCurrentContextConfigMaps],
([$searchPattern, $configmaps]) =>
$configmaps.filter(configmap => findMatchInLeaves(configmap, $searchPattern.toLowerCase())),
);
// Secrets
export const kubernetesCurrentContextSecrets = readable<KubernetesObject[]>([], set => {
window.kubernetesRegisterGetCurrentContextResources('secrets').then(value => set(value));
window.events?.receive('kubernetes-current-context-secrets-update', (value: unknown) => {
set(value as KubernetesObject[]);
});
return () => {
window.kubernetesUnregisterGetCurrentContextResources('secrets');
};
});
export const secretSearchPattern = writable('');
// The secrets in the current context, filtered with `secretSearchPattern`
export const kubernetesCurrentContextSecretsFiltered = derived(
[secretSearchPattern, kubernetesCurrentContextSecrets],
([$searchPattern, $secrets]) => $secrets.filter(secret => findMatchInLeaves(secret, $searchPattern.toLowerCase())),
);
// The routes in the current context, filtered with `routeSearchPattern`
export const kubernetesCurrentContextRoutesFiltered = derived(
[routeSearchPattern, kubernetesCurrentContextRoutes],