Secret Type
@@ -88,7 +82,7 @@
YAML
-
+
@@ -184,7 +178,7 @@
table-key="kubernetes.configurations.applications"
order-by="Name"
refresh-callback="ctrl.getApplications"
- title-text="Applications using this configuration"
+ title-text="Applications using this secret"
title-icon="svg-laptopcode"
>
diff --git a/app/kubernetes/views/configurations/secret/edit/secret.js b/app/kubernetes/views/configurations/secret/edit/secret.js
new file mode 100644
index 000000000..f59157d14
--- /dev/null
+++ b/app/kubernetes/views/configurations/secret/edit/secret.js
@@ -0,0 +1,8 @@
+angular.module('portainer.kubernetes').component('kubernetesSecretView', {
+ templateUrl: './secret.html',
+ controller: 'KubernetesSecretController',
+ controllerAs: 'ctrl',
+ bindings: {
+ $transition$: '<',
+ },
+});
diff --git a/app/kubernetes/views/configurations/edit/configurationController.js b/app/kubernetes/views/configurations/secret/edit/secretController.js
similarity index 81%
rename from app/kubernetes/views/configurations/edit/configurationController.js
rename to app/kubernetes/views/configurations/secret/edit/secretController.js
index d9d09af57..10133da4d 100644
--- a/app/kubernetes/views/configurations/edit/configurationController.js
+++ b/app/kubernetes/views/configurations/secret/edit/secretController.js
@@ -8,10 +8,12 @@ import KubernetesConfigurationConverter from 'Kubernetes/converters/configuratio
import KubernetesEventHelper from 'Kubernetes/helpers/eventHelper';
import KubernetesNamespaceHelper from 'Kubernetes/helpers/namespaceHelper';
-import { confirmUpdate, confirmWebEditorDiscard } from '@@/modals/confirm';
-import { isConfigurationFormValid } from '../validation';
+import { pluralize } from '@/portainer/helpers/strings';
-class KubernetesConfigurationController {
+import { confirmUpdate, confirmWebEditorDiscard } from '@@/modals/confirm';
+import { isConfigurationFormValid } from '../../validation';
+
+class KubernetesSecretController {
/* @ngInject */
constructor(
$async,
@@ -21,7 +23,6 @@ class KubernetesConfigurationController {
Notifications,
LocalStorage,
KubernetesConfigurationService,
- KubernetesConfigMapService,
KubernetesSecretService,
KubernetesResourcePoolService,
KubernetesApplicationService,
@@ -39,7 +40,6 @@ class KubernetesConfigurationController {
this.KubernetesEventService = KubernetesEventService;
this.KubernetesConfigurationKinds = KubernetesConfigurationKinds;
this.KubernetesSecretTypeOptions = KubernetesSecretTypeOptions;
- this.KubernetesConfigMapService = KubernetesConfigMapService;
this.KubernetesSecretService = KubernetesSecretService;
this.onInit = this.onInit.bind(this);
@@ -48,7 +48,6 @@ class KubernetesConfigurationController {
this.getEventsAsync = this.getEventsAsync.bind(this);
this.getApplications = this.getApplications.bind(this);
this.getApplicationsAsync = this.getApplicationsAsync.bind(this);
- this.getConfigurationsAsync = this.getConfigurationsAsync.bind(this);
this.updateConfiguration = this.updateConfiguration.bind(this);
this.updateConfigurationAsync = this.updateConfigurationAsync.bind(this);
}
@@ -87,11 +86,6 @@ class KubernetesConfigurationController {
// It looks like we're still doing a create/delete process but we've decided to get rid of this
// approach.
async updateConfigurationAsync() {
- let kind = 'ConfigMap';
- if (this.formValues.Kind === KubernetesConfigurationKinds.SECRET) {
- kind = 'Secret';
- }
-
try {
this.state.actionInProgress = true;
if (
@@ -101,9 +95,9 @@ class KubernetesConfigurationController {
) {
await this.KubernetesConfigurationService.create(this.formValues);
await this.KubernetesConfigurationService.delete(this.configuration);
- this.Notifications.success('Success', `${kind} successfully updated`);
+ this.Notifications.success('Success', `Secret successfully updated`);
this.$state.go(
- 'kubernetes.configurations.configuration',
+ 'kubernetes.secrets.secret',
{
namespace: this.formValues.ResourcePool.Namespace.Name,
name: this.formValues.Name,
@@ -112,11 +106,11 @@ class KubernetesConfigurationController {
);
} else {
await this.KubernetesConfigurationService.update(this.formValues, this.configuration);
- this.Notifications.success('Success', `${kind} successfully updated`);
+ this.Notifications.success('Success', `Secret successfully updated`);
this.$state.reload(this.$state.current);
}
} catch (err) {
- this.Notifications.error('Failure', err, `Unable to update ${kind}`);
+ this.Notifications.error('Failure', err, `Unable to update Secret`);
} finally {
this.state.actionInProgress = false;
}
@@ -124,10 +118,11 @@ class KubernetesConfigurationController {
updateConfiguration() {
if (this.configuration.Used) {
- const plural = this.configuration.Applications.length > 1 ? 's' : '';
- const thisorthese = this.configuration.Applications.length > 1 ? 'these' : 'this';
confirmUpdate(
- `The changes will be propagated to ${this.configuration.Applications.length} running application${plural}. Are you sure you want to update ${thisorthese} ConfigMap${plural} or Secret${plural}?`,
+ `The changes will be propagated to ${this.configuration.Applications.length} running ${pluralize(
+ this.configuration.Applications.length,
+ 'application'
+ )}. Are you sure you want to update this Secret?`,
(confirmed) => {
if (confirmed) {
return this.$async(this.updateConfigurationAsync);
@@ -144,18 +139,15 @@ class KubernetesConfigurationController {
this.state.configurationLoading = true;
const name = this.$transition$.params().name;
const namespace = this.$transition$.params().namespace;
- const [configMap, secret] = await Promise.allSettled([this.KubernetesConfigMapService.get(namespace, name), this.KubernetesSecretService.get(namespace, name)]);
- if (secret.status === 'rejected' && secret.reason.err.status === 403) {
- this.$state.go('kubernetes.configurations');
- throw new Error('Not authorized to edit secret');
- }
- if (secret.status === 'fulfilled') {
- this.configuration = KubernetesConfigurationConverter.secretToConfiguration(secret.value);
- this.formValues.Data = secret.value.Data;
- // this.formValues.ServiceAccountName = secret.value.ServiceAccountName;
- } else {
- this.configuration = KubernetesConfigurationConverter.configMapToConfiguration(configMap.value);
- this.formValues.Data = configMap.value.Data;
+ try {
+ const secret = await this.KubernetesSecretService.get(namespace, name);
+ this.configuration = KubernetesConfigurationConverter.secretToConfiguration(secret);
+ this.formValues.Data = secret.Data;
+ } catch (err) {
+ if (err.status === 403) {
+ this.$state.go('kubernetes.configurations', { tab: 'secrets' });
+ throw new Error('Not authorized to edit secret');
+ }
}
this.formValues.ResourcePool = _.find(this.resourcePools, (resourcePool) => resourcePool.Namespace.Name === this.configuration.Namespace);
this.formValues.Id = this.configuration.Id;
@@ -166,7 +158,7 @@ class KubernetesConfigurationController {
return this.configuration;
} catch (err) {
- this.Notifications.error('Failure', err, 'Unable to retrieve configuration');
+ this.Notifications.error('Failure', err, 'Unable to retrieve secret');
} finally {
this.state.configurationLoading = false;
}
@@ -214,18 +206,6 @@ class KubernetesConfigurationController {
return this.$async(this.getEventsAsync, namespace);
}
- async getConfigurationsAsync() {
- try {
- this.configurations = await this.KubernetesConfigurationService.get();
- } catch (err) {
- this.Notifications.error('Failure', err, 'Unable to retrieve configurations');
- }
- }
-
- getConfigurations() {
- return this.$async(this.getConfigurationsAsync);
- }
-
tagUsedDataKeys() {
const configName = this.$transition$.params().name;
const usedDataKeys = _.uniq(
@@ -276,7 +256,6 @@ class KubernetesConfigurationController {
if (configuration) {
await this.getApplications(this.configuration.Namespace);
await this.getEvents(this.configuration.Namespace);
- await this.getConfigurations();
}
// after loading the configuration, check if it is a docker config secret type
@@ -325,5 +304,5 @@ class KubernetesConfigurationController {
}
}
-export default KubernetesConfigurationController;
-angular.module('portainer.kubernetes').controller('KubernetesConfigurationController', KubernetesConfigurationController);
+export default KubernetesSecretController;
+angular.module('portainer.kubernetes').controller('KubernetesSecretController', KubernetesSecretController);
diff --git a/app/kubernetes/views/deploy/deployController.js b/app/kubernetes/views/deploy/deployController.js
index 894c6d406..28414cd24 100644
--- a/app/kubernetes/views/deploy/deployController.js
+++ b/app/kubernetes/views/deploy/deployController.js
@@ -284,6 +284,11 @@ class KubernetesDeployController {
this.Notifications.success('Success', 'Request to deploy manifest successfully submitted');
this.state.isEditorDirty = false;
+ if (this.$state.params.referrer && this.$state.params.tab) {
+ this.$state.go(this.$state.params.referrer, { tab: this.$state.params.tab });
+ return;
+ }
+
if (this.$state.params.referrer) {
this.$state.go(this.$state.params.referrer);
return;
diff --git a/app/portainer/helpers/promise-utils.ts b/app/portainer/helpers/promise-utils.ts
index b71899f77..5fa7f672e 100644
--- a/app/portainer/helpers/promise-utils.ts
+++ b/app/portainer/helpers/promise-utils.ts
@@ -16,6 +16,12 @@ export function isFulfilled(
return result.status === 'fulfilled';
}
+export function isRejected(
+ result: PromiseSettledResult
+): result is PromiseRejectedResult {
+ return result.status === 'rejected';
+}
+
export function getFulfilledResults(
results: Array>
) {
diff --git a/app/portainer/react/components/index.ts b/app/portainer/react/components/index.ts
index af2306fd3..738232908 100644
--- a/app/portainer/react/components/index.ts
+++ b/app/portainer/react/components/index.ts
@@ -127,6 +127,7 @@ export const ngModule = angular
'type',
'value',
'to',
+ 'params',
'children',
'pluralType',
'isLoading',
diff --git a/app/portainer/services/axios.ts b/app/portainer/services/axios.ts
index 980d6534a..be2d9432c 100644
--- a/app/portainer/services/axios.ts
+++ b/app/portainer/services/axios.ts
@@ -49,6 +49,13 @@ export function agentInterceptor(config: AxiosRequestConfig) {
axios.interceptors.request.use(agentInterceptor);
+/**
+ * Parses an Axios error and returns a PortainerError.
+ * @param err The original error.
+ * @param msg An optional error message to prepend.
+ * @param parseError A function to parse AxiosErrors. Defaults to defaultErrorParser.
+ * @returns A PortainerError with the parsed error message and details.
+ */
export function parseAxiosError(
err: Error,
msg = '',
@@ -70,7 +77,7 @@ export function parseAxiosError(
return new PortainerError(resultMsg, resultErr);
}
-function defaultErrorParser(axiosError: AxiosError) {
+export function defaultErrorParser(axiosError: AxiosError) {
const message = axiosError.response?.data.message || '';
const details = axiosError.response?.data.details || message;
const error = new Error(message);
diff --git a/app/react/.keep b/app/react/.keep
deleted file mode 100644
index e69de29bb..000000000
diff --git a/app/react/components/Badge/Badge.tsx b/app/react/components/Badge/Badge.tsx
index 25ba2f36a..7fd36e435 100644
--- a/app/react/components/Badge/Badge.tsx
+++ b/app/react/components/Badge/Badge.tsx
@@ -22,7 +22,7 @@ export function Badge({ type, className, children }: PropsWithChildren) {
);
}
-// classes in full to prevent a dev server bug, where tailwind doesn't render the interpolated classes
+// the classes are typed in full to prevent a dev server bug, where tailwind doesn't render the interpolated classes
function getClasses(type: BadgeType | undefined) {
switch (type) {
case 'success':
diff --git a/app/react/components/DashboardItem/DashboardItem.tsx b/app/react/components/DashboardItem/DashboardItem.tsx
index bf2107b3c..630c3db1e 100644
--- a/app/react/components/DashboardItem/DashboardItem.tsx
+++ b/app/react/components/DashboardItem/DashboardItem.tsx
@@ -14,6 +14,7 @@ interface Props extends IconProps {
isRefetching?: boolean;
value?: number;
to?: string;
+ params?: object;
children?: ReactNode;
dataCy?: string;
}
@@ -26,6 +27,7 @@ export function DashboardItem({
isRefetching,
value,
to,
+ params,
children,
dataCy,
}: Props) {
@@ -41,7 +43,7 @@ export function DashboardItem({
>
@@ -50,7 +52,7 @@ export function DashboardItem({
@@ -101,7 +103,7 @@ export function DashboardItem({
if (to) {
return (
-
+
{Item}
);
diff --git a/app/react/components/Widget/WidgetTabs.tsx b/app/react/components/Widget/WidgetTabs.tsx
new file mode 100644
index 000000000..0ec0db4c5
--- /dev/null
+++ b/app/react/components/Widget/WidgetTabs.tsx
@@ -0,0 +1,66 @@
+import { RawParams } from '@uirouter/react';
+import clsx from 'clsx';
+import { ReactNode } from 'react';
+
+import { Icon } from '@@/Icon';
+import { Link } from '@@/Link';
+
+export interface Tab {
+ name: string;
+ icon: ReactNode;
+ widget: ReactNode;
+ selectedTabParam: string;
+}
+
+interface Props {
+ currentTabIndex: number;
+ tabs: Tab[];
+}
+
+export function WidgetTabs({ currentTabIndex, tabs }: Props) {
+ // ensure that the selectedTab param is always valid
+ const invalidQueryParamValue = tabs.every(
+ (tab) => encodeURIComponent(tab.selectedTabParam) !== tab.selectedTabParam
+ );
+
+ if (invalidQueryParamValue) {
+ throw new Error('Invalid query param value for tab');
+ }
+
+ return (
+
+
+
+ {tabs.map(({ name, icon }, index) => (
+
+
+ {name}
+
+ ))}
+
+
+
+ );
+}
+
+// findSelectedTabIndex returns the index of the tab, or 0 if none is selected
+export function findSelectedTabIndex(
+ { params }: { params: RawParams },
+ tabs: Tab[]
+) {
+ const selectedTabParam = params.tab || tabs[0].selectedTabParam;
+ const currentTabIndex = tabs.findIndex(
+ (tab) => tab.selectedTabParam === selectedTabParam
+ );
+ return currentTabIndex || 0;
+}
diff --git a/app/react/components/datatables/select-column.tsx b/app/react/components/datatables/select-column.tsx
index a036736c6..03afeebd2 100644
--- a/app/react/components/datatables/select-column.tsx
+++ b/app/react/components/datatables/select-column.tsx
@@ -13,6 +13,7 @@ export function createSelectColumn
(): ColumnDef {
checked={table.getIsAllRowsSelected()}
indeterminate={table.getIsSomeRowsSelected()}
onChange={table.getToggleAllRowsSelectedHandler()}
+ disabled={table.getRowModel().rows.every((row) => !row.getCanSelect())}
/>
),
cell: ({ row, table }) => (
@@ -21,6 +22,7 @@ export function createSelectColumn(): ColumnDef {
checked={row.getIsSelected()}
indeterminate={row.getIsSomeSelected()}
onChange={row.getToggleSelectedHandler()}
+ disabled={!row.getCanSelect()}
onClick={(e) => {
if (e.shiftKey) {
const { rows, rowsById } = table.getRowModel();
diff --git a/app/react/kubernetes/.keep b/app/react/kubernetes/.keep
deleted file mode 100644
index e69de29bb..000000000
diff --git a/app/react/kubernetes/applications/DetailsView/ApplicationDetailsWidget/ApplicationEnvVarsTable.tsx b/app/react/kubernetes/applications/DetailsView/ApplicationDetailsWidget/ApplicationEnvVarsTable.tsx
index d82e4c13a..a7fe06683 100644
--- a/app/react/kubernetes/applications/DetailsView/ApplicationDetailsWidget/ApplicationEnvVarsTable.tsx
+++ b/app/react/kubernetes/applications/DetailsView/ApplicationDetailsWidget/ApplicationEnvVarsTable.tsx
@@ -1,5 +1,5 @@
import { EnvVar, Pod } from 'kubernetes-types/core/v1';
-import { Asterisk, File, Key } from 'lucide-react';
+import { Asterisk, File, FileCode, Key, Lock } from 'lucide-react';
import { Icon } from '@@/Icon';
import { TextTip } from '@@/Tip/TextTip';
@@ -93,14 +93,14 @@ export function ApplicationEnvVarsTable({ namespace, app }: Props) {
{envVar.valueFrom?.configMapKeyRef && (
-
+
{envVar.valueFrom.configMapKeyRef.name}
@@ -108,14 +108,14 @@ export function ApplicationEnvVarsTable({ namespace, app }: Props) {
{envVar.valueFrom?.secretKeyRef && (
-
+
{envVar.valueFrom.secretKeyRef.name}
diff --git a/app/react/kubernetes/applications/application.queries.ts b/app/react/kubernetes/applications/application.queries.ts
index b0332c0a8..17dcbee37 100644
--- a/app/react/kubernetes/applications/application.queries.ts
+++ b/app/react/kubernetes/applications/application.queries.ts
@@ -4,7 +4,7 @@ import { Pod } from 'kubernetes-types/core/v1';
import { queryClient, withError } from '@/react-tools/react-query';
import { EnvironmentId } from '@/react/portainer/environments/types';
-import { getNamespaceServices } from '../ServicesView/service';
+import { getNamespaceServices } from '../services/service';
import {
getApplicationsForCluster,
@@ -112,10 +112,10 @@ export function useApplicationsForCluster(
) {
return useQuery(
queryKeys.applicationsForCluster(environemtId),
- () => namespaces && getApplicationsForCluster(environemtId, namespaces),
+ () => getApplicationsForCluster(environemtId, namespaces),
{
...withError('Unable to retrieve applications'),
- enabled: !!namespaces,
+ enabled: !!namespaces?.length,
}
);
}
diff --git a/app/react/kubernetes/applications/application.service.ts b/app/react/kubernetes/applications/application.service.ts
index 9757a9166..ac04f2d04 100644
--- a/app/react/kubernetes/applications/application.service.ts
+++ b/app/react/kubernetes/applications/application.service.ts
@@ -27,11 +27,14 @@ import { appRevisionAnnotation } from './constants';
export async function getApplicationsForCluster(
environmentId: EnvironmentId,
- namespaces: string[]
+ namespaceNames?: string[]
) {
try {
+ if (!namespaceNames) {
+ return [];
+ }
const applications = await Promise.all(
- namespaces.map((namespace) =>
+ namespaceNames.map((namespace) =>
getApplicationsForNamespace(environmentId, namespace)
)
);
@@ -74,7 +77,7 @@ async function getApplicationsForNamespace(
} catch (e) {
throw parseAxiosError(
e as Error,
- `Unable to retrieve applications in namespace ${namespace}`
+ `Unable to retrieve applications in namespace '${namespace}'`
);
}
}
@@ -145,7 +148,7 @@ export async function getApplication(
} catch (e) {
throw parseAxiosError(
e as Error,
- `Unable to retrieve application ${name} in namespace ${namespace}`
+ `Unable to retrieve application ${name} in namespace '${namespace}'`
);
}
}
@@ -193,7 +196,7 @@ export async function patchApplication(
} catch (e) {
throw parseAxiosError(
e as Error,
- `Unable to patch application ${name} in namespace ${namespace}`
+ `Unable to patch application ${name} in namespace '${namespace}'`
);
}
}
diff --git a/app/react/kubernetes/axiosError.ts b/app/react/kubernetes/axiosError.ts
new file mode 100644
index 000000000..1c49606a8
--- /dev/null
+++ b/app/react/kubernetes/axiosError.ts
@@ -0,0 +1,29 @@
+import { Status } from 'kubernetes-types/meta/v1';
+import { AxiosError } from 'axios';
+
+import {
+ defaultErrorParser,
+ parseAxiosError,
+} from '@/portainer/services/axios';
+
+export function kubernetesErrorParser(axiosError: AxiosError) {
+ const responseStatus = axiosError.response?.data as Status;
+ const { message } = responseStatus;
+ if (message) {
+ return {
+ error: new Error(message),
+ details: message,
+ };
+ }
+ return defaultErrorParser(axiosError);
+}
+
+/**
+ * Parses an Axios error response from the Kubernetes API.
+ * @param err The Axios error object.
+ * @param msg An optional error message to prepend.
+ * @returns An error object with an error message and details.
+ */
+export function parseKubernetesAxiosError(err: Error, msg = '') {
+ return parseAxiosError(err, msg, kubernetesErrorParser);
+}
diff --git a/app/react/kubernetes/cluster/ingressClass/ingressClass.service.ts b/app/react/kubernetes/cluster/ingressClass/ingressClass.service.ts
index b39f793d7..08ed26d89 100644
--- a/app/react/kubernetes/cluster/ingressClass/ingressClass.service.ts
+++ b/app/react/kubernetes/cluster/ingressClass/ingressClass.service.ts
@@ -1,17 +1,13 @@
+import { IngressClassList } from 'kubernetes-types/networking/v1';
+
import axios, { parseAxiosError } from '@/portainer/services/axios';
import { EnvironmentId } from '@/react/portainer/environments/types';
-import {
- KubernetesApiListResponse,
- V1IngressClass,
-} from '@/react/kubernetes/services/types';
export async function getAllIngressClasses(environmentId: EnvironmentId) {
try {
const {
data: { items },
- } = await axios.get>(
- urlBuilder(environmentId)
- );
+ } = await axios.get(urlBuilder(environmentId));
return items;
} catch (error) {
throw parseAxiosError(error as Error);
diff --git a/app/react/kubernetes/configs/.keep b/app/react/kubernetes/configs/.keep
deleted file mode 100644
index e69de29bb..000000000
diff --git a/app/react/kubernetes/configs/ListView/.keep b/app/react/kubernetes/configs/ListView/.keep
deleted file mode 100644
index e69de29bb..000000000
diff --git a/app/react/kubernetes/configs/ListView/ConfigMapsDatatable/ConfigMapsDatatable.tsx b/app/react/kubernetes/configs/ListView/ConfigMapsDatatable/ConfigMapsDatatable.tsx
new file mode 100644
index 000000000..e38fd3ec8
--- /dev/null
+++ b/app/react/kubernetes/configs/ListView/ConfigMapsDatatable/ConfigMapsDatatable.tsx
@@ -0,0 +1,196 @@
+import { useMemo } from 'react';
+import { FileCode, Plus, Trash2 } from 'lucide-react';
+import { ConfigMap } from 'kubernetes-types/core/v1';
+
+import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
+import {
+ Authorized,
+ useAuthorizations,
+ useCurrentUser,
+} from '@/react/hooks/useUser';
+import { useNamespaces } from '@/react/kubernetes/namespaces/queries';
+import { DefaultDatatableSettings } from '@/react/kubernetes/datatables/DefaultDatatableSettings';
+import { createStore } from '@/react/kubernetes/datatables/default-kube-datatable-store';
+import { isSystemNamespace } from '@/react/kubernetes/namespaces/utils';
+import { SystemResourceDescription } from '@/react/kubernetes/datatables/SystemResourceDescription';
+import { useApplicationsForCluster } from '@/react/kubernetes/applications/application.queries';
+import { Application } from '@/react/kubernetes/applications/types';
+import { pluralize } from '@/portainer/helpers/strings';
+
+import { Datatable, TableSettingsMenu } from '@@/datatables';
+import { confirmDelete } from '@@/modals/confirm';
+import { Button } from '@@/buttons';
+import { Link } from '@@/Link';
+import { useTableState } from '@@/datatables/useTableState';
+
+import {
+ useConfigMapsForCluster,
+ useMutationDeleteConfigMaps,
+} from '../../configmap.service';
+import { IndexOptional } from '../../types';
+
+import { getIsConfigMapInUse } from './utils';
+import { ConfigMapRowData } from './types';
+import { columns } from './columns';
+
+const storageKey = 'k8sConfigMapsDatatable';
+const settingsStore = createStore(storageKey);
+
+export function ConfigMapsDatatable() {
+ const tableState = useTableState(settingsStore, storageKey);
+ const readOnly = !useAuthorizations(['K8sConfigMapsW']);
+ const { isAdmin } = useCurrentUser();
+
+ const environmentId = useEnvironmentId();
+ const { data: namespaces, ...namespacesQuery } = useNamespaces(
+ environmentId,
+ {
+ autoRefreshRate: tableState.autoRefreshRate * 1000,
+ }
+ );
+ const namespaceNames = Object.keys(namespaces || {});
+ const { data: configMaps, ...configMapsQuery } = useConfigMapsForCluster(
+ environmentId,
+ namespaceNames,
+ {
+ autoRefreshRate: tableState.autoRefreshRate * 1000,
+ }
+ );
+ const { data: applications, ...applicationsQuery } =
+ useApplicationsForCluster(environmentId, namespaceNames);
+
+ const filteredConfigMaps = useMemo(
+ () =>
+ configMaps?.filter(
+ (configMap) =>
+ (isAdmin && tableState.showSystemResources) ||
+ !isSystemNamespace(configMap.metadata?.namespace ?? '')
+ ) || [],
+ [configMaps, tableState, isAdmin]
+ );
+ const configMapRowData = useConfigMapRowData(
+ filteredConfigMaps,
+ applications ?? [],
+ applicationsQuery.isLoading
+ );
+
+ return (
+ >
+ dataset={configMapRowData}
+ columns={columns}
+ settingsManager={tableState}
+ isLoading={configMapsQuery.isLoading || namespacesQuery.isLoading}
+ emptyContentLabel="No ConfigMaps found"
+ title="ConfigMaps"
+ titleIcon={FileCode}
+ getRowId={(row) => row.metadata?.uid ?? ''}
+ isRowSelectable={(row) =>
+ !isSystemNamespace(row.original.metadata?.namespace ?? '')
+ }
+ disableSelect={readOnly}
+ renderTableActions={(selectedRows) => (
+
+ )}
+ renderTableSettings={() => (
+
+
+
+ )}
+ description={
+
+ }
+ />
+ );
+}
+
+// useConfigMapRowData appends the `inUse` property to the ConfigMap data (for the unused badge in the name column)
+// and wraps with useMemo to prevent unnecessary calculations
+function useConfigMapRowData(
+ configMaps: ConfigMap[],
+ applications: Application[],
+ applicationsLoading: boolean
+): ConfigMapRowData[] {
+ return useMemo(
+ () =>
+ configMaps.map((configMap) => ({
+ ...configMap,
+ inUse:
+ // if the apps are loading, set inUse to true to hide the 'unused' badge
+ applicationsLoading || getIsConfigMapInUse(configMap, applications),
+ })),
+ [configMaps, applicationsLoading, applications]
+ );
+}
+
+function TableActions({
+ selectedItems,
+}: {
+ selectedItems: ConfigMapRowData[];
+}) {
+ const environmentId = useEnvironmentId();
+ const deleteConfigMapMutation = useMutationDeleteConfigMaps(environmentId);
+
+ async function handleRemoveClick(configMaps: ConfigMap[]) {
+ const confirmed = await confirmDelete(
+ `Are you sure you want to remove the selected ${pluralize(
+ configMaps.length,
+ 'ConfigMap'
+ )}?`
+ );
+ if (!confirmed) {
+ return;
+ }
+
+ const configMapsToDelete = configMaps.map((configMap) => ({
+ namespace: configMap.metadata?.namespace ?? '',
+ name: configMap.metadata?.name ?? '',
+ }));
+
+ await deleteConfigMapMutation.mutateAsync(configMapsToDelete);
+ }
+
+ return (
+
+ {
+ handleRemoveClick(selectedItems);
+ }}
+ icon={Trash2}
+ data-cy="k8sConfig-removeConfigButton"
+ >
+ Remove
+
+
+
+ Add with form
+
+
+
+
+ Create from manifest
+
+
+
+ );
+}
diff --git a/app/react/kubernetes/configs/ListView/ConfigMapsDatatable/columns/created.tsx b/app/react/kubernetes/configs/ListView/ConfigMapsDatatable/columns/created.tsx
new file mode 100644
index 000000000..02ec353d0
--- /dev/null
+++ b/app/react/kubernetes/configs/ListView/ConfigMapsDatatable/columns/created.tsx
@@ -0,0 +1,18 @@
+import { formatDate } from '@/portainer/filters/filters';
+
+import { ConfigMapRowData } from '../types';
+
+import { columnHelper } from './helper';
+
+export const created = columnHelper.accessor((row) => getCreatedAtText(row), {
+ header: 'Created',
+ id: 'created',
+ cell: ({ row }) => getCreatedAtText(row.original),
+});
+
+function getCreatedAtText(row: ConfigMapRowData) {
+ const owner =
+ row.metadata?.labels?.['io.portainer.kubernetes.configuration.owner'];
+ const date = formatDate(row.metadata?.creationTimestamp);
+ return owner ? `${date} by ${owner}` : date;
+}
diff --git a/app/react/kubernetes/configs/ListView/ConfigMapsDatatable/columns/helper.ts b/app/react/kubernetes/configs/ListView/ConfigMapsDatatable/columns/helper.ts
new file mode 100644
index 000000000..4298845ed
--- /dev/null
+++ b/app/react/kubernetes/configs/ListView/ConfigMapsDatatable/columns/helper.ts
@@ -0,0 +1,5 @@
+import { createColumnHelper } from '@tanstack/react-table';
+
+import { ConfigMapRowData } from '../types';
+
+export const columnHelper = createColumnHelper();
diff --git a/app/react/kubernetes/configs/ListView/ConfigMapsDatatable/columns/index.ts b/app/react/kubernetes/configs/ListView/ConfigMapsDatatable/columns/index.ts
new file mode 100644
index 000000000..006a506ec
--- /dev/null
+++ b/app/react/kubernetes/configs/ListView/ConfigMapsDatatable/columns/index.ts
@@ -0,0 +1,5 @@
+import { name } from './name';
+import { namespace } from './namespace';
+import { created } from './created';
+
+export const columns = [name, namespace, created];
diff --git a/app/react/kubernetes/configs/ListView/ConfigMapsDatatable/columns/name.tsx b/app/react/kubernetes/configs/ListView/ConfigMapsDatatable/columns/name.tsx
new file mode 100644
index 000000000..09b414403
--- /dev/null
+++ b/app/react/kubernetes/configs/ListView/ConfigMapsDatatable/columns/name.tsx
@@ -0,0 +1,80 @@
+import { CellContext } from '@tanstack/react-table';
+
+import { isSystemNamespace } from '@/react/kubernetes/namespaces/utils';
+import { Authorized } from '@/react/hooks/useUser';
+
+import { Link } from '@@/Link';
+import { Badge } from '@@/Badge';
+
+import { ConfigMapRowData } from '../types';
+
+import { columnHelper } from './helper';
+
+export const name = columnHelper.accessor(
+ (row) => {
+ const name = row.metadata?.name;
+ const namespace = row.metadata?.namespace;
+
+ const isSystemToken = name?.includes('default-token-');
+ const isInSystemNamespace = namespace
+ ? isSystemNamespace(namespace)
+ : false;
+ const isSystemConfigMap = isSystemToken || isInSystemNamespace;
+
+ const hasConfigurationOwner =
+ !!row.metadata?.labels?.['io.portainer.kubernetes.configuration.owner'];
+ return `${name} ${isSystemConfigMap ? 'system' : ''} ${
+ !isSystemToken && !hasConfigurationOwner ? 'external' : ''
+ } ${!row.inUse && !isSystemConfigMap ? 'unused' : ''}`;
+ },
+ {
+ header: 'Name',
+ cell: Cell,
+ id: 'name',
+ }
+);
+
+function Cell({ row }: CellContext) {
+ const name = row.original.metadata?.name;
+ const namespace = row.original.metadata?.namespace;
+
+ const isSystemToken = name?.includes('default-token-');
+ const isInSystemNamespace = namespace ? isSystemNamespace(namespace) : false;
+ const isSystemConfigMap = isSystemToken || isInSystemNamespace;
+
+ const hasConfigurationOwner =
+ !!row.original.metadata?.labels?.[
+ 'io.portainer.kubernetes.configuration.owner'
+ ];
+
+ return (
+
+
+
+ {name}
+
+ {isSystemConfigMap && (
+
+ system
+
+ )}
+ {!isSystemToken && !hasConfigurationOwner && (
+ external
+ )}
+ {!row.original.inUse && !isSystemConfigMap && (
+
+ unused
+
+ )}
+
+
+ );
+}
diff --git a/app/react/kubernetes/configs/ListView/ConfigMapsDatatable/columns/namespace.tsx b/app/react/kubernetes/configs/ListView/ConfigMapsDatatable/columns/namespace.tsx
new file mode 100644
index 000000000..cad3737e5
--- /dev/null
+++ b/app/react/kubernetes/configs/ListView/ConfigMapsDatatable/columns/namespace.tsx
@@ -0,0 +1,43 @@
+import { Row } from '@tanstack/react-table';
+
+import { filterHOC } from '@/react/components/datatables/Filter';
+
+import { Link } from '@@/Link';
+
+import { ConfigMapRowData } from '../types';
+
+import { columnHelper } from './helper';
+
+export const namespace = columnHelper.accessor(
+ (row) => row.metadata?.namespace,
+ {
+ header: 'Namespace',
+ id: 'namespace',
+ cell: ({ getValue }) => {
+ const namespace = getValue();
+
+ return (
+
+ {namespace}
+
+ );
+ },
+ meta: {
+ filter: filterHOC('Filter by namespace'),
+ },
+ enableColumnFilter: true,
+ filterFn: (
+ row: Row,
+ _columnId: string,
+ filterValue: string[]
+ ) =>
+ filterValue.length === 0 ||
+ filterValue.includes(row.original.metadata?.namespace ?? ''),
+ }
+);
diff --git a/app/react/kubernetes/configs/ListView/ConfigMapsDatatable/index.ts b/app/react/kubernetes/configs/ListView/ConfigMapsDatatable/index.ts
new file mode 100644
index 000000000..ca1379077
--- /dev/null
+++ b/app/react/kubernetes/configs/ListView/ConfigMapsDatatable/index.ts
@@ -0,0 +1 @@
+export { ConfigMapsDatatable } from './ConfigMapsDatatable';
diff --git a/app/react/kubernetes/configs/ListView/ConfigMapsDatatable/types.ts b/app/react/kubernetes/configs/ListView/ConfigMapsDatatable/types.ts
new file mode 100644
index 000000000..81eabea3e
--- /dev/null
+++ b/app/react/kubernetes/configs/ListView/ConfigMapsDatatable/types.ts
@@ -0,0 +1,5 @@
+import { ConfigMap } from 'kubernetes-types/core/v1';
+
+export interface ConfigMapRowData extends ConfigMap {
+ inUse: boolean;
+}
diff --git a/app/react/kubernetes/configs/ListView/ConfigMapsDatatable/utils.ts b/app/react/kubernetes/configs/ListView/ConfigMapsDatatable/utils.ts
new file mode 100644
index 000000000..844c3f38c
--- /dev/null
+++ b/app/react/kubernetes/configs/ListView/ConfigMapsDatatable/utils.ts
@@ -0,0 +1,29 @@
+import { ConfigMap, Pod } from 'kubernetes-types/core/v1';
+
+import { Application } from '@/react/kubernetes/applications/types';
+import { applicationIsKind } from '@/react/kubernetes/applications/utils';
+
+// getIsConfigMapInUse returns true if the configmap is referenced by any
+// application in the cluster
+export function getIsConfigMapInUse(
+ configMap: ConfigMap,
+ applications: Application[]
+) {
+ return applications.some((app) => {
+ const appSpec = applicationIsKind('Pod', app)
+ ? app?.spec
+ : app?.spec?.template?.spec;
+
+ const hasEnvVarReference = appSpec?.containers.some((container) =>
+ container.env?.some(
+ (envVar) =>
+ envVar.valueFrom?.configMapKeyRef?.name === configMap.metadata?.name
+ )
+ );
+ const hasVolumeReference = appSpec?.volumes?.some(
+ (volume) => volume.configMap?.name === configMap.metadata?.name
+ );
+
+ return hasEnvVarReference || hasVolumeReference;
+ });
+}
diff --git a/app/react/kubernetes/configs/ListView/ConfigmapsAndSecretsView.tsx b/app/react/kubernetes/configs/ListView/ConfigmapsAndSecretsView.tsx
new file mode 100644
index 000000000..e93c1d060
--- /dev/null
+++ b/app/react/kubernetes/configs/ListView/ConfigmapsAndSecretsView.tsx
@@ -0,0 +1,43 @@
+import { FileCode, Lock } from 'lucide-react';
+import { useCurrentStateAndParams } from '@uirouter/react';
+
+import { PageHeader } from '@@/PageHeader';
+import { Tab, WidgetTabs, findSelectedTabIndex } from '@@/Widget/WidgetTabs';
+
+import { ConfigMapsDatatable } from './ConfigMapsDatatable';
+import { SecretsDatatable } from './SecretsDatatable';
+
+const tabs: Tab[] = [
+ {
+ name: 'ConfigMaps',
+ icon: FileCode,
+ widget: ,
+ selectedTabParam: 'configmaps',
+ },
+ {
+ name: 'Secrets',
+ icon: Lock,
+ widget: ,
+ selectedTabParam: 'secrets',
+ },
+];
+
+export function ConfigmapsAndSecretsView() {
+ const currentTabIndex = findSelectedTabIndex(
+ useCurrentStateAndParams(),
+ tabs
+ );
+ return (
+ <>
+
+ <>
+
+ {tabs[currentTabIndex].widget}
+ >
+ >
+ );
+}
diff --git a/app/react/kubernetes/configs/ListView/SecretsDatatable/SecretsDatatable.tsx b/app/react/kubernetes/configs/ListView/SecretsDatatable/SecretsDatatable.tsx
new file mode 100644
index 000000000..fd7ff79fd
--- /dev/null
+++ b/app/react/kubernetes/configs/ListView/SecretsDatatable/SecretsDatatable.tsx
@@ -0,0 +1,192 @@
+import { useMemo } from 'react';
+import { Lock, Plus, Trash2 } from 'lucide-react';
+import { Secret } from 'kubernetes-types/core/v1';
+
+import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
+import {
+ Authorized,
+ useAuthorizations,
+ useCurrentUser,
+} from '@/react/hooks/useUser';
+import { useNamespaces } from '@/react/kubernetes/namespaces/queries';
+import { DefaultDatatableSettings } from '@/react/kubernetes/datatables/DefaultDatatableSettings';
+import { createStore } from '@/react/kubernetes/datatables/default-kube-datatable-store';
+import { isSystemNamespace } from '@/react/kubernetes/namespaces/utils';
+import { SystemResourceDescription } from '@/react/kubernetes/datatables/SystemResourceDescription';
+import { useApplicationsForCluster } from '@/react/kubernetes/applications/application.queries';
+import { Application } from '@/react/kubernetes/applications/types';
+import { pluralize } from '@/portainer/helpers/strings';
+
+import { Datatable, TableSettingsMenu } from '@@/datatables';
+import { confirmDelete } from '@@/modals/confirm';
+import { Button } from '@@/buttons';
+import { Link } from '@@/Link';
+import { useTableState } from '@@/datatables/useTableState';
+
+import {
+ useSecretsForCluster,
+ useMutationDeleteSecrets,
+} from '../../secret.service';
+import { IndexOptional } from '../../types';
+
+import { getIsSecretInUse } from './utils';
+import { SecretRowData } from './types';
+import { columns } from './columns';
+
+const storageKey = 'k8sSecretsDatatable';
+const settingsStore = createStore(storageKey);
+
+export function SecretsDatatable() {
+ const tableState = useTableState(settingsStore, storageKey);
+ const readOnly = !useAuthorizations(['K8sSecretsW']);
+ const { isAdmin } = useCurrentUser();
+
+ const environmentId = useEnvironmentId();
+ const { data: namespaces, ...namespacesQuery } = useNamespaces(
+ environmentId,
+ {
+ autoRefreshRate: tableState.autoRefreshRate * 1000,
+ }
+ );
+ const namespaceNames = Object.keys(namespaces || {});
+ const { data: secrets, ...secretsQuery } = useSecretsForCluster(
+ environmentId,
+ namespaceNames,
+ {
+ autoRefreshRate: tableState.autoRefreshRate * 1000,
+ }
+ );
+ const { data: applications, ...applicationsQuery } =
+ useApplicationsForCluster(environmentId, namespaceNames);
+
+ const filteredSecrets = useMemo(
+ () =>
+ secrets?.filter(
+ (secret) =>
+ (isAdmin && tableState.showSystemResources) ||
+ !isSystemNamespace(secret.metadata?.namespace ?? '')
+ ) || [],
+ [secrets, tableState, isAdmin]
+ );
+ const secretRowData = useSecretRowData(
+ filteredSecrets,
+ applications ?? [],
+ applicationsQuery.isLoading
+ );
+
+ return (
+ >
+ dataset={secretRowData}
+ columns={columns}
+ settingsManager={tableState}
+ isLoading={secretsQuery.isLoading || namespacesQuery.isLoading}
+ emptyContentLabel="No secrets found"
+ title="Secrets"
+ titleIcon={Lock}
+ getRowId={(row) => row.metadata?.uid ?? ''}
+ isRowSelectable={(row) =>
+ !isSystemNamespace(row.original.metadata?.namespace ?? '')
+ }
+ disableSelect={readOnly}
+ renderTableActions={(selectedRows) => (
+
+ )}
+ renderTableSettings={() => (
+
+
+
+ )}
+ description={
+
+ }
+ />
+ );
+}
+
+// useSecretRowData appends the `inUse` property to the secret data (for the unused badge in the name column)
+// and wraps with useMemo to prevent unnecessary calculations
+function useSecretRowData(
+ secrets: Secret[],
+ applications: Application[],
+ applicationsLoading: boolean
+): SecretRowData[] {
+ return useMemo(
+ () =>
+ secrets.map((secret) => ({
+ ...secret,
+ inUse:
+ // if the apps are loading, set inUse to true to hide the 'unused' badge
+ applicationsLoading || getIsSecretInUse(secret, applications),
+ })),
+ [secrets, applicationsLoading, applications]
+ );
+}
+
+function TableActions({ selectedItems }: { selectedItems: SecretRowData[] }) {
+ const environmentId = useEnvironmentId();
+ const deleteSecretMutation = useMutationDeleteSecrets(environmentId);
+
+ async function handleRemoveClick(secrets: SecretRowData[]) {
+ const confirmed = await confirmDelete(
+ `Are you sure you want to remove the selected ${pluralize(
+ secrets.length,
+ 'secret'
+ )}?`
+ );
+ if (!confirmed) {
+ return;
+ }
+
+ const secretsToDelete = secrets.map((secret) => ({
+ namespace: secret.metadata?.namespace ?? '',
+ name: secret.metadata?.name ?? '',
+ }));
+
+ await deleteSecretMutation.mutateAsync(secretsToDelete);
+ }
+
+ return (
+
+ {
+ handleRemoveClick(selectedItems);
+ }}
+ icon={Trash2}
+ data-cy="k8sSecret-removeSecretButton"
+ >
+ Remove
+
+
+
+ Add with form
+
+
+
+
+ Create from manifest
+
+
+
+ );
+}
diff --git a/app/react/kubernetes/configs/ListView/SecretsDatatable/columns/created.tsx b/app/react/kubernetes/configs/ListView/SecretsDatatable/columns/created.tsx
new file mode 100644
index 000000000..df27985b9
--- /dev/null
+++ b/app/react/kubernetes/configs/ListView/SecretsDatatable/columns/created.tsx
@@ -0,0 +1,18 @@
+import { formatDate } from '@/portainer/filters/filters';
+
+import { SecretRowData } from '../types';
+
+import { columnHelper } from './helper';
+
+export const created = columnHelper.accessor((row) => getCreatedAtText(row), {
+ header: 'Created',
+ id: 'created',
+ cell: ({ row }) => getCreatedAtText(row.original),
+});
+
+function getCreatedAtText(row: SecretRowData) {
+ const owner =
+ row.metadata?.labels?.['io.portainer.kubernetes.configuration.owner'];
+ const date = formatDate(row.metadata?.creationTimestamp);
+ return owner ? `${date} by ${owner}` : date;
+}
diff --git a/app/react/kubernetes/configs/ListView/SecretsDatatable/columns/helper.ts b/app/react/kubernetes/configs/ListView/SecretsDatatable/columns/helper.ts
new file mode 100644
index 000000000..6b59bd73b
--- /dev/null
+++ b/app/react/kubernetes/configs/ListView/SecretsDatatable/columns/helper.ts
@@ -0,0 +1,5 @@
+import { createColumnHelper } from '@tanstack/react-table';
+
+import { SecretRowData } from '../types';
+
+export const columnHelper = createColumnHelper();
diff --git a/app/react/kubernetes/configs/ListView/SecretsDatatable/columns/index.ts b/app/react/kubernetes/configs/ListView/SecretsDatatable/columns/index.ts
new file mode 100644
index 000000000..006a506ec
--- /dev/null
+++ b/app/react/kubernetes/configs/ListView/SecretsDatatable/columns/index.ts
@@ -0,0 +1,5 @@
+import { name } from './name';
+import { namespace } from './namespace';
+import { created } from './created';
+
+export const columns = [name, namespace, created];
diff --git a/app/react/kubernetes/configs/ListView/SecretsDatatable/columns/name.tsx b/app/react/kubernetes/configs/ListView/SecretsDatatable/columns/name.tsx
new file mode 100644
index 000000000..9a03fd57e
--- /dev/null
+++ b/app/react/kubernetes/configs/ListView/SecretsDatatable/columns/name.tsx
@@ -0,0 +1,83 @@
+import { CellContext } from '@tanstack/react-table';
+
+import { isSystemNamespace } from '@/react/kubernetes/namespaces/utils';
+import { Authorized } from '@/react/hooks/useUser';
+
+import { Link } from '@@/Link';
+import { Badge } from '@@/Badge';
+
+import { SecretRowData } from '../types';
+
+import { columnHelper } from './helper';
+
+export const name = columnHelper.accessor(
+ (row) => {
+ const name = row.metadata?.name;
+ const namespace = row.metadata?.namespace;
+
+ const isSystemToken = name?.includes('default-token-');
+ const isInSystemNamespace = namespace
+ ? isSystemNamespace(namespace)
+ : false;
+ const isRegistrySecret =
+ row.metadata?.annotations?.['portainer.io/registry.id'];
+ const isSystemSecret =
+ isSystemToken || isInSystemNamespace || isRegistrySecret;
+
+ const hasConfigurationOwner =
+ !!row.metadata?.labels?.['io.portainer.kubernetes.configuration.owner'];
+ return `${name} ${isSystemSecret ? 'system' : ''} ${
+ !isSystemToken && !hasConfigurationOwner ? 'external' : ''
+ } ${!row.inUse && !isSystemSecret ? 'unused' : ''}`;
+ },
+ {
+ header: 'Name',
+ cell: Cell,
+ id: 'name',
+ }
+);
+
+function Cell({ row }: CellContext) {
+ const name = row.original.metadata?.name;
+ const namespace = row.original.metadata?.namespace;
+
+ const isSystemToken = name?.includes('default-token-');
+ const isInSystemNamespace = namespace ? isSystemNamespace(namespace) : false;
+ const isSystemSecret = isSystemToken || isInSystemNamespace;
+
+ const hasConfigurationOwner =
+ !!row.original.metadata?.labels?.[
+ 'io.portainer.kubernetes.configuration.owner'
+ ];
+
+ return (
+
+
+
+ {name}
+
+ {isSystemSecret && (
+
+ system
+
+ )}
+ {!isSystemToken && !hasConfigurationOwner && (
+ external
+ )}
+ {!row.original.inUse && !isSystemSecret && (
+
+ unused
+
+ )}
+
+
+ );
+}
diff --git a/app/react/kubernetes/configs/ListView/SecretsDatatable/columns/namespace.tsx b/app/react/kubernetes/configs/ListView/SecretsDatatable/columns/namespace.tsx
new file mode 100644
index 000000000..eabe7d5ba
--- /dev/null
+++ b/app/react/kubernetes/configs/ListView/SecretsDatatable/columns/namespace.tsx
@@ -0,0 +1,43 @@
+import { Row } from '@tanstack/react-table';
+
+import { filterHOC } from '@/react/components/datatables/Filter';
+
+import { Link } from '@@/Link';
+
+import { SecretRowData } from '../types';
+
+import { columnHelper } from './helper';
+
+export const namespace = columnHelper.accessor(
+ (row) => row.metadata?.namespace,
+ {
+ header: 'Namespace',
+ id: 'namespace',
+ cell: ({ getValue }) => {
+ const namespace = getValue();
+
+ return (
+
+ {namespace}
+
+ );
+ },
+ meta: {
+ filter: filterHOC('Filter by namespace'),
+ },
+ enableColumnFilter: true,
+ filterFn: (
+ row: Row,
+ _columnId: string,
+ filterValue: string[]
+ ) =>
+ filterValue.length === 0 ||
+ filterValue.includes(row.original.metadata?.namespace ?? ''),
+ }
+);
diff --git a/app/react/kubernetes/configs/ListView/SecretsDatatable/index.ts b/app/react/kubernetes/configs/ListView/SecretsDatatable/index.ts
new file mode 100644
index 000000000..639c677ac
--- /dev/null
+++ b/app/react/kubernetes/configs/ListView/SecretsDatatable/index.ts
@@ -0,0 +1 @@
+export { SecretsDatatable } from './SecretsDatatable';
diff --git a/app/react/kubernetes/configs/ListView/SecretsDatatable/types.ts b/app/react/kubernetes/configs/ListView/SecretsDatatable/types.ts
new file mode 100644
index 000000000..cedb9013d
--- /dev/null
+++ b/app/react/kubernetes/configs/ListView/SecretsDatatable/types.ts
@@ -0,0 +1,5 @@
+import { Secret } from 'kubernetes-types/core/v1';
+
+export interface SecretRowData extends Secret {
+ inUse: boolean;
+}
diff --git a/app/react/kubernetes/configs/ListView/SecretsDatatable/utils.ts b/app/react/kubernetes/configs/ListView/SecretsDatatable/utils.ts
new file mode 100644
index 000000000..ca4546b52
--- /dev/null
+++ b/app/react/kubernetes/configs/ListView/SecretsDatatable/utils.ts
@@ -0,0 +1,26 @@
+import { Secret, Pod } from 'kubernetes-types/core/v1';
+
+import { Application } from '@/react/kubernetes/applications/types';
+import { applicationIsKind } from '@/react/kubernetes/applications/utils';
+
+// getIsSecretInUse returns true if the secret is referenced by any
+// application in the cluster
+export function getIsSecretInUse(secret: Secret, applications: Application[]) {
+ return applications.some((app) => {
+ const appSpec = applicationIsKind('Pod', app)
+ ? app?.spec
+ : app?.spec?.template?.spec;
+
+ const hasEnvVarReference = appSpec?.containers.some((container) =>
+ container.env?.some(
+ (envVar) =>
+ envVar.valueFrom?.secretKeyRef?.name === secret.metadata?.name
+ )
+ );
+ const hasVolumeReference = appSpec?.volumes?.some(
+ (volume) => volume.secret?.secretName === secret.metadata?.name
+ );
+
+ return hasEnvVarReference || hasVolumeReference;
+ });
+}
diff --git a/app/react/kubernetes/configs/ListView/index.ts b/app/react/kubernetes/configs/ListView/index.ts
new file mode 100644
index 000000000..53b741318
--- /dev/null
+++ b/app/react/kubernetes/configs/ListView/index.ts
@@ -0,0 +1 @@
+export { ConfigmapsAndSecretsView } from './ConfigmapsAndSecretsView';
diff --git a/app/react/kubernetes/configs/configmap.service.ts b/app/react/kubernetes/configs/configmap.service.ts
new file mode 100644
index 000000000..5f8082899
--- /dev/null
+++ b/app/react/kubernetes/configs/configmap.service.ts
@@ -0,0 +1,163 @@
+import { ConfigMapList } from 'kubernetes-types/core/v1';
+import { useMutation, useQuery } from 'react-query';
+
+import { queryClient, withError } from '@/react-tools/react-query';
+import axios from '@/portainer/services/axios';
+import { EnvironmentId } from '@/react/portainer/environments/types';
+import {
+ error as notifyError,
+ notifySuccess,
+} from '@/portainer/services/notifications';
+import { isFulfilled, isRejected } from '@/portainer/helpers/promise-utils';
+
+import { parseKubernetesAxiosError } from '../axiosError';
+
+export const configMapQueryKeys = {
+ configMaps: (environmentId: EnvironmentId, namespace?: string) => [
+ 'environments',
+ environmentId,
+ 'kubernetes',
+ 'configmaps',
+ 'namespaces',
+ namespace,
+ ],
+ configMapsForCluster: (environmentId: EnvironmentId) => [
+ 'environments',
+ environmentId,
+ 'kubernetes',
+ 'configmaps',
+ ],
+};
+
+// returns a usequery hook for the list of configmaps from the kubernetes API
+export function useConfigMaps(
+ environmentId: EnvironmentId,
+ namespace?: string
+) {
+ return useQuery(
+ configMapQueryKeys.configMaps(environmentId, namespace),
+ () => (namespace ? getConfigMaps(environmentId, namespace) : []),
+ {
+ onError: (err) => {
+ notifyError(
+ 'Failure',
+ err as Error,
+ `Unable to get ConfigMaps in namespace '${namespace}'`
+ );
+ },
+ enabled: !!namespace,
+ }
+ );
+}
+
+export function useConfigMapsForCluster(
+ environmentId: EnvironmentId,
+ namespaces?: string[],
+ options?: { autoRefreshRate?: number }
+) {
+ return useQuery(
+ configMapQueryKeys.configMapsForCluster(environmentId),
+ () => namespaces && getConfigMapsForCluster(environmentId, namespaces),
+ {
+ ...withError('Unable to retrieve ConfigMaps for cluster'),
+ enabled: !!namespaces?.length,
+ refetchInterval() {
+ return options?.autoRefreshRate ?? false;
+ },
+ }
+ );
+}
+
+export function useMutationDeleteConfigMaps(environmentId: EnvironmentId) {
+ return useMutation(
+ async (configMaps: { namespace: string; name: string }[]) => {
+ const promises = await Promise.allSettled(
+ configMaps.map(({ namespace, name }) =>
+ deleteConfigMap(environmentId, namespace, name)
+ )
+ );
+ const successfulConfigMaps = promises
+ .filter(isFulfilled)
+ .map((_, index) => configMaps[index].name);
+ const failedConfigMaps = promises
+ .filter(isRejected)
+ .map(({ reason }, index) => ({
+ name: configMaps[index].name,
+ reason,
+ }));
+ return { failedConfigMaps, successfulConfigMaps };
+ },
+ {
+ ...withError('Unable to remove ConfigMaps'),
+ onSuccess: ({ failedConfigMaps, successfulConfigMaps }) => {
+ // Promise.allSettled can also resolve with errors, so check for errors here
+ queryClient.invalidateQueries(
+ configMapQueryKeys.configMapsForCluster(environmentId)
+ );
+ // show an error message for each configmap that failed to delete
+ failedConfigMaps.forEach(({ name, reason }) => {
+ notifyError(
+ `Failed to remove ConfigMap '${name}'`,
+ new Error(reason.message) as Error
+ );
+ });
+ // show one summary message for all successful deletes
+ if (successfulConfigMaps.length) {
+ notifySuccess(
+ 'ConfigMaps successfully removed',
+ successfulConfigMaps.join(', ')
+ );
+ }
+ },
+ }
+ );
+}
+
+async function getConfigMapsForCluster(
+ environmentId: EnvironmentId,
+ namespaces: string[]
+) {
+ try {
+ const configMaps = await Promise.all(
+ namespaces.map((namespace) => getConfigMaps(environmentId, namespace))
+ );
+ return configMaps.flat();
+ } catch (e) {
+ throw parseKubernetesAxiosError(
+ e as Error,
+ 'Unable to retrieve ConfigMaps for cluster'
+ );
+ }
+}
+
+// get all configmaps for a namespace
+async function getConfigMaps(environmentId: EnvironmentId, namespace: string) {
+ try {
+ const { data } = await axios.get(
+ buildUrl(environmentId, namespace)
+ );
+ return data.items;
+ } catch (e) {
+ throw parseKubernetesAxiosError(
+ e as Error,
+ 'Unable to retrieve ConfigMaps'
+ );
+ }
+}
+
+async function deleteConfigMap(
+ environmentId: EnvironmentId,
+ namespace: string,
+ name: string
+) {
+ try {
+ await axios.delete(buildUrl(environmentId, namespace, name));
+ } catch (e) {
+ throw parseKubernetesAxiosError(e as Error, 'Unable to remove ConfigMap');
+ }
+}
+
+function buildUrl(environmentId: number, namespace: string, name?: string) {
+ const url = `/endpoints/${environmentId}/kubernetes/api/v1/namespaces/${namespace}/configmaps`;
+ return name ? `${url}/${name}` : url;
+}
diff --git a/app/react/kubernetes/configs/queries.ts b/app/react/kubernetes/configs/queries.ts
index 2b27653ff..965597b1c 100644
--- a/app/react/kubernetes/configs/queries.ts
+++ b/app/react/kubernetes/configs/queries.ts
@@ -23,7 +23,11 @@ export function useConfigurations(
() => (namespace ? getConfigurations(environmentId, namespace) : []),
{
onError: (err) => {
- notifyError('Failure', err as Error, 'Unable to get configurations');
+ notifyError(
+ 'Failure',
+ err as Error,
+ `Unable to get configurations for namespace ${namespace}`
+ );
},
enabled: !!namespace,
}
@@ -35,10 +39,10 @@ export function useConfigurationsForCluster(
namespaces?: string[]
) {
return useQuery(
- ['environments', environemtId, 'kubernetes', 'configmaps'],
+ ['environments', environemtId, 'kubernetes', 'configurations'],
() => namespaces && getConfigMapsForCluster(environemtId, namespaces),
{
- ...withError('Unable to retrieve applications'),
+ ...withError('Unable to retrieve configurations for cluster'),
enabled: !!namespaces,
}
);
diff --git a/app/react/kubernetes/configs/secret.service.ts b/app/react/kubernetes/configs/secret.service.ts
new file mode 100644
index 000000000..01c3b99f4
--- /dev/null
+++ b/app/react/kubernetes/configs/secret.service.ts
@@ -0,0 +1,156 @@
+import { SecretList } from 'kubernetes-types/core/v1';
+import { useMutation, useQuery } from 'react-query';
+
+import { queryClient, withError } from '@/react-tools/react-query';
+import axios from '@/portainer/services/axios';
+import { EnvironmentId } from '@/react/portainer/environments/types';
+import {
+ error as notifyError,
+ notifySuccess,
+} from '@/portainer/services/notifications';
+import { isFulfilled, isRejected } from '@/portainer/helpers/promise-utils';
+
+import { parseKubernetesAxiosError } from '../axiosError';
+
+export const secretQueryKeys = {
+ secrets: (environmentId: EnvironmentId, namespace?: string) => [
+ 'environments',
+ environmentId,
+ 'kubernetes',
+ 'secrets',
+ 'namespaces',
+ namespace,
+ ],
+ secretsForCluster: (environmentId: EnvironmentId) => [
+ 'environments',
+ environmentId,
+ 'kubernetes',
+ 'secrets',
+ ],
+};
+
+// returns a usequery hook for the list of secrets from the kubernetes API
+export function useSecrets(environmentId: EnvironmentId, namespace?: string) {
+ return useQuery(
+ secretQueryKeys.secrets(environmentId, namespace),
+ () => (namespace ? getSecrets(environmentId, namespace) : []),
+ {
+ onError: (err) => {
+ notifyError(
+ 'Failure',
+ err as Error,
+ `Unable to get secrets in namespace '${namespace}'`
+ );
+ },
+ enabled: !!namespace,
+ }
+ );
+}
+
+export function useSecretsForCluster(
+ environmentId: EnvironmentId,
+ namespaces?: string[],
+ options?: { autoRefreshRate?: number }
+) {
+ return useQuery(
+ secretQueryKeys.secretsForCluster(environmentId),
+ () => namespaces && getSecretsForCluster(environmentId, namespaces),
+ {
+ ...withError('Unable to retrieve secrets for cluster'),
+ enabled: !!namespaces?.length,
+ refetchInterval() {
+ return options?.autoRefreshRate ?? false;
+ },
+ }
+ );
+}
+
+export function useMutationDeleteSecrets(environmentId: EnvironmentId) {
+ return useMutation(
+ async (secrets: { namespace: string; name: string }[]) => {
+ const promises = await Promise.allSettled(
+ secrets.map(({ namespace, name }) =>
+ deleteSecret(environmentId, namespace, name)
+ )
+ );
+ const successfulSecrets = promises
+ .filter(isFulfilled)
+ .map((_, index) => secrets[index].name);
+ const failedSecrets = promises
+ .filter(isRejected)
+ .map(({ reason }, index) => ({
+ name: secrets[index].name,
+ reason,
+ }));
+ return { failedSecrets, successfulSecrets };
+ },
+ {
+ ...withError('Unable to remove secrets'),
+ onSuccess: ({ failedSecrets, successfulSecrets }) => {
+ queryClient.invalidateQueries(
+ secretQueryKeys.secretsForCluster(environmentId)
+ );
+ // show an error message for each secret that failed to delete
+ failedSecrets.forEach(({ name, reason }) => {
+ notifyError(
+ `Failed to remove secret '${name}'`,
+ new Error(reason.message) as Error
+ );
+ });
+ // show one summary message for all successful deletes
+ if (successfulSecrets.length) {
+ notifySuccess(
+ 'Secrets successfully removed',
+ successfulSecrets.join(', ')
+ );
+ }
+ },
+ }
+ );
+}
+
+async function getSecretsForCluster(
+ environmentId: EnvironmentId,
+ namespaces: string[]
+) {
+ try {
+ const secrets = await Promise.all(
+ namespaces.map((namespace) => getSecrets(environmentId, namespace))
+ );
+ return secrets.flat();
+ } catch (e) {
+ throw parseKubernetesAxiosError(
+ e as Error,
+ 'Unable to retrieve secrets for cluster'
+ );
+ }
+}
+
+// get all secrets for a namespace
+async function getSecrets(environmentId: EnvironmentId, namespace: string) {
+ try {
+ const { data } = await axios.get(
+ buildUrl(environmentId, namespace)
+ );
+ return data.items;
+ } catch (e) {
+ throw parseKubernetesAxiosError(e as Error, 'Unable to retrieve secrets');
+ }
+}
+
+async function deleteSecret(
+ environmentId: EnvironmentId,
+ namespace: string,
+ name: string
+) {
+ try {
+ await axios.delete(buildUrl(environmentId, namespace, name));
+ } catch (e) {
+ throw parseKubernetesAxiosError(e as Error, 'Unable to remove secret');
+ }
+}
+
+function buildUrl(environmentId: number, namespace: string, name?: string) {
+ const url = `/endpoints/${environmentId}/kubernetes/api/v1/namespaces/${namespace}/secrets`;
+ return name ? `${url}/${name}` : url;
+}
diff --git a/app/react/kubernetes/configs/types.ts b/app/react/kubernetes/configs/types.ts
index ef0c95269..1ab7fccac 100644
--- a/app/react/kubernetes/configs/types.ts
+++ b/app/react/kubernetes/configs/types.ts
@@ -8,10 +8,13 @@ export interface Configuration {
ConfigurationOwner: string;
Used: boolean;
- // Applications: any[];
Data: Document;
Yaml: string;
SecretType?: string;
IsRegistrySecret?: boolean;
}
+
+// Workaround for the TS error `Type 'ConfigMap' does not satisfy the constraint 'Record'` for the datatable
+// https://github.com/microsoft/TypeScript/issues/15300#issuecomment-1320480061
+export type IndexOptional = Pick;
diff --git a/app/react/kubernetes/DashboardView/DashboardView.tsx b/app/react/kubernetes/dashboard/DashboardView.tsx
similarity index 53%
rename from app/react/kubernetes/DashboardView/DashboardView.tsx
rename to app/react/kubernetes/dashboard/DashboardView.tsx
index 2dcd87b5b..8a99a1056 100644
--- a/app/react/kubernetes/DashboardView/DashboardView.tsx
+++ b/app/react/kubernetes/dashboard/DashboardView.tsx
@@ -1,7 +1,8 @@
-import { Box, Database, Layers, Lock } from 'lucide-react';
+import { Box, Database, FileCode, Layers, Lock, Shuffle } from 'lucide-react';
import { useQueryClient } from 'react-query';
import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
+import Route from '@/assets/ico/route.svg?c';
import { DashboardGrid } from '@@/DashboardItem/DashboardGrid';
import { DashboardItem } from '@@/DashboardItem/DashboardItem';
@@ -9,8 +10,11 @@ import { PageHeader } from '@@/PageHeader';
import { useNamespaces } from '../namespaces/queries';
import { useApplicationsForCluster } from '../applications/application.queries';
-import { useConfigurationsForCluster } from '../configs/queries';
import { usePVCsForCluster } from '../volumes/queries';
+import { useServicesForCluster } from '../services/service';
+import { useIngresses } from '../ingresses/queries';
+import { useConfigMapsForCluster } from '../configs/configmap.service';
+import { useSecretsForCluster } from '../configs/secret.service';
import { EnvironmentInfo } from './EnvironmentInfo';
@@ -21,12 +25,26 @@ export function DashboardView() {
const namespaceNames = namespaces && Object.keys(namespaces);
const { data: applications, ...applicationsQuery } =
useApplicationsForCluster(environmentId, namespaceNames);
- const { data: configurations, ...configurationsQuery } =
- useConfigurationsForCluster(environmentId, namespaceNames);
const { data: pvcs, ...pvcsQuery } = usePVCsForCluster(
environmentId,
namespaceNames
);
+ const { data: services, ...servicesQuery } = useServicesForCluster(
+ environmentId,
+ namespaceNames
+ );
+ const { data: ingresses, ...ingressesQuery } = useIngresses(
+ environmentId,
+ namespaceNames
+ );
+ const { data: configMaps, ...configMapsQuery } = useConfigMapsForCluster(
+ environmentId,
+ namespaceNames
+ );
+ const { data: secrets, ...secretsQuery } = useSecretsForCluster(
+ environmentId,
+ namespaceNames
+ );
return (
<>
@@ -62,18 +80,51 @@ export function DashboardView() {
dataCy="dashboard-application"
/>
+
+
+
{!showSystemResources && (
diff --git a/app/react/kubernetes/datatables/default-kube-datatable-store.ts b/app/react/kubernetes/datatables/default-kube-datatable-store.ts
new file mode 100644
index 000000000..065d3a6d9
--- /dev/null
+++ b/app/react/kubernetes/datatables/default-kube-datatable-store.ts
@@ -0,0 +1,13 @@
+import { refreshableSettings, createPersistedStore } from '@@/datatables/types';
+
+import {
+ systemResourcesSettings,
+ TableSettings,
+} from './DefaultDatatableSettings';
+
+export function createStore(storageKey: string) {
+ return createPersistedStore(storageKey, 'name', (set) => ({
+ ...refreshableSettings(set),
+ ...systemResourcesSettings(set),
+ }));
+}
diff --git a/app/react/kubernetes/ingresses/CreateIngressView/IngressForm.tsx b/app/react/kubernetes/ingresses/CreateIngressView/IngressForm.tsx
index 9da619ba9..a1932f01b 100644
--- a/app/react/kubernetes/ingresses/CreateIngressView/IngressForm.tsx
+++ b/app/react/kubernetes/ingresses/CreateIngressView/IngressForm.tsx
@@ -373,18 +373,16 @@ export function IngressForm({
- Add a secret via{' '}
+ You may also use the{' '}
- ConfigMaps & Secrets
-
- {', '}
- then select 'Reload TLS secrets' above to
- populate the dropdown with your changes.
+ Create secret
+ {' '}
+ function, and reload the dropdown.
diff --git a/app/react/kubernetes/ingresses/IngressDatatable/IngressDatatable.tsx b/app/react/kubernetes/ingresses/IngressDatatable/IngressDatatable.tsx
index 004164021..db55dacc3 100644
--- a/app/react/kubernetes/ingresses/IngressDatatable/IngressDatatable.tsx
+++ b/app/react/kubernetes/ingresses/IngressDatatable/IngressDatatable.tsx
@@ -31,10 +31,10 @@ const settingsStore = createPersistedStore(storageKey);
export function IngressDatatable() {
const environmentId = useEnvironmentId();
- const nsResult = useNamespaces(environmentId);
+ const { data: namespaces, ...namespacesQuery } = useNamespaces(environmentId);
const ingressesQuery = useIngresses(
environmentId,
- Object.keys(nsResult?.data || {})
+ Object.keys(namespaces || {})
);
const deleteIngressesMutation = useDeleteIngresses();
@@ -47,7 +47,7 @@ export function IngressDatatable() {
settingsManager={tableState}
dataset={ingressesQuery.data || []}
columns={columns}
- isLoading={ingressesQuery.isLoading}
+ isLoading={ingressesQuery.isLoading || namespacesQuery.isLoading}
emptyContentLabel="No supported ingresses found"
title="Ingresses"
titleIcon={Route}
diff --git a/app/react/kubernetes/ingresses/IngressDatatable/columns/ingressRules.tsx b/app/react/kubernetes/ingresses/IngressDatatable/columns/ingressRules.tsx
index 36a9dedad..bc30727ec 100644
--- a/app/react/kubernetes/ingresses/IngressDatatable/columns/ingressRules.tsx
+++ b/app/react/kubernetes/ingresses/IngressDatatable/columns/ingressRules.tsx
@@ -31,24 +31,29 @@ function Cell({ row }: CellContext) {
return
;
}
- return paths.map((path) => {
- const isHttp = isHTTP(row.original.TLS || [], path.Host);
- return (
-
-
- {link(path.Host, path.Path, isHttp)}
-
- {`${path.ServiceName}:${path.Port}`}
- {!path.HasService && (
-
-
- Service doesn't exist
-
- )}
-
-
- );
- });
+ return (
+
+ {paths.map((path) => (
+
+
+ {link(
+ path.Host,
+ path.Path,
+ isHTTP(row.original.TLS || [], path.Host)
+ )}
+
+ {`${path.ServiceName}:${path.Port}`}
+ {!path.HasService && (
+
+
+ Service doesn't exist
+
+ )}
+
+
+ ))}
+
+ );
}
function isHTTP(TLSs: TLS[], host: string) {
diff --git a/app/react/kubernetes/ingresses/IngressDatatable/index.tsx b/app/react/kubernetes/ingresses/IngressDatatable/index.tsx
index 4ccf540d3..a66a66d8a 100644
--- a/app/react/kubernetes/ingresses/IngressDatatable/index.tsx
+++ b/app/react/kubernetes/ingresses/IngressDatatable/index.tsx
@@ -6,7 +6,7 @@ export function IngressesDatatableView() {
return (
<>
{
+ if (!namespaces?.length) {
+ return [];
+ }
const settledIngressesPromise = await Promise.allSettled(
namespaces.map((namespace) => getIngresses(environmentId, namespace))
);
@@ -110,7 +113,7 @@ export function useIngresses(
return filteredIngresses;
},
{
- enabled: namespaces.length > 0,
+ enabled: !!namespaces?.length,
...withError('Unable to get ingresses'),
}
);
diff --git a/app/react/kubernetes/namespaces/queries.ts b/app/react/kubernetes/namespaces/queries.ts
index 6062a1f93..905248fca 100644
--- a/app/react/kubernetes/namespaces/queries.ts
+++ b/app/react/kubernetes/namespaces/queries.ts
@@ -2,6 +2,7 @@ import { useQuery } from 'react-query';
import { EnvironmentId } from '@/react/portainer/environments/types';
import { error as notifyError } from '@/portainer/services/notifications';
+import { withError } from '@/react-tools/react-query';
import {
getNamespaces,
@@ -10,7 +11,10 @@ import {
} from './service';
import { Namespaces } from './types';
-export function useNamespaces(environmentId: EnvironmentId) {
+export function useNamespaces(
+ environmentId: EnvironmentId,
+ options?: { autoRefreshRate?: number }
+) {
return useQuery(
['environments', environmentId, 'kubernetes', 'namespaces'],
async () => {
@@ -34,8 +38,9 @@ export function useNamespaces(environmentId: EnvironmentId) {
return allowedNamespaces;
},
{
- onError: (err) => {
- notifyError('Failure', err as Error, 'Unable to get namespaces.');
+ ...withError('Unable to get namespaces.'),
+ refetchInterval() {
+ return options?.autoRefreshRate ?? false;
},
}
);
diff --git a/app/react/kubernetes/ServicesView/ServicesDatatable/ServicesDatatable.tsx b/app/react/kubernetes/services/ServicesView/ServicesDatatable/ServicesDatatable.tsx
similarity index 79%
rename from app/react/kubernetes/ServicesView/ServicesDatatable/ServicesDatatable.tsx
rename to app/react/kubernetes/services/ServicesView/ServicesDatatable/ServicesDatatable.tsx
index 050b4469e..edab47c50 100644
--- a/app/react/kubernetes/ServicesView/ServicesDatatable/ServicesDatatable.tsx
+++ b/app/react/kubernetes/services/ServicesView/ServicesDatatable/ServicesDatatable.tsx
@@ -9,8 +9,8 @@ import {
useAuthorizations,
useCurrentUser,
} from '@/react/hooks/useUser';
-import KubernetesNamespaceHelper from '@/kubernetes/helpers/namespaceHelper';
import { notifyError, notifySuccess } from '@/portainer/services/notifications';
+import { pluralize } from '@/portainer/helpers/strings';
import { Datatable, Table, TableSettingsMenu } from '@@/datatables';
import { confirmDelete } from '@@/modals/confirm';
@@ -18,14 +18,18 @@ import { Button } from '@@/buttons';
import { Link } from '@@/Link';
import { useTableState } from '@@/datatables/useTableState';
-import { useMutationDeleteServices, useServices } from '../service';
-import { Service } from '../types';
-import { DefaultDatatableSettings } from '../../datatables/DefaultDatatableSettings';
-import { isSystemNamespace } from '../../namespaces/utils';
+import {
+ useMutationDeleteServices,
+ useServicesForCluster,
+} from '../../service';
+import { Service } from '../../types';
+import { DefaultDatatableSettings } from '../../../datatables/DefaultDatatableSettings';
+import { isSystemNamespace } from '../../../namespaces/utils';
+import { useNamespaces } from '../../../namespaces/queries';
+import { SystemResourceDescription } from '../../../datatables/SystemResourceDescription';
import { columns } from './columns';
import { createStore } from './datatable-store';
-import { ServicesDatatableDescription } from './ServicesDatatableDescription';
const storageKey = 'k8sServicesDatatable';
const settingsStore = createStore(storageKey);
@@ -33,12 +37,20 @@ const settingsStore = createStore(storageKey);
export function ServicesDatatable() {
const tableState = useTableState(settingsStore, storageKey);
const environmentId = useEnvironmentId();
- const servicesQuery = useServices(environmentId);
+ const { data: namespaces, ...namespacesQuery } = useNamespaces(environmentId);
+ const namespaceNames = (namespaces && Object.keys(namespaces)) || [];
+ const { data: services, ...servicesQuery } = useServicesForCluster(
+ environmentId,
+ namespaceNames,
+ {
+ autoRefreshRate: tableState.autoRefreshRate * 1000,
+ }
+ );
const readOnly = !useAuthorizations(['K8sServiceW']);
const { isAdmin } = useCurrentUser();
- const filteredServices = servicesQuery.data?.filter(
+ const filteredServices = services?.filter(
(service) =>
(isAdmin && tableState.showSystemResources) ||
!isSystemNamespace(service.Namespace)
@@ -49,14 +61,12 @@ export function ServicesDatatable() {
dataset={filteredServices || []}
columns={columns}
settingsManager={tableState}
- isLoading={servicesQuery.isLoading}
+ isLoading={servicesQuery.isLoading || namespacesQuery.isLoading}
emptyContentLabel="No services found"
title="Services"
titleIcon={Shuffle}
getRowId={(row) => row.UID}
- isRowSelectable={(row) =>
- !KubernetesNamespaceHelper.isSystemNamespace(row.original.Namespace)
- }
+ isRowSelectable={(row) => !isSystemNamespace(row.original.Namespace)}
disableSelect={readOnly}
renderTableActions={(selectedRows) => (
@@ -70,7 +80,7 @@ export function ServicesDatatable() {
)}
description={
-
}
@@ -109,7 +119,10 @@ function TableActions({ selectedItems }: TableActionsProps) {
async function handleRemoveClick(services: SelectedService[]) {
const confirmed = await confirmDelete(
<>
- Are you sure you want to delete the selected service(s)?
+ {`Are you sure you want to remove the selected ${pluralize(
+ services.length,
+ 'service'
+ )}?`}
{services.map((s, index) => (
diff --git a/app/react/kubernetes/ServicesView/ServicesDatatable/columns/ExternalIPLink.tsx b/app/react/kubernetes/services/ServicesView/ServicesDatatable/columns/ExternalIPLink.tsx
similarity index 100%
rename from app/react/kubernetes/ServicesView/ServicesDatatable/columns/ExternalIPLink.tsx
rename to app/react/kubernetes/services/ServicesView/ServicesDatatable/columns/ExternalIPLink.tsx
diff --git a/app/react/kubernetes/ServicesView/ServicesDatatable/columns/application.tsx b/app/react/kubernetes/services/ServicesView/ServicesDatatable/columns/application.tsx
similarity index 95%
rename from app/react/kubernetes/ServicesView/ServicesDatatable/columns/application.tsx
rename to app/react/kubernetes/services/ServicesView/ServicesDatatable/columns/application.tsx
index 5c97a6771..d0a6f8b43 100644
--- a/app/react/kubernetes/ServicesView/ServicesDatatable/columns/application.tsx
+++ b/app/react/kubernetes/services/ServicesView/ServicesDatatable/columns/application.tsx
@@ -4,7 +4,7 @@ import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
import { Link } from '@@/Link';
-import { Service } from '../../types';
+import { Service } from '../../../types';
import { columnHelper } from './helper';
diff --git a/app/react/kubernetes/ServicesView/ServicesDatatable/columns/clusterIP.tsx b/app/react/kubernetes/services/ServicesView/ServicesDatatable/columns/clusterIP.tsx
similarity index 100%
rename from app/react/kubernetes/ServicesView/ServicesDatatable/columns/clusterIP.tsx
rename to app/react/kubernetes/services/ServicesView/ServicesDatatable/columns/clusterIP.tsx
diff --git a/app/react/kubernetes/ServicesView/ServicesDatatable/columns/created.tsx b/app/react/kubernetes/services/ServicesView/ServicesDatatable/columns/created.tsx
similarity index 100%
rename from app/react/kubernetes/ServicesView/ServicesDatatable/columns/created.tsx
rename to app/react/kubernetes/services/ServicesView/ServicesDatatable/columns/created.tsx
diff --git a/app/react/kubernetes/ServicesView/ServicesDatatable/columns/externalIP.tsx b/app/react/kubernetes/services/ServicesView/ServicesDatatable/columns/externalIP.tsx
similarity index 98%
rename from app/react/kubernetes/ServicesView/ServicesDatatable/columns/externalIP.tsx
rename to app/react/kubernetes/services/ServicesView/ServicesDatatable/columns/externalIP.tsx
index 7e414deb5..a392d5fff 100644
--- a/app/react/kubernetes/ServicesView/ServicesDatatable/columns/externalIP.tsx
+++ b/app/react/kubernetes/services/ServicesView/ServicesDatatable/columns/externalIP.tsx
@@ -1,6 +1,6 @@
import { CellContext } from '@tanstack/react-table';
-import { Service } from '../../types';
+import { Service } from '../../../types';
import { ExternalIPLink } from './ExternalIPLink';
import { columnHelper } from './helper';
diff --git a/app/react/kubernetes/ServicesView/ServicesDatatable/columns/helper.ts b/app/react/kubernetes/services/ServicesView/ServicesDatatable/columns/helper.ts
similarity index 74%
rename from app/react/kubernetes/ServicesView/ServicesDatatable/columns/helper.ts
rename to app/react/kubernetes/services/ServicesView/ServicesDatatable/columns/helper.ts
index e614b54c0..1debacd16 100644
--- a/app/react/kubernetes/ServicesView/ServicesDatatable/columns/helper.ts
+++ b/app/react/kubernetes/services/ServicesView/ServicesDatatable/columns/helper.ts
@@ -1,5 +1,5 @@
import { createColumnHelper } from '@tanstack/react-table';
-import { Service } from '../../types';
+import { Service } from '../../../types';
export const columnHelper = createColumnHelper();
diff --git a/app/react/kubernetes/ServicesView/ServicesDatatable/columns/index.tsx b/app/react/kubernetes/services/ServicesView/ServicesDatatable/columns/index.tsx
similarity index 100%
rename from app/react/kubernetes/ServicesView/ServicesDatatable/columns/index.tsx
rename to app/react/kubernetes/services/ServicesView/ServicesDatatable/columns/index.tsx
diff --git a/app/react/kubernetes/ServicesView/ServicesDatatable/columns/name.tsx b/app/react/kubernetes/services/ServicesView/ServicesDatatable/columns/name.tsx
similarity index 98%
rename from app/react/kubernetes/ServicesView/ServicesDatatable/columns/name.tsx
rename to app/react/kubernetes/services/ServicesView/ServicesDatatable/columns/name.tsx
index 1561c7aad..99d89b5b9 100644
--- a/app/react/kubernetes/ServicesView/ServicesDatatable/columns/name.tsx
+++ b/app/react/kubernetes/services/ServicesView/ServicesDatatable/columns/name.tsx
@@ -22,7 +22,7 @@ export const name = columnHelper.accessor(
},
{
header: 'Name',
- id: 'Name',
+ id: 'name',
cell: ({ row }) => {
const name = row.original.Name;
const isSystem = isSystemNamespace(row.original.Namespace);
diff --git a/app/react/kubernetes/ServicesView/ServicesDatatable/columns/namespace.tsx b/app/react/kubernetes/services/ServicesView/ServicesDatatable/columns/namespace.tsx
similarity index 95%
rename from app/react/kubernetes/ServicesView/ServicesDatatable/columns/namespace.tsx
rename to app/react/kubernetes/services/ServicesView/ServicesDatatable/columns/namespace.tsx
index ef8dfa625..40374ae16 100644
--- a/app/react/kubernetes/ServicesView/ServicesDatatable/columns/namespace.tsx
+++ b/app/react/kubernetes/services/ServicesView/ServicesDatatable/columns/namespace.tsx
@@ -4,7 +4,7 @@ import { filterHOC } from '@/react/components/datatables/Filter';
import { Link } from '@@/Link';
-import { Service } from '../../types';
+import { Service } from '../../../types';
import { columnHelper } from './helper';
diff --git a/app/react/kubernetes/ServicesView/ServicesDatatable/columns/ports.tsx b/app/react/kubernetes/services/ServicesView/ServicesDatatable/columns/ports.tsx
similarity index 100%
rename from app/react/kubernetes/ServicesView/ServicesDatatable/columns/ports.tsx
rename to app/react/kubernetes/services/ServicesView/ServicesDatatable/columns/ports.tsx
diff --git a/app/react/kubernetes/ServicesView/ServicesDatatable/columns/targetPorts.tsx b/app/react/kubernetes/services/ServicesView/ServicesDatatable/columns/targetPorts.tsx
similarity index 100%
rename from app/react/kubernetes/ServicesView/ServicesDatatable/columns/targetPorts.tsx
rename to app/react/kubernetes/services/ServicesView/ServicesDatatable/columns/targetPorts.tsx
diff --git a/app/react/kubernetes/ServicesView/ServicesDatatable/columns/type.tsx b/app/react/kubernetes/services/ServicesView/ServicesDatatable/columns/type.tsx
similarity index 91%
rename from app/react/kubernetes/ServicesView/ServicesDatatable/columns/type.tsx
rename to app/react/kubernetes/services/ServicesView/ServicesDatatable/columns/type.tsx
index 819538536..f443865e1 100644
--- a/app/react/kubernetes/ServicesView/ServicesDatatable/columns/type.tsx
+++ b/app/react/kubernetes/services/ServicesView/ServicesDatatable/columns/type.tsx
@@ -2,7 +2,7 @@ import { Row } from '@tanstack/react-table';
import { filterHOC } from '@@/datatables/Filter';
-import { Service } from '../../types';
+import { Service } from '../../../types';
import { columnHelper } from './helper';
diff --git a/app/react/kubernetes/ServicesView/ServicesDatatable/datatable-store.ts b/app/react/kubernetes/services/ServicesView/ServicesDatatable/datatable-store.ts
similarity index 86%
rename from app/react/kubernetes/ServicesView/ServicesDatatable/datatable-store.ts
rename to app/react/kubernetes/services/ServicesView/ServicesDatatable/datatable-store.ts
index 77716b10f..82c82153e 100644
--- a/app/react/kubernetes/ServicesView/ServicesDatatable/datatable-store.ts
+++ b/app/react/kubernetes/services/ServicesView/ServicesDatatable/datatable-store.ts
@@ -3,7 +3,7 @@ import { refreshableSettings, createPersistedStore } from '@@/datatables/types';
import {
systemResourcesSettings,
TableSettings,
-} from '../../datatables/DefaultDatatableSettings';
+} from '../../../datatables/DefaultDatatableSettings';
export function createStore(storageKey: string) {
return createPersistedStore(storageKey, 'name', (set) => ({
diff --git a/app/react/kubernetes/ServicesView/ServicesDatatable/index.tsx b/app/react/kubernetes/services/ServicesView/ServicesDatatable/index.tsx
similarity index 100%
rename from app/react/kubernetes/ServicesView/ServicesDatatable/index.tsx
rename to app/react/kubernetes/services/ServicesView/ServicesDatatable/index.tsx
diff --git a/app/react/kubernetes/ServicesView/ServicesView.tsx b/app/react/kubernetes/services/ServicesView/ServicesView.tsx
similarity index 76%
rename from app/react/kubernetes/ServicesView/ServicesView.tsx
rename to app/react/kubernetes/services/ServicesView/ServicesView.tsx
index 344e4feae..23165fb91 100644
--- a/app/react/kubernetes/ServicesView/ServicesView.tsx
+++ b/app/react/kubernetes/services/ServicesView/ServicesView.tsx
@@ -5,7 +5,7 @@ import { ServicesDatatable } from './ServicesDatatable';
export function ServicesView() {
return (
<>
-
+
>
);
diff --git a/app/react/kubernetes/ServicesView/index.ts b/app/react/kubernetes/services/ServicesView/index.ts
similarity index 100%
rename from app/react/kubernetes/ServicesView/index.ts
rename to app/react/kubernetes/services/ServicesView/index.ts
diff --git a/app/react/kubernetes/services/readme.md b/app/react/kubernetes/services/readme.md
deleted file mode 100644
index 10ae44910..000000000
--- a/app/react/kubernetes/services/readme.md
+++ /dev/null
@@ -1,5 +0,0 @@
-## Common Services
-
-This folder contains rest api services that are shared by different features within kubernetes.
-
-This includes api requests to the portainer backend, and also requests to the kubernetes api.
diff --git a/app/react/kubernetes/ServicesView/service.ts b/app/react/kubernetes/services/service.ts
similarity index 85%
rename from app/react/kubernetes/ServicesView/service.ts
rename to app/react/kubernetes/services/service.ts
index 48f553949..6889f0d26 100644
--- a/app/react/kubernetes/ServicesView/service.ts
+++ b/app/react/kubernetes/services/service.ts
@@ -7,8 +7,6 @@ import axios, { parseAxiosError } from '@/portainer/services/axios';
import { EnvironmentId } from '@/react/portainer/environments/types';
import { isFulfilled } from '@/portainer/helpers/promise-utils';
-import { getNamespaces } from '../namespaces/service';
-
import { Service } from './types';
export const queryKeys = {
@@ -16,6 +14,45 @@ export const queryKeys = {
['environments', environmentId, 'kubernetes', 'services'] as const,
};
+export function useServicesForCluster(
+ environmentId: EnvironmentId,
+ namespaceNames?: string[],
+ options?: { autoRefreshRate?: number }
+) {
+ return useQuery(
+ queryKeys.clusterServices(environmentId),
+ async () => {
+ if (!namespaceNames?.length) {
+ return [];
+ }
+ const settledServicesPromise = await Promise.allSettled(
+ namespaceNames.map((namespace) =>
+ getServices(environmentId, namespace, true)
+ )
+ );
+ return compact(
+ settledServicesPromise.filter(isFulfilled).flatMap((i) => i.value)
+ );
+ },
+ {
+ ...withError('Unable to get services.'),
+ refetchInterval() {
+ return options?.autoRefreshRate ?? false;
+ },
+ enabled: !!namespaceNames?.length,
+ }
+ );
+}
+
+export function useMutationDeleteServices(environmentId: EnvironmentId) {
+ const queryClient = useQueryClient();
+ return useMutation(deleteServices, {
+ onSuccess: () =>
+ queryClient.invalidateQueries(queryKeys.clusterServices(environmentId)),
+ ...withError('Unable to delete service(s)'),
+ });
+}
+
// get a list of services for a specific namespace from the Portainer API
async function getServices(
environmentId: EnvironmentId,
@@ -37,24 +74,6 @@ async function getServices(
}
}
-export function useServices(environmentId: EnvironmentId) {
- return useQuery(
- queryKeys.clusterServices(environmentId),
- async () => {
- const namespaces = await getNamespaces(environmentId);
- const settledServicesPromise = await Promise.allSettled(
- Object.keys(namespaces).map((namespace) =>
- getServices(environmentId, namespace, true)
- )
- );
- return compact(
- settledServicesPromise.filter(isFulfilled).flatMap((i) => i.value)
- );
- },
- withError('Unable to get services.')
- );
-}
-
// getNamespaceServices is used to get a list of services for a specific namespace directly from the Kubernetes API
export async function getNamespaceServices(
environmentId: EnvironmentId,
@@ -70,15 +89,6 @@ export async function getNamespaceServices(
return services.items;
}
-export function useMutationDeleteServices(environmentId: EnvironmentId) {
- const queryClient = useQueryClient();
- return useMutation(deleteServices, {
- onSuccess: () =>
- queryClient.invalidateQueries(queryKeys.clusterServices(environmentId)),
- ...withError('Unable to delete service(s)'),
- });
-}
-
export async function deleteServices({
environmentId,
data,
diff --git a/app/react/kubernetes/ServicesView/types.ts b/app/react/kubernetes/services/types.ts
similarity index 100%
rename from app/react/kubernetes/ServicesView/types.ts
rename to app/react/kubernetes/services/types.ts
diff --git a/app/react/kubernetes/services/types/index.ts b/app/react/kubernetes/services/types/index.ts
deleted file mode 100644
index f6ffc0a15..000000000
--- a/app/react/kubernetes/services/types/index.ts
+++ /dev/null
@@ -1,11 +0,0 @@
-export * from './v1IngressClass';
-export * from './v1ObjectMeta';
-
-export type KubernetesApiListResponse = {
- apiVersion: string;
- kind: string;
- items: T;
- metadata: {
- resourceVersion?: string;
- };
-};
diff --git a/app/react/kubernetes/services/types/v1IngressClass.ts b/app/react/kubernetes/services/types/v1IngressClass.ts
deleted file mode 100644
index 4f5362de3..000000000
--- a/app/react/kubernetes/services/types/v1IngressClass.ts
+++ /dev/null
@@ -1,48 +0,0 @@
-import { V1ObjectMeta } from './v1ObjectMeta';
-
-/**
- * IngressClassParametersReference identifies an API object. This can be used to specify a cluster or namespace-scoped resource.
- */
-type V1IngressClassParametersReference = {
- /**
- * APIGroup is the group for the resource being referenced. If APIGroup is not specified, the specified Kind must be in the core API group. For any other third-party types, APIGroup is required.
- */
- apiGroup?: string;
- /**
- * Kind is the type of resource being referenced.
- */
- kind: string;
- /**
- * Name is the name of resource being referenced.
- */
- name: string;
- /**
- * Namespace is the namespace of the resource being referenced. This field is required when scope is set to \"Namespace\" and must be unset when scope is set to \"Cluster\".
- */
- namespace?: string;
- /**
- * Scope represents if this refers to a cluster or namespace scoped resource. This may be set to \"Cluster\" (default) or \"Namespace\".
- */
- scope?: string;
-};
-
-type V1IngressClassSpec = {
- controller?: string;
- parameters?: V1IngressClassParametersReference;
-};
-
-/**
- * IngressClass represents the class of the Ingress, referenced by the Ingress Spec. The `ingressclass.kubernetes.io/is-default-class` annotation can be used to indicate that an IngressClass should be considered default. When a single IngressClass resource has this annotation set to true, new Ingress resources without a class specified will be assigned this default class.
- */
-export type V1IngressClass = {
- /**
- * APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources
- */
- apiVersion?: string;
- /**
- * Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds
- */
- kind?: string;
- metadata?: V1ObjectMeta;
- spec?: V1IngressClassSpec;
-};
diff --git a/app/react/kubernetes/services/types/v1ObjectMeta.ts b/app/react/kubernetes/services/types/v1ObjectMeta.ts
deleted file mode 100644
index 7edcc20bb..000000000
--- a/app/react/kubernetes/services/types/v1ObjectMeta.ts
+++ /dev/null
@@ -1,33 +0,0 @@
-// type definitions taken from https://github.com/kubernetes-client/javascript/blob/master/src/gen/model/v1ObjectMeta.ts
-// and simplified to only include the types we need
-
-export type V1ObjectMeta = {
- /**
- * Annotations is an unstructured key value map stored with a resource that may be set by external tools to store and retrieve arbitrary metadata. They are not queryable and should be preserved when modifying objects. More info: http://kubernetes.io/docs/user-guide/annotations
- */
- annotations?: { [key: string]: string };
- /**
- * Map of string keys and values that can be used to organize and categorize (scope and select) objects. May match selectors of replication controllers and services. More info: http://kubernetes.io/docs/user-guide/labels
- */
- labels?: { [key: string]: string };
- /**
- * Deprecated: ClusterName is a legacy field that was always cleared by the system and never used; it will be removed completely in 1.25. The name in the go struct is changed to help clients detect accidental use.
- */
- clusterName?: string;
- /**
- * Name must be unique within a namespace. Is required when creating resources, although some resources may allow a client to request the generation of an appropriate name automatically. Name is primarily intended for creation idempotence and configuration definition. Cannot be updated. More info: http://kubernetes.io/docs/user-guide/identifiers#names
- */
- name?: string;
- /**
- * Namespace defines the space within which each name must be unique. An empty namespace is equivalent to the \"default\" namespace, but \"default\" is the canonical representation. Not all objects are required to be scoped to a namespace - the value of this field for those objects will be empty. Must be a DNS_LABEL. Cannot be updated. More info: http://kubernetes.io/docs/user-guide/namespaces
- */
- namespace?: string;
- /**
- * An opaque value that represents the internal version of this object that can be used by clients to determine when objects have changed. May be used for optimistic concurrency, change detection, and the watch operation on a resource or set of resources. Clients must treat these values as opaque and passed unmodified back to the server. They may only be valid for a particular resource or set of resources. Populated by the system. Read-only. Value must be treated as opaque by clients and . More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#concurrency-control-and-consistency
- */
- resourceVersion?: string;
- /**
- * UID is the unique in time and space value for this object. It is typically generated by the server on successful creation of a resource and is not allowed to change on PUT operations. Populated by the system. Read-only. More info: http://kubernetes.io/docs/user-guide/identifiers#uids
- */
- uid?: string;
-};
diff --git a/app/react/kubernetes/volumes/queries.ts b/app/react/kubernetes/volumes/queries.ts
index c03856416..64ff0acee 100644
--- a/app/react/kubernetes/volumes/queries.ts
+++ b/app/react/kubernetes/volumes/queries.ts
@@ -5,7 +5,7 @@ import { EnvironmentId } from '@/react/portainer/environments/types';
import { getPVCsForCluster } from './service';
-// useQuery to get a list of all applications from an array of namespaces
+// useQuery to get a list of all persistent volume claims from an array of namespaces
export function usePVCsForCluster(
environemtId: EnvironmentId,
namespaces?: string[]
@@ -14,7 +14,7 @@ export function usePVCsForCluster(
['environments', environemtId, 'kubernetes', 'pvcs'],
() => namespaces && getPVCsForCluster(environemtId, namespaces),
{
- ...withError('Unable to retrieve applications'),
+ ...withError('Unable to retrieve perrsistent volume claims'),
enabled: !!namespaces,
}
);
diff --git a/app/react/kubernetes/volumes/service.ts b/app/react/kubernetes/volumes/service.ts
index 3f79e7014..1c711273e 100644
--- a/app/react/kubernetes/volumes/service.ts
+++ b/app/react/kubernetes/volumes/service.ts
@@ -28,6 +28,9 @@ export async function getPVCs(environmentId: EnvironmentId, namespace: string) {
);
return data.items;
} catch (e) {
- throw parseAxiosError(e as Error, 'Unable to retrieve deployments');
+ throw parseAxiosError(
+ e as Error,
+ 'Unable to retrieve persistent volume claims'
+ );
}
}
diff --git a/app/react/portainer/HomeView/EnvironmentList/KubeconfigButton/KubeconfigPrompt.tsx b/app/react/portainer/HomeView/EnvironmentList/KubeconfigButton/KubeconfigPrompt.tsx
index 197960fc3..53fc01f2f 100644
--- a/app/react/portainer/HomeView/EnvironmentList/KubeconfigButton/KubeconfigPrompt.tsx
+++ b/app/react/portainer/HomeView/EnvironmentList/KubeconfigButton/KubeconfigPrompt.tsx
@@ -1,7 +1,7 @@
import clsx from 'clsx';
import { useState } from 'react';
-import { downloadKubeconfigFile } from '@/react/kubernetes/services/kubeconfig.service';
+import { downloadKubeconfigFile } from '@/react/portainer/HomeView/EnvironmentList/KubeconfigButton/kubeconfig.service';
import * as notifications from '@/portainer/services/notifications';
import {
Environment,
diff --git a/app/react/kubernetes/services/kubeconfig.service.ts b/app/react/portainer/HomeView/EnvironmentList/KubeconfigButton/kubeconfig.service.ts
similarity index 100%
rename from app/react/kubernetes/services/kubeconfig.service.ts
rename to app/react/portainer/HomeView/EnvironmentList/KubeconfigButton/kubeconfig.service.ts