1
0
Fork 0
mirror of https://github.com/portainer/portainer.git synced 2025-07-24 15:59:41 +02:00

feat(config): separate configmaps and secrets [EE-5078] (#9029)

This commit is contained in:
Ali 2023-06-12 09:46:48 +12:00 committed by GitHub
parent 4a331b71e1
commit d7fc2046d7
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
102 changed files with 2845 additions and 665 deletions

View file

@ -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 && (
<span>
<Link
to="kubernetes.configurations.configuration"
to="kubernetes.configmaps.configmap"
params={{
name: envVar.valueFrom.configMapKeyRef.name,
namespace,
}}
className="flex items-center"
>
<Icon icon={File} className="!mr-1" />
<Icon icon={FileCode} className="!mr-1" />
{envVar.valueFrom.configMapKeyRef.name}
</Link>
</span>
@ -108,14 +108,14 @@ export function ApplicationEnvVarsTable({ namespace, app }: Props) {
{envVar.valueFrom?.secretKeyRef && (
<span>
<Link
to="kubernetes.configurations.configuration"
to="kubernetes.secrets.secret"
params={{
name: envVar.valueFrom.secretKeyRef.name,
namespace,
}}
className="flex items-center"
>
<Icon icon={File} className="!mr-1" />
<Icon icon={Lock} className="!mr-1" />
{envVar.valueFrom.secretKeyRef.name}
</Link>
</span>

View file

@ -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,
}
);
}

View file

@ -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}'`
);
}
}

View file

@ -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);
}

View file

@ -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<KubernetesApiListResponse<V1IngressClass[]>>(
urlBuilder(environmentId)
);
} = await axios.get<IngressClassList>(urlBuilder(environmentId));
return items;
} catch (error) {
throw parseAxiosError(error as Error);

View file

@ -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 (
<Datatable<IndexOptional<ConfigMapRowData>>
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) => (
<TableActions selectedItems={selectedRows} />
)}
renderTableSettings={() => (
<TableSettingsMenu>
<DefaultDatatableSettings
settings={tableState}
hideShowSystemResources={!isAdmin}
/>
</TableSettingsMenu>
)}
description={
<SystemResourceDescription
showSystemResources={tableState.showSystemResources || !isAdmin}
/>
}
/>
);
}
// 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 (
<Authorized authorizations="K8sConfigMapsW">
<Button
className="btn-wrapper"
color="dangerlight"
disabled={selectedItems.length === 0}
onClick={async () => {
handleRemoveClick(selectedItems);
}}
icon={Trash2}
data-cy="k8sConfig-removeConfigButton"
>
Remove
</Button>
<Link to="kubernetes.configmaps.new" className="ml-1">
<Button
className="btn-wrapper"
color="secondary"
icon={Plus}
data-cy="k8sConfig-addConfigWithFormButton"
>
Add with form
</Button>
</Link>
<Link
to="kubernetes.deploy"
params={{
referrer: 'kubernetes.configurations',
tab: 'configmaps',
}}
className="ml-1"
data-cy="k8sConfig-deployFromManifestButton"
>
<Button className="btn-wrapper" color="primary" icon={Plus}>
Create from manifest
</Button>
</Link>
</Authorized>
);
}

View file

@ -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;
}

View file

@ -0,0 +1,5 @@
import { createColumnHelper } from '@tanstack/react-table';
import { ConfigMapRowData } from '../types';
export const columnHelper = createColumnHelper<ConfigMapRowData>();

View file

@ -0,0 +1,5 @@
import { name } from './name';
import { namespace } from './namespace';
import { created } from './created';
export const columns = [name, namespace, created];

View file

@ -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<ConfigMapRowData, string>) {
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 (
<Authorized authorizations="K8sConfigMapsR" childrenUnauthorized={name}>
<div className="flex">
<Link
to="kubernetes.configmaps.configmap"
params={{
namespace: row.original.metadata?.namespace,
name,
}}
title={name}
className="w-fit max-w-xs truncate xl:max-w-sm 2xl:max-w-md"
>
{name}
</Link>
{isSystemConfigMap && (
<Badge type="success" className="ml-2">
system
</Badge>
)}
{!isSystemToken && !hasConfigurationOwner && (
<Badge className="ml-2">external</Badge>
)}
{!row.original.inUse && !isSystemConfigMap && (
<Badge type="warn" className="ml-2">
unused
</Badge>
)}
</div>
</Authorized>
);
}

View file

@ -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 (
<Link
to="kubernetes.resourcePools.resourcePool"
params={{
id: namespace,
}}
title={namespace}
>
{namespace}
</Link>
);
},
meta: {
filter: filterHOC('Filter by namespace'),
},
enableColumnFilter: true,
filterFn: (
row: Row<ConfigMapRowData>,
_columnId: string,
filterValue: string[]
) =>
filterValue.length === 0 ||
filterValue.includes(row.original.metadata?.namespace ?? ''),
}
);

View file

@ -0,0 +1 @@
export { ConfigMapsDatatable } from './ConfigMapsDatatable';

View file

@ -0,0 +1,5 @@
import { ConfigMap } from 'kubernetes-types/core/v1';
export interface ConfigMapRowData extends ConfigMap {
inUse: boolean;
}

View file

@ -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>('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;
});
}

View file

@ -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: <ConfigMapsDatatable />,
selectedTabParam: 'configmaps',
},
{
name: 'Secrets',
icon: Lock,
widget: <SecretsDatatable />,
selectedTabParam: 'secrets',
},
];
export function ConfigmapsAndSecretsView() {
const currentTabIndex = findSelectedTabIndex(
useCurrentStateAndParams(),
tabs
);
return (
<>
<PageHeader
title="ConfigMap & Secret lists"
breadcrumbs="ConfigMaps & Secrets"
reload
/>
<>
<WidgetTabs tabs={tabs} currentTabIndex={currentTabIndex} />
<div className="content">{tabs[currentTabIndex].widget}</div>
</>
</>
);
}

View file

@ -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 (
<Datatable<IndexOptional<SecretRowData>>
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) => (
<TableActions selectedItems={selectedRows} />
)}
renderTableSettings={() => (
<TableSettingsMenu>
<DefaultDatatableSettings
settings={tableState}
hideShowSystemResources={!isAdmin}
/>
</TableSettingsMenu>
)}
description={
<SystemResourceDescription
showSystemResources={tableState.showSystemResources || !isAdmin}
/>
}
/>
);
}
// 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 (
<Authorized authorizations="K8sSecretsW">
<Button
className="btn-wrapper"
color="dangerlight"
disabled={selectedItems.length === 0}
onClick={async () => {
handleRemoveClick(selectedItems);
}}
icon={Trash2}
data-cy="k8sSecret-removeSecretButton"
>
Remove
</Button>
<Link to="kubernetes.secrets.new" className="ml-1">
<Button
className="btn-wrapper"
color="secondary"
icon={Plus}
data-cy="k8sSecret-addSecretWithFormButton"
>
Add with form
</Button>
</Link>
<Link
to="kubernetes.deploy"
params={{
referrer: 'kubernetes.configurations',
tab: 'secrets',
}}
className="ml-1"
data-cy="k8sSecret-deployFromManifestButton"
>
<Button className="btn-wrapper" color="primary" icon={Plus}>
Create from manifest
</Button>
</Link>
</Authorized>
);
}

View file

@ -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;
}

View file

@ -0,0 +1,5 @@
import { createColumnHelper } from '@tanstack/react-table';
import { SecretRowData } from '../types';
export const columnHelper = createColumnHelper<SecretRowData>();

View file

@ -0,0 +1,5 @@
import { name } from './name';
import { namespace } from './namespace';
import { created } from './created';
export const columns = [name, namespace, created];

View file

@ -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<SecretRowData, string>) {
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 (
<Authorized authorizations="K8sSecretsR" childrenUnauthorized={name}>
<div className="flex w-fit">
<Link
to="kubernetes.secrets.secret"
params={{
namespace: row.original.metadata?.namespace,
name,
}}
title={name}
className="w-fit max-w-xs truncate xl:max-w-sm 2xl:max-w-md"
>
{name}
</Link>
{isSystemSecret && (
<Badge type="success" className="ml-2">
system
</Badge>
)}
{!isSystemToken && !hasConfigurationOwner && (
<Badge className="ml-2">external</Badge>
)}
{!row.original.inUse && !isSystemSecret && (
<Badge type="warn" className="ml-2">
unused
</Badge>
)}
</div>
</Authorized>
);
}

View file

@ -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 (
<Link
to="kubernetes.resourcePools.resourcePool"
params={{
id: namespace,
}}
title={namespace}
>
{namespace}
</Link>
);
},
meta: {
filter: filterHOC('Filter by namespace'),
},
enableColumnFilter: true,
filterFn: (
row: Row<SecretRowData>,
_columnId: string,
filterValue: string[]
) =>
filterValue.length === 0 ||
filterValue.includes(row.original.metadata?.namespace ?? ''),
}
);

View file

@ -0,0 +1 @@
export { SecretsDatatable } from './SecretsDatatable';

View file

@ -0,0 +1,5 @@
import { Secret } from 'kubernetes-types/core/v1';
export interface SecretRowData extends Secret {
inUse: boolean;
}

View file

@ -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>('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;
});
}

View file

@ -0,0 +1 @@
export { ConfigmapsAndSecretsView } from './ConfigmapsAndSecretsView';

View file

@ -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<ConfigMapList>(
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;
}

View file

@ -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,
}
);

View file

@ -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<SecretList>(
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;
}

View file

@ -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<string, unknown>'` for the datatable
// https://github.com/microsoft/TypeScript/issues/15300#issuecomment-1320480061
export type IndexOptional<T> = Pick<T, keyof T>;

View file

@ -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"
/>
<DashboardItem
value={configurations?.length}
isLoading={
configurationsQuery.isLoading || namespacesQuery.isLoading
}
value={services?.length}
isLoading={servicesQuery.isLoading || namespacesQuery.isLoading}
isRefetching={
configurationsQuery.isRefetching || namespacesQuery.isRefetching
servicesQuery.isRefetching || namespacesQuery.isRefetching
}
icon={Shuffle}
to="kubernetes.services"
type="Service"
dataCy="dashboard-service"
/>
<DashboardItem
value={ingresses?.length}
isLoading={ingressesQuery.isLoading || namespacesQuery.isLoading}
isRefetching={
ingressesQuery.isRefetching || namespacesQuery.isRefetching
}
icon={Route}
to="kubernetes.ingresses"
type="Ingress"
pluralType="Ingresses"
dataCy="dashboard-ingress"
/>
<DashboardItem
value={configMaps?.length}
isLoading={configMapsQuery.isLoading || namespacesQuery.isLoading}
isRefetching={
configMapsQuery.isRefetching || namespacesQuery.isRefetching
}
icon={FileCode}
to="kubernetes.configurations"
params={{ tab: 'configmaps' }}
type="ConfigMap"
dataCy="dashboard-configmaps"
/>
<DashboardItem
value={secrets?.length}
isLoading={secretsQuery.isLoading || namespacesQuery.isLoading}
isRefetching={
secretsQuery.isRefetching || namespacesQuery.isRefetching
}
icon={Lock}
to="kubernetes.configurations"
type="ConfigMaps & Secrets"
pluralType="ConfigMaps & Secrets"
dataCy="dashboard-config"
params={{ tab: 'secrets' }}
type="Secret"
dataCy="dashboard-secrets"
/>
<DashboardItem
value={pvcs?.length}

View file

@ -4,7 +4,7 @@ interface Props {
showSystemResources: boolean;
}
export function ServicesDatatableDescription({ showSystemResources }: Props) {
export function SystemResourceDescription({ showSystemResources }: Props) {
return (
<div className="w-full">
{!showSystemResources && (

View file

@ -0,0 +1,13 @@
import { refreshableSettings, createPersistedStore } from '@@/datatables/types';
import {
systemResourcesSettings,
TableSettings,
} from './DefaultDatatableSettings';
export function createStore(storageKey: string) {
return createPersistedStore<TableSettings>(storageKey, 'name', (set) => ({
...refreshableSettings(set),
...systemResourcesSettings(set),
}));
}

View file

@ -373,18 +373,16 @@ export function IngressForm({
<div className="col-sm-12 p-0">
<TextTip color="blue">
Add a secret via{' '}
You may also use the{' '}
<Link
to="kubernetes.configurations"
to="kubernetes.secrets.new"
params={{ id: environmentID }}
className="text-primary"
target="_blank"
>
ConfigMaps &amp; Secrets
</Link>
{', '}
then select &apos;Reload TLS secrets&apos; above to
populate the dropdown with your changes.
Create secret
</Link>{' '}
function, and reload the dropdown.
</TextTip>
</div>
</div>

View file

@ -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}

View file

@ -31,24 +31,29 @@ function Cell({ row }: CellContext<Ingress, string>) {
return <div />;
}
return paths.map((path) => {
const isHttp = isHTTP(row.original.TLS || [], path.Host);
return (
<div key={`${path.Host}${path.Path}${path.ServiceName}:${path.Port}`}>
<span className="flex flex-nowrap items-center gap-1 px-2">
{link(path.Host, path.Path, isHttp)}
<Icon icon={ArrowRight} />
{`${path.ServiceName}:${path.Port}`}
{!path.HasService && (
<Badge type="warn" className="ml-1 gap-1">
<Icon icon={AlertTriangle} />
Service doesn&apos;t exist
</Badge>
)}
</span>
</div>
);
});
return (
<div className="flex flex-col gap-y-0.5">
{paths.map((path) => (
<div key={`${path.Host}${path.Path}${path.ServiceName}:${path.Port}`}>
<span className="flex flex-nowrap items-center gap-1 px-2">
{link(
path.Host,
path.Path,
isHTTP(row.original.TLS || [], path.Host)
)}
<Icon icon={ArrowRight} />
{`${path.ServiceName}:${path.Port}`}
{!path.HasService && (
<Badge type="warn" className="ml-1 gap-1">
<Icon icon={AlertTriangle} />
Service doesn&apos;t exist
</Badge>
)}
</span>
</div>
))}
</div>
);
}
function isHTTP(TLSs: TLS[], host: string) {

View file

@ -6,7 +6,7 @@ export function IngressesDatatableView() {
return (
<>
<PageHeader
title="Ingresses"
title="Ingress list"
breadcrumbs={[
{
label: 'Ingresses',

View file

@ -55,7 +55,7 @@ export function useIngress(
export function useIngresses(
environmentId: EnvironmentId,
namespaces: string[]
namespaces?: string[]
) {
return useQuery(
[
@ -67,6 +67,9 @@ export function useIngresses(
'ingress',
],
async () => {
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'),
}
);

View file

@ -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;
},
}
);

View file

@ -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) => (
<TableActions selectedItems={selectedRows} />
@ -70,7 +80,7 @@ export function ServicesDatatable() {
</TableSettingsMenu>
)}
description={
<ServicesDatatableDescription
<SystemResourceDescription
showSystemResources={tableState.showSystemResources || !isAdmin}
/>
}
@ -109,7 +119,10 @@ function TableActions({ selectedItems }: TableActionsProps) {
async function handleRemoveClick(services: SelectedService[]) {
const confirmed = await confirmDelete(
<>
<p>Are you sure you want to delete the selected service(s)?</p>
<p>{`Are you sure you want to remove the selected ${pluralize(
services.length,
'service'
)}?`}</p>
<ul className="pl-6">
{services.map((s, index) => (
<li key={index}>

View file

@ -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';

View file

@ -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';

View file

@ -1,5 +1,5 @@
import { createColumnHelper } from '@tanstack/react-table';
import { Service } from '../../types';
import { Service } from '../../../types';
export const columnHelper = createColumnHelper<Service>();

View file

@ -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);

View file

@ -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';

View file

@ -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';

View file

@ -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<TableSettings>(storageKey, 'name', (set) => ({

View file

@ -5,7 +5,7 @@ import { ServicesDatatable } from './ServicesDatatable';
export function ServicesView() {
return (
<>
<PageHeader title="Service List" breadcrumbs="Services" reload />
<PageHeader title="Service list" breadcrumbs="Services" reload />
<ServicesDatatable />
</>
);

View file

@ -1,23 +0,0 @@
import { saveAs } from 'file-saver';
import axios, { parseAxiosError } from '@/portainer/services/axios';
import { EnvironmentId } from '@/react/portainer/environments/types';
const baseUrl = 'kubernetes';
export async function downloadKubeconfigFile(environmentIds: EnvironmentId[]) {
try {
const { headers, data } = await axios.get<Blob>(`${baseUrl}/config`, {
params: { ids: JSON.stringify(environmentIds) },
responseType: 'blob',
headers: {
Accept: 'text/yaml',
},
});
const contentDispositionHeader = headers['content-disposition'];
const filename = contentDispositionHeader.replace('attachment;', '').trim();
saveAs(data, filename);
} catch (e) {
throw parseAxiosError(e as Error, '');
}
}

View file

@ -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.

View file

@ -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,

View file

@ -1,11 +0,0 @@
export * from './v1IngressClass';
export * from './v1ObjectMeta';
export type KubernetesApiListResponse<T> = {
apiVersion: string;
kind: string;
items: T;
metadata: {
resourceVersion?: string;
};
};

View file

@ -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;
};

View file

@ -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;
};

View file

@ -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,
}
);

View file

@ -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'
);
}
}