mirror of
https://github.com/portainer/portainer.git
synced 2025-07-24 15:59:41 +02:00
refactor(app): summary widget migration [EE-5351] (#8796)
* refactor(app): summary widget migration [EE-5351] * update converter and limit display --------- Co-authored-by: testa113 <testa113>
This commit is contained in:
parent
745bbb7d79
commit
98e6393274
23 changed files with 964 additions and 304 deletions
|
@ -0,0 +1,289 @@
|
|||
import { User, Clock, Edit, ChevronDown, ChevronUp } from 'lucide-react';
|
||||
import moment from 'moment';
|
||||
import { useState } from 'react';
|
||||
import { Pod } from 'kubernetes-types/core/v1';
|
||||
import { useCurrentStateAndParams } from '@uirouter/react';
|
||||
|
||||
import { Authorized } from '@/react/hooks/useUser';
|
||||
import { notifyError, notifySuccess } from '@/portainer/services/notifications';
|
||||
|
||||
import { DetailsTable } from '@@/DetailsTable';
|
||||
import { Badge } from '@@/Badge';
|
||||
import { Link } from '@@/Link';
|
||||
import { Button, LoadingButton } from '@@/buttons';
|
||||
|
||||
import { isSystemNamespace } from '../../namespaces/utils';
|
||||
import {
|
||||
appStackNameLabel,
|
||||
appKindToDeploymentTypeMap,
|
||||
appOwnerLabel,
|
||||
appDeployMethodLabel,
|
||||
appNoteAnnotation,
|
||||
} from '../constants';
|
||||
import {
|
||||
applicationIsKind,
|
||||
bytesToReadableFormat,
|
||||
getResourceRequests,
|
||||
getRunningPods,
|
||||
getTotalPods,
|
||||
isExternalApplication,
|
||||
} from '../utils';
|
||||
import {
|
||||
useApplication,
|
||||
usePatchApplicationMutation,
|
||||
} from '../application.queries';
|
||||
import { Application } from '../types';
|
||||
|
||||
export function ApplicationSummaryWidget() {
|
||||
const stateAndParams = useCurrentStateAndParams();
|
||||
const {
|
||||
params: {
|
||||
namespace,
|
||||
name,
|
||||
'resource-type': resourceType,
|
||||
endpointId: environmentId,
|
||||
},
|
||||
} = stateAndParams;
|
||||
const applicationQuery = useApplication(
|
||||
environmentId,
|
||||
namespace,
|
||||
name,
|
||||
resourceType
|
||||
);
|
||||
const application = applicationQuery.data;
|
||||
const systemNamespace = isSystemNamespace(namespace);
|
||||
const externalApplication = application && isExternalApplication(application);
|
||||
const applicationRequests = application && getResourceRequests(application);
|
||||
const applicationOwner = application?.metadata?.labels?.[appOwnerLabel];
|
||||
const applicationDeployMethod = getApplicationDeployMethod(application);
|
||||
const applicationNote =
|
||||
application?.metadata?.annotations?.[appNoteAnnotation];
|
||||
|
||||
const [isNoteOpen, setIsNoteOpen] = useState(true);
|
||||
const [applicationNoteFormValues, setApplicationNoteFormValues] = useState(
|
||||
applicationNote || ''
|
||||
);
|
||||
const patchApplicationMutation = usePatchApplicationMutation(
|
||||
environmentId,
|
||||
namespace,
|
||||
name
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="p-5">
|
||||
<DetailsTable>
|
||||
<tr>
|
||||
<td>Name</td>
|
||||
<td>
|
||||
<div
|
||||
className="flex items-center gap-x-2"
|
||||
data-cy="k8sAppDetail-appName"
|
||||
>
|
||||
{name}
|
||||
{externalApplication && !systemNamespace && (
|
||||
<Badge type="info">external</Badge>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Stack</td>
|
||||
<td data-cy="k8sAppDetail-stackName">
|
||||
{application?.metadata?.labels?.[appStackNameLabel] || '-'}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Namespace</td>
|
||||
<td>
|
||||
<div
|
||||
className="flex items-center gap-x-2"
|
||||
data-cy="k8sAppDetail-resourcePoolName"
|
||||
>
|
||||
<Link
|
||||
to="kubernetes.resourcePools.resourcePool"
|
||||
params={{ id: namespace }}
|
||||
>
|
||||
{namespace}
|
||||
</Link>
|
||||
{systemNamespace && <Badge type="info">system</Badge>}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Application type</td>
|
||||
<td data-cy="k8sAppDetail-appType">{application?.kind || '-'}</td>
|
||||
</tr>
|
||||
{application?.kind && (
|
||||
<tr>
|
||||
<td>Status</td>
|
||||
{applicationIsKind<Pod>('Pod', application) && (
|
||||
<td data-cy="k8sAppDetail-appType">
|
||||
{application?.status?.phase}
|
||||
</td>
|
||||
)}
|
||||
{!applicationIsKind<Pod>('Pod', application) && (
|
||||
<td data-cy="k8sAppDetail-appType">
|
||||
{appKindToDeploymentTypeMap[application.kind]}
|
||||
<code className="ml-1">
|
||||
{getRunningPods(application)}
|
||||
</code> / <code>{getTotalPods(application)}</code>
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
)}
|
||||
{(!!applicationRequests?.cpu || !!applicationRequests?.memoryBytes) && (
|
||||
<tr>
|
||||
<td>
|
||||
Resource reservations
|
||||
{!applicationIsKind<Pod>('Pod', application) && (
|
||||
<div className="text-muted small">per instance</div>
|
||||
)}
|
||||
</td>
|
||||
<td>
|
||||
{!!applicationRequests?.cpu && (
|
||||
<div data-cy="k8sAppDetail-cpuReservation">
|
||||
CPU {applicationRequests.cpu}
|
||||
</div>
|
||||
)}
|
||||
{!!applicationRequests?.memoryBytes && (
|
||||
<div data-cy="k8sAppDetail-memoryReservation">
|
||||
Memory{' '}
|
||||
{bytesToReadableFormat(applicationRequests.memoryBytes)}
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
<tr>
|
||||
<td>Creation</td>
|
||||
<td>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
{applicationOwner && (
|
||||
<span
|
||||
className="flex items-center gap-1"
|
||||
data-cy="k8sAppDetail-owner"
|
||||
>
|
||||
<User />
|
||||
{applicationOwner}
|
||||
</span>
|
||||
)}
|
||||
<span
|
||||
className="flex items-center gap-1"
|
||||
data-cy="k8sAppDetail-creationDate"
|
||||
>
|
||||
<Clock />
|
||||
{moment(application?.metadata?.creationTimestamp).format(
|
||||
'YYYY-MM-DD HH:mm:ss'
|
||||
)}
|
||||
</span>
|
||||
{(!externalApplication || systemNamespace) && (
|
||||
<span
|
||||
className="flex items-center gap-1"
|
||||
data-cy="k8sAppDetail-creationMethod"
|
||||
>
|
||||
<Clock />
|
||||
Deployed from {applicationDeployMethod}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colSpan={2}>
|
||||
<form className="form-horizontal">
|
||||
<div className="form-group">
|
||||
<div className="col-sm-12 vertical-center">
|
||||
<Edit /> Note
|
||||
<Button
|
||||
size="xsmall"
|
||||
type="button"
|
||||
color="light"
|
||||
data-cy="k8sAppDetail-expandNoteButton"
|
||||
onClick={() => setIsNoteOpen(!isNoteOpen)}
|
||||
>
|
||||
{isNoteOpen ? 'Collapse' : 'Expand'}
|
||||
{isNoteOpen ? <ChevronUp /> : <ChevronDown />}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isNoteOpen && (
|
||||
<>
|
||||
<div className="form-group">
|
||||
<div className="col-sm-12">
|
||||
<textarea
|
||||
className="form-control resize-y"
|
||||
name="application_note"
|
||||
id="application_note"
|
||||
value={applicationNoteFormValues}
|
||||
onChange={(e) =>
|
||||
setApplicationNoteFormValues(e.target.value)
|
||||
}
|
||||
rows={5}
|
||||
placeholder="Enter a note about this application..."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Authorized authorizations="K8sApplicationDetailsW">
|
||||
<div className="form-group">
|
||||
<div className="col-sm-12">
|
||||
<LoadingButton
|
||||
color="primary"
|
||||
size="small"
|
||||
className="!ml-0"
|
||||
type="button"
|
||||
onClick={() => patchApplicationNote()}
|
||||
disabled={
|
||||
// disable if there is no change to the note, or it's updating
|
||||
applicationNoteFormValues ===
|
||||
(applicationNote || '') ||
|
||||
patchApplicationMutation.isLoading
|
||||
}
|
||||
data-cy="k8sAppDetail-saveNoteButton"
|
||||
isLoading={patchApplicationMutation.isLoading}
|
||||
loadingText={applicationNote ? 'Updating' : 'Saving'}
|
||||
>
|
||||
{applicationNote ? 'Update' : 'Save'} note
|
||||
</LoadingButton>
|
||||
</div>
|
||||
</div>
|
||||
</Authorized>
|
||||
</>
|
||||
)}
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
</DetailsTable>
|
||||
</div>
|
||||
);
|
||||
|
||||
async function patchApplicationNote() {
|
||||
const path = `/metadata/annotations/${appNoteAnnotation}`;
|
||||
const value = applicationNoteFormValues;
|
||||
if (application?.kind) {
|
||||
try {
|
||||
await patchApplicationMutation.mutateAsync({
|
||||
appKind: application.kind,
|
||||
path,
|
||||
value,
|
||||
});
|
||||
notifySuccess('Success', 'Application successfully updated');
|
||||
} catch (error) {
|
||||
notifyError(
|
||||
`Failed to ${applicationNote ? 'update' : 'save'} note`,
|
||||
error as Error
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getApplicationDeployMethod(application?: Application) {
|
||||
if (!application?.metadata?.labels?.[appDeployMethodLabel])
|
||||
return 'application form';
|
||||
if (application?.metadata?.labels?.[appDeployMethodLabel] === 'content') {
|
||||
return 'manifest';
|
||||
}
|
||||
return application?.metadata?.labels?.[appDeployMethodLabel];
|
||||
}
|
1
app/react/kubernetes/applications/DetailsView/index.ts
Normal file
1
app/react/kubernetes/applications/DetailsView/index.ts
Normal file
|
@ -0,0 +1 @@
|
|||
export { ApplicationSummaryWidget } from './ApplicationSummaryWidget';
|
90
app/react/kubernetes/applications/application.queries.ts
Normal file
90
app/react/kubernetes/applications/application.queries.ts
Normal file
|
@ -0,0 +1,90 @@
|
|||
import { useMutation, useQuery } from 'react-query';
|
||||
|
||||
import { queryClient, withError } from '@/react-tools/react-query';
|
||||
import { EnvironmentId } from '@/react/portainer/environments/types';
|
||||
|
||||
import {
|
||||
getApplicationsForCluster,
|
||||
getApplication,
|
||||
patchApplication,
|
||||
} from './application.service';
|
||||
import { AppKind } from './types';
|
||||
|
||||
const queryKeys = {
|
||||
applicationsForCluster: (environmentId: EnvironmentId) => [
|
||||
'environments',
|
||||
environmentId,
|
||||
'kubernetes',
|
||||
'applications',
|
||||
],
|
||||
application: (
|
||||
environmentId: EnvironmentId,
|
||||
namespace: string,
|
||||
name: string
|
||||
) => [
|
||||
'environments',
|
||||
environmentId,
|
||||
'kubernetes',
|
||||
'applications',
|
||||
namespace,
|
||||
name,
|
||||
],
|
||||
};
|
||||
|
||||
// useQuery to get a list of all applications from an array of namespaces
|
||||
export function useApplicationsForCluster(
|
||||
environemtId: EnvironmentId,
|
||||
namespaces?: string[]
|
||||
) {
|
||||
return useQuery(
|
||||
queryKeys.applicationsForCluster(environemtId),
|
||||
() => namespaces && getApplicationsForCluster(environemtId, namespaces),
|
||||
{
|
||||
...withError('Unable to retrieve applications'),
|
||||
enabled: !!namespaces,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// useQuery to get an application by environmentId, namespace and name
|
||||
export function useApplication(
|
||||
environmentId: EnvironmentId,
|
||||
namespace: string,
|
||||
name: string,
|
||||
appKind?: AppKind
|
||||
) {
|
||||
return useQuery(
|
||||
queryKeys.application(environmentId, namespace, name),
|
||||
() => getApplication(environmentId, namespace, name, appKind),
|
||||
{
|
||||
...withError('Unable to retrieve application'),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// useQuery to patch an application by environmentId, namespace, name and patch payload
|
||||
export function usePatchApplicationMutation(
|
||||
environmentId: EnvironmentId,
|
||||
namespace: string,
|
||||
name: string
|
||||
) {
|
||||
return useMutation(
|
||||
({
|
||||
appKind,
|
||||
path,
|
||||
value,
|
||||
}: {
|
||||
appKind: AppKind;
|
||||
path: string;
|
||||
value: string;
|
||||
}) =>
|
||||
patchApplication(environmentId, namespace, appKind, name, path, value),
|
||||
{
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries(
|
||||
queryKeys.application(environmentId, namespace, name)
|
||||
);
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
268
app/react/kubernetes/applications/application.service.ts
Normal file
268
app/react/kubernetes/applications/application.service.ts
Normal file
|
@ -0,0 +1,268 @@
|
|||
import {
|
||||
DaemonSetList,
|
||||
StatefulSetList,
|
||||
DeploymentList,
|
||||
Deployment,
|
||||
DaemonSet,
|
||||
StatefulSet,
|
||||
} from 'kubernetes-types/apps/v1';
|
||||
|
||||
import axios, { parseAxiosError } from '@/portainer/services/axios';
|
||||
import { EnvironmentId } from '@/react/portainer/environments/types';
|
||||
import { isFulfilled } from '@/react/utils';
|
||||
|
||||
import { getPod, getPods, patchPod } from './pod.service';
|
||||
import { getNakedPods } from './utils';
|
||||
import { AppKind, Application, ApplicationList } from './types';
|
||||
|
||||
// This file contains services for Kubernetes apps/v1 resources (Deployments, DaemonSets, StatefulSets)
|
||||
|
||||
export async function getApplicationsForCluster(
|
||||
environmentId: EnvironmentId,
|
||||
namespaces: string[]
|
||||
) {
|
||||
try {
|
||||
const applications = await Promise.all(
|
||||
namespaces.map((namespace) =>
|
||||
getApplicationsForNamespace(environmentId, namespace)
|
||||
)
|
||||
);
|
||||
return applications.flat();
|
||||
} catch (e) {
|
||||
throw parseAxiosError(
|
||||
e as Error,
|
||||
'Unable to retrieve applications for cluster'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// get a list of all Deployments, DaemonSets, StatefulSets and naked pods (https://portainer.atlassian.net/browse/CE-2) in one namespace
|
||||
async function getApplicationsForNamespace(
|
||||
environmentId: EnvironmentId,
|
||||
namespace: string
|
||||
) {
|
||||
try {
|
||||
const [deployments, daemonSets, statefulSets, pods] = await Promise.all([
|
||||
getApplicationsByKind<DeploymentList>(
|
||||
environmentId,
|
||||
namespace,
|
||||
'Deployment'
|
||||
),
|
||||
getApplicationsByKind<DaemonSetList>(
|
||||
environmentId,
|
||||
namespace,
|
||||
'DaemonSet'
|
||||
),
|
||||
getApplicationsByKind<StatefulSetList>(
|
||||
environmentId,
|
||||
namespace,
|
||||
'StatefulSet'
|
||||
),
|
||||
getPods(environmentId, namespace),
|
||||
]);
|
||||
// find all pods which are 'naked' (not owned by a deployment, daemonset or statefulset)
|
||||
const nakedPods = getNakedPods(pods, deployments, daemonSets, statefulSets);
|
||||
return [...deployments, ...daemonSets, ...statefulSets, ...nakedPods];
|
||||
} catch (e) {
|
||||
throw parseAxiosError(
|
||||
e as Error,
|
||||
`Unable to retrieve applications in namespace ${namespace}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// if not known, get the type of an application (Deployment, DaemonSet, StatefulSet or naked pod) by name
|
||||
export async function getApplication(
|
||||
environmentId: EnvironmentId,
|
||||
namespace: string,
|
||||
name: string,
|
||||
appKind?: AppKind
|
||||
) {
|
||||
try {
|
||||
// if resourceType is known, get the application by type and name
|
||||
if (appKind) {
|
||||
switch (appKind) {
|
||||
case 'Deployment':
|
||||
case 'DaemonSet':
|
||||
case 'StatefulSet':
|
||||
return await getApplicationByKind(
|
||||
environmentId,
|
||||
namespace,
|
||||
appKind,
|
||||
name
|
||||
);
|
||||
case 'Pod':
|
||||
return await getPod(environmentId, namespace, name);
|
||||
default:
|
||||
throw new Error('Unknown resource type');
|
||||
}
|
||||
}
|
||||
|
||||
// if resourceType is not known, get the application by name and return the first one that is fulfilled
|
||||
const [deployment, daemonSet, statefulSet, pod] = await Promise.allSettled([
|
||||
getApplicationByKind<Deployment>(
|
||||
environmentId,
|
||||
namespace,
|
||||
'Deployment',
|
||||
name
|
||||
),
|
||||
getApplicationByKind<DaemonSet>(
|
||||
environmentId,
|
||||
namespace,
|
||||
'DaemonSet',
|
||||
name
|
||||
),
|
||||
getApplicationByKind<StatefulSet>(
|
||||
environmentId,
|
||||
namespace,
|
||||
'StatefulSet',
|
||||
name
|
||||
),
|
||||
getPod(environmentId, namespace, name),
|
||||
]);
|
||||
|
||||
if (isFulfilled(deployment)) {
|
||||
return deployment.value;
|
||||
}
|
||||
if (isFulfilled(daemonSet)) {
|
||||
return daemonSet.value;
|
||||
}
|
||||
if (isFulfilled(statefulSet)) {
|
||||
return statefulSet.value;
|
||||
}
|
||||
if (isFulfilled(pod)) {
|
||||
return pod.value;
|
||||
}
|
||||
throw new Error('Unable to retrieve application');
|
||||
} catch (e) {
|
||||
throw parseAxiosError(
|
||||
e as Error,
|
||||
`Unable to retrieve application ${name} in namespace ${namespace}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function patchApplication(
|
||||
environmentId: EnvironmentId,
|
||||
namespace: string,
|
||||
appKind: AppKind,
|
||||
name: string,
|
||||
path: string,
|
||||
value: string
|
||||
) {
|
||||
try {
|
||||
switch (appKind) {
|
||||
case 'Deployment':
|
||||
return await patchApplicationByKind<Deployment>(
|
||||
environmentId,
|
||||
namespace,
|
||||
appKind,
|
||||
name,
|
||||
path,
|
||||
value
|
||||
);
|
||||
case 'DaemonSet':
|
||||
return await patchApplicationByKind<DaemonSet>(
|
||||
environmentId,
|
||||
namespace,
|
||||
appKind,
|
||||
name,
|
||||
path,
|
||||
value
|
||||
);
|
||||
case 'StatefulSet':
|
||||
return await patchApplicationByKind<StatefulSet>(
|
||||
environmentId,
|
||||
namespace,
|
||||
appKind,
|
||||
name,
|
||||
path,
|
||||
value
|
||||
);
|
||||
case 'Pod':
|
||||
return await patchPod(environmentId, namespace, name, path, value);
|
||||
default:
|
||||
throw new Error(`Unknown application kind ${appKind}`);
|
||||
}
|
||||
} catch (e) {
|
||||
throw parseAxiosError(
|
||||
e as Error,
|
||||
`Unable to patch application ${name} in namespace ${namespace}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function patchApplicationByKind<T extends Application>(
|
||||
environmentId: EnvironmentId,
|
||||
namespace: string,
|
||||
appKind: 'Deployment' | 'DaemonSet' | 'StatefulSet',
|
||||
name: string,
|
||||
path: string,
|
||||
value: string
|
||||
) {
|
||||
const payload = [
|
||||
{
|
||||
op: 'replace',
|
||||
path,
|
||||
value,
|
||||
},
|
||||
];
|
||||
try {
|
||||
const res = await axios.patch<T>(
|
||||
buildUrl(environmentId, namespace, `${appKind}s`, name),
|
||||
payload,
|
||||
{
|
||||
headers: {
|
||||
'Content-Type': 'application/json-patch+json',
|
||||
},
|
||||
}
|
||||
);
|
||||
return res;
|
||||
} catch (e) {
|
||||
throw parseAxiosError(e as Error, 'Unable to patch application');
|
||||
}
|
||||
}
|
||||
|
||||
async function getApplicationByKind<T extends Application>(
|
||||
environmentId: EnvironmentId,
|
||||
namespace: string,
|
||||
appKind: 'Deployment' | 'DaemonSet' | 'StatefulSet',
|
||||
name: string
|
||||
) {
|
||||
try {
|
||||
const { data } = await axios.get<T>(
|
||||
buildUrl(environmentId, namespace, `${appKind}s`, name)
|
||||
);
|
||||
return data;
|
||||
} catch (e) {
|
||||
throw parseAxiosError(e as Error, 'Unable to retrieve application');
|
||||
}
|
||||
}
|
||||
|
||||
async function getApplicationsByKind<T extends ApplicationList>(
|
||||
environmentId: EnvironmentId,
|
||||
namespace: string,
|
||||
appKind: 'Deployment' | 'DaemonSet' | 'StatefulSet'
|
||||
) {
|
||||
try {
|
||||
const { data } = await axios.get<T>(
|
||||
buildUrl(environmentId, namespace, `${appKind}s`)
|
||||
);
|
||||
return data.items as T['items'];
|
||||
} catch (e) {
|
||||
throw parseAxiosError(e as Error, `Unable to retrieve ${appKind}s`);
|
||||
}
|
||||
}
|
||||
|
||||
function buildUrl(
|
||||
environmentId: EnvironmentId,
|
||||
namespace: string,
|
||||
appKind: 'Deployments' | 'DaemonSets' | 'StatefulSets',
|
||||
name?: string
|
||||
) {
|
||||
let baseUrl = `/endpoints/${environmentId}/kubernetes/apis/apps/v1/namespaces/${namespace}/${appKind.toLowerCase()}`;
|
||||
if (name) {
|
||||
baseUrl += `/${name}`;
|
||||
}
|
||||
return baseUrl;
|
||||
}
|
17
app/react/kubernetes/applications/constants.ts
Normal file
17
app/react/kubernetes/applications/constants.ts
Normal file
|
@ -0,0 +1,17 @@
|
|||
import { AppKind, DeploymentType } from './types';
|
||||
|
||||
// Portainer specific labels
|
||||
export const appStackNameLabel = 'io.portainer.kubernetes.application.stack';
|
||||
export const appOwnerLabel = 'io.portainer.kubernetes.application.owner';
|
||||
export const appNoteAnnotation = 'io.portainer.kubernetes.application.note';
|
||||
export const appDeployMethodLabel = 'io.portainer.kubernetes.application.kind';
|
||||
|
||||
export const appKindToDeploymentTypeMap: Record<
|
||||
AppKind,
|
||||
DeploymentType | null
|
||||
> = {
|
||||
Deployment: 'Replicated',
|
||||
StatefulSet: 'Replicated',
|
||||
DaemonSet: 'Global',
|
||||
Pod: null,
|
||||
};
|
66
app/react/kubernetes/applications/pod.service.ts
Normal file
66
app/react/kubernetes/applications/pod.service.ts
Normal file
|
@ -0,0 +1,66 @@
|
|||
import { Pod, PodList } from 'kubernetes-types/core/v1';
|
||||
|
||||
import axios, { parseAxiosError } from '@/portainer/services/axios';
|
||||
import { EnvironmentId } from '@/react/portainer/environments/types';
|
||||
|
||||
export async function getPods(environmentId: EnvironmentId, namespace: string) {
|
||||
try {
|
||||
const { data } = await axios.get<PodList>(
|
||||
buildUrl(environmentId, namespace)
|
||||
);
|
||||
return data.items;
|
||||
} catch (e) {
|
||||
throw parseAxiosError(e as Error, 'Unable to retrieve pods');
|
||||
}
|
||||
}
|
||||
|
||||
export async function getPod(
|
||||
environmentId: EnvironmentId,
|
||||
namespace: string,
|
||||
name: string
|
||||
) {
|
||||
try {
|
||||
const { data } = await axios.get<Pod>(
|
||||
buildUrl(environmentId, namespace, name)
|
||||
);
|
||||
return data;
|
||||
} catch (e) {
|
||||
throw parseAxiosError(e as Error, 'Unable to retrieve pod');
|
||||
}
|
||||
}
|
||||
|
||||
export async function patchPod(
|
||||
environmentId: EnvironmentId,
|
||||
namespace: string,
|
||||
name: string,
|
||||
path: string,
|
||||
value: string
|
||||
) {
|
||||
const payload = [
|
||||
{
|
||||
op: 'replace',
|
||||
path,
|
||||
value,
|
||||
},
|
||||
];
|
||||
try {
|
||||
return await axios.put<Pod>(
|
||||
buildUrl(environmentId, namespace, name),
|
||||
payload
|
||||
);
|
||||
} catch (e) {
|
||||
throw parseAxiosError(e as Error, 'Unable to update pod');
|
||||
}
|
||||
}
|
||||
|
||||
export function buildUrl(
|
||||
environmentId: EnvironmentId,
|
||||
namespace: string,
|
||||
name?: string
|
||||
) {
|
||||
let baseUrl = `/endpoints/${environmentId}/kubernetes/api/v1/namespaces/${namespace}/pods`;
|
||||
if (name) {
|
||||
baseUrl += `/${name}`;
|
||||
}
|
||||
return baseUrl;
|
||||
}
|
|
@ -1,21 +0,0 @@
|
|||
import { useQuery } from 'react-query';
|
||||
|
||||
import { EnvironmentId } from '@/react/portainer/environments/types';
|
||||
import { withError } from '@/react-tools/react-query';
|
||||
|
||||
import { getApplicationsListForCluster } from './service';
|
||||
|
||||
// useQuery to get a list of all applications from an array of namespaces
|
||||
export function useApplicationsForCluster(
|
||||
environemtId: EnvironmentId,
|
||||
namespaces?: string[]
|
||||
) {
|
||||
return useQuery(
|
||||
['environments', environemtId, 'kubernetes', 'applications'],
|
||||
() => namespaces && getApplicationsListForCluster(environemtId, namespaces),
|
||||
{
|
||||
...withError('Unable to retrieve applications'),
|
||||
enabled: !!namespaces,
|
||||
}
|
||||
);
|
||||
}
|
|
@ -1,141 +0,0 @@
|
|||
import { Pod, PodList } from 'kubernetes-types/core/v1';
|
||||
import {
|
||||
Deployment,
|
||||
DeploymentList,
|
||||
DaemonSet,
|
||||
DaemonSetList,
|
||||
StatefulSet,
|
||||
StatefulSetList,
|
||||
} from 'kubernetes-types/apps/v1';
|
||||
|
||||
import axios, { parseAxiosError } from '@/portainer/services/axios';
|
||||
import { EnvironmentId } from '@/react/portainer/environments/types';
|
||||
|
||||
export async function getApplicationsListForCluster(
|
||||
environmentId: EnvironmentId,
|
||||
namespaces: string[]
|
||||
) {
|
||||
try {
|
||||
const applications = await Promise.all(
|
||||
namespaces.map((namespace) =>
|
||||
getApplicationsListForNamespace(environmentId, namespace)
|
||||
)
|
||||
);
|
||||
return applications.flat();
|
||||
} catch (e) {
|
||||
throw parseAxiosError(
|
||||
e as Error,
|
||||
'Unable to retrieve applications for cluster'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// get a list of all Deployments, DaemonSets and StatefulSets in one namespace
|
||||
export async function getApplicationsListForNamespace(
|
||||
environmentId: EnvironmentId,
|
||||
namespace: string
|
||||
) {
|
||||
try {
|
||||
const [deployments, daemonSets, statefulSets, pods] = await Promise.all([
|
||||
getDeployments(environmentId, namespace),
|
||||
getDaemonSets(environmentId, namespace),
|
||||
getStatefulSets(environmentId, namespace),
|
||||
getPods(environmentId, namespace),
|
||||
]);
|
||||
// find all pods which are 'naked' (not owned by a deployment, daemonset or statefulset)
|
||||
const nakedPods = getNakedPods(pods, deployments, daemonSets, statefulSets);
|
||||
return [...deployments, ...daemonSets, ...statefulSets, ...nakedPods];
|
||||
} catch (e) {
|
||||
throw parseAxiosError(
|
||||
e as Error,
|
||||
`Unable to retrieve applications in namespace ${namespace}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function getDeployments(environmentId: EnvironmentId, namespace: string) {
|
||||
try {
|
||||
const { data } = await axios.get<DeploymentList>(
|
||||
buildUrl(environmentId, namespace, 'deployments')
|
||||
);
|
||||
return data.items;
|
||||
} catch (e) {
|
||||
throw parseAxiosError(e as Error, 'Unable to retrieve deployments');
|
||||
}
|
||||
}
|
||||
|
||||
async function getDaemonSets(environmentId: EnvironmentId, namespace: string) {
|
||||
try {
|
||||
const { data } = await axios.get<DaemonSetList>(
|
||||
buildUrl(environmentId, namespace, 'daemonsets')
|
||||
);
|
||||
return data.items;
|
||||
} catch (e) {
|
||||
throw parseAxiosError(e as Error, 'Unable to retrieve daemonsets');
|
||||
}
|
||||
}
|
||||
|
||||
async function getStatefulSets(
|
||||
environmentId: EnvironmentId,
|
||||
namespace: string
|
||||
) {
|
||||
try {
|
||||
const { data } = await axios.get<StatefulSetList>(
|
||||
buildUrl(environmentId, namespace, 'statefulsets')
|
||||
);
|
||||
return data.items;
|
||||
} catch (e) {
|
||||
throw parseAxiosError(e as Error, 'Unable to retrieve statefulsets');
|
||||
}
|
||||
}
|
||||
|
||||
async function getPods(environmentId: EnvironmentId, namespace: string) {
|
||||
try {
|
||||
const { data } = await axios.get<PodList>(
|
||||
`/endpoints/${environmentId}/kubernetes/api/v1/namespaces/${namespace}/pods`
|
||||
);
|
||||
return data.items;
|
||||
} catch (e) {
|
||||
throw parseAxiosError(e as Error, 'Unable to retrieve pods');
|
||||
}
|
||||
}
|
||||
|
||||
function buildUrl(
|
||||
environmentId: EnvironmentId,
|
||||
namespace: string,
|
||||
appResource: 'deployments' | 'daemonsets' | 'statefulsets'
|
||||
) {
|
||||
return `/endpoints/${environmentId}/kubernetes/apis/apps/v1/namespaces/${namespace}/${appResource}`;
|
||||
}
|
||||
|
||||
function getNakedPods(
|
||||
pods: Pod[],
|
||||
deployments: Deployment[],
|
||||
daemonSets: DaemonSet[],
|
||||
statefulSets: StatefulSet[]
|
||||
) {
|
||||
// naked pods are pods which are not owned by a deployment, daemonset, statefulset or replicaset
|
||||
// https://kubernetes.io/docs/concepts/configuration/overview/#naked-pods-vs-replicasets-deployments-and-jobs
|
||||
const appLabels = [
|
||||
...deployments.map((deployment) => deployment.spec?.selector.matchLabels),
|
||||
...daemonSets.map((daemonSet) => daemonSet.spec?.selector.matchLabels),
|
||||
...statefulSets.map(
|
||||
(statefulSet) => statefulSet.spec?.selector.matchLabels
|
||||
),
|
||||
];
|
||||
|
||||
const nakedPods = pods.filter((pod) => {
|
||||
const podLabels = pod.metadata?.labels;
|
||||
// if the pod has no labels, it is naked
|
||||
if (!podLabels) return true;
|
||||
// if the pod has labels, but no app labels, it is naked
|
||||
return !appLabels.some((appLabel) => {
|
||||
if (!appLabel) return false;
|
||||
return Object.entries(appLabel).every(
|
||||
([key, value]) => podLabels[key] === value
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
return nakedPods;
|
||||
}
|
21
app/react/kubernetes/applications/types.ts
Normal file
21
app/react/kubernetes/applications/types.ts
Normal file
|
@ -0,0 +1,21 @@
|
|||
import {
|
||||
DaemonSet,
|
||||
DaemonSetList,
|
||||
Deployment,
|
||||
DeploymentList,
|
||||
StatefulSet,
|
||||
StatefulSetList,
|
||||
} from 'kubernetes-types/apps/v1';
|
||||
import { Pod, PodList } from 'kubernetes-types/core/v1';
|
||||
|
||||
export type Application = Deployment | DaemonSet | StatefulSet | Pod;
|
||||
|
||||
export type ApplicationList =
|
||||
| DeploymentList
|
||||
| DaemonSetList
|
||||
| StatefulSetList
|
||||
| PodList;
|
||||
|
||||
export type AppKind = 'Deployment' | 'DaemonSet' | 'StatefulSet' | 'Pod';
|
||||
|
||||
export type DeploymentType = 'Replicated' | 'Global';
|
167
app/react/kubernetes/applications/utils.ts
Normal file
167
app/react/kubernetes/applications/utils.ts
Normal file
|
@ -0,0 +1,167 @@
|
|||
import { Deployment, DaemonSet, StatefulSet } from 'kubernetes-types/apps/v1';
|
||||
import { Pod } from 'kubernetes-types/core/v1';
|
||||
import filesizeParser from 'filesize-parser';
|
||||
|
||||
import { Application } from './types';
|
||||
import { appOwnerLabel } from './constants';
|
||||
|
||||
export function getNakedPods(
|
||||
pods: Pod[],
|
||||
deployments: Deployment[],
|
||||
daemonSets: DaemonSet[],
|
||||
statefulSets: StatefulSet[]
|
||||
) {
|
||||
// naked pods are pods which are not owned by a deployment, daemonset, statefulset or replicaset
|
||||
// https://kubernetes.io/docs/concepts/configuration/overview/#naked-pods-vs-replicasets-deployments-and-jobs
|
||||
const appLabels = [
|
||||
...deployments.map((deployment) => deployment.spec?.selector.matchLabels),
|
||||
...daemonSets.map((daemonSet) => daemonSet.spec?.selector.matchLabels),
|
||||
...statefulSets.map(
|
||||
(statefulSet) => statefulSet.spec?.selector.matchLabels
|
||||
),
|
||||
];
|
||||
|
||||
const nakedPods = pods.filter((pod) => {
|
||||
const podLabels = pod.metadata?.labels;
|
||||
// if the pod has no labels, it is naked
|
||||
if (!podLabels) return true;
|
||||
// if the pod has labels, but no app labels, it is naked
|
||||
return !appLabels.some((appLabel) => {
|
||||
if (!appLabel) return false;
|
||||
return Object.entries(appLabel).every(
|
||||
([key, value]) => podLabels[key] === value
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
return nakedPods;
|
||||
}
|
||||
|
||||
// type guard to check if an application is a deployment, daemonset statefulset or pod
|
||||
export function applicationIsKind<T extends Application>(
|
||||
appKind: 'Deployment' | 'DaemonSet' | 'StatefulSet' | 'Pod',
|
||||
application?: Application
|
||||
): application is T {
|
||||
return application?.kind === appKind;
|
||||
}
|
||||
|
||||
// the application is external if it has no owner label
|
||||
export function isExternalApplication(application: Application) {
|
||||
return !application.metadata?.labels?.[appOwnerLabel];
|
||||
}
|
||||
|
||||
function getDeploymentRunningPods(deployment: Deployment): number {
|
||||
const availableReplicas = deployment.status?.availableReplicas ?? 0;
|
||||
const totalReplicas = deployment.status?.replicas ?? 0;
|
||||
const unavailableReplicas = deployment.status?.unavailableReplicas ?? 0;
|
||||
return availableReplicas || totalReplicas - unavailableReplicas;
|
||||
}
|
||||
|
||||
function getDaemonSetRunningPods(daemonSet: DaemonSet): number {
|
||||
const numberAvailable = daemonSet.status?.numberAvailable ?? 0;
|
||||
const desiredNumberScheduled = daemonSet.status?.desiredNumberScheduled ?? 0;
|
||||
const numberUnavailable = daemonSet.status?.numberUnavailable ?? 0;
|
||||
return numberAvailable || desiredNumberScheduled - numberUnavailable;
|
||||
}
|
||||
|
||||
function getStatefulSetRunningPods(statefulSet: StatefulSet): number {
|
||||
return statefulSet.status?.readyReplicas ?? 0;
|
||||
}
|
||||
|
||||
export function getRunningPods(
|
||||
application: Deployment | DaemonSet | StatefulSet
|
||||
): number {
|
||||
switch (application.kind) {
|
||||
case 'Deployment':
|
||||
return getDeploymentRunningPods(application);
|
||||
case 'DaemonSet':
|
||||
return getDaemonSetRunningPods(application);
|
||||
case 'StatefulSet':
|
||||
return getStatefulSetRunningPods(application);
|
||||
default:
|
||||
throw new Error('Unknown application type');
|
||||
}
|
||||
}
|
||||
|
||||
export function getTotalPods(
|
||||
application: Deployment | DaemonSet | StatefulSet
|
||||
): number {
|
||||
switch (application.kind) {
|
||||
case 'Deployment':
|
||||
return application.status?.replicas ?? 0;
|
||||
case 'DaemonSet':
|
||||
return application.status?.desiredNumberScheduled ?? 0;
|
||||
case 'StatefulSet':
|
||||
return application.status?.replicas ?? 0;
|
||||
default:
|
||||
throw new Error('Unknown application type');
|
||||
}
|
||||
}
|
||||
|
||||
function parseCpu(cpu: string) {
|
||||
let res = parseInt(cpu, 10);
|
||||
if (cpu.endsWith('m')) {
|
||||
res /= 1000;
|
||||
} else if (cpu.endsWith('n')) {
|
||||
res /= 1000000000;
|
||||
}
|
||||
return res;
|
||||
}
|
||||
|
||||
// bytesToReadableFormat converts bytes to a human readable string (e.g. '1.5 GB'), assuming base 10
|
||||
// there's some discussion about whether base 2 or base 10 should be used for memory units
|
||||
// https://www.quora.com/Is-1-GB-equal-to-1024-MB-or-1000-MB
|
||||
export function bytesToReadableFormat(memoryBytes: number) {
|
||||
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||
let unitIndex = 0;
|
||||
let memoryValue = memoryBytes;
|
||||
while (memoryValue > 1000 && unitIndex < units.length) {
|
||||
memoryValue /= 1000;
|
||||
unitIndex++;
|
||||
}
|
||||
return `${memoryValue.toFixed(1)} ${units[unitIndex]}`;
|
||||
}
|
||||
|
||||
// getResourceRequests returns the total cpu and memory requests for all containers in an application
|
||||
export function getResourceRequests(application: Application) {
|
||||
const appContainers = applicationIsKind<Pod>('Pod', application)
|
||||
? application.spec?.containers
|
||||
: application.spec?.template.spec?.containers;
|
||||
|
||||
if (!appContainers) return null;
|
||||
|
||||
const requests = appContainers.reduce(
|
||||
(acc, container) => {
|
||||
const cpu = container.resources?.requests?.cpu;
|
||||
const memory = container.resources?.requests?.memory;
|
||||
if (cpu) acc.cpu += parseCpu(cpu);
|
||||
if (memory) acc.memoryBytes += filesizeParser(memory, { base: 10 });
|
||||
return acc;
|
||||
},
|
||||
{ cpu: 0, memoryBytes: 0 }
|
||||
);
|
||||
|
||||
return requests;
|
||||
}
|
||||
|
||||
// getResourceLimits returns the total cpu and memory limits for all containers in an application
|
||||
export function getResourceLimits(application: Application) {
|
||||
const appContainers = applicationIsKind<Pod>('Pod', application)
|
||||
? application.spec?.containers
|
||||
: application.spec?.template.spec?.containers;
|
||||
|
||||
if (!appContainers) return null;
|
||||
|
||||
const limits = appContainers.reduce(
|
||||
(acc, container) => {
|
||||
const cpu = container.resources?.limits?.cpu;
|
||||
const memory = container.resources?.limits?.memory;
|
||||
if (cpu) acc.cpu += parseCpu(cpu);
|
||||
if (memory) acc.memory += filesizeParser(memory, { base: 10 });
|
||||
return acc;
|
||||
},
|
||||
{ cpu: 0, memory: 0 }
|
||||
);
|
||||
|
||||
return limits;
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue