diff --git a/CHANGELOG.md b/CHANGELOG.md index 025ea7618..98aee1182 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,7 +15,8 @@ NOTE: As semantic versioning states all 0.y.z releases can contain breaking chan - [#32](https://github.com/kobsio/kobs/pull/32): Add support for container logs via the Kubernetes API. - [#34](https://github.com/kobsio/kobs/pull/34): Add a new Custom Resource Definition for Teams. Teams can be used to define the ownership for Applications and other Kubernetes resources. :warning: *Breaking change:* :warning: We are now using the `apiextensions.k8s.io/v1` API for the Custom Resource Definitions of kobs. - [#39](https://github.com/kobsio/kobs/pull/39): Add Opsgenie plugin to view alerts within an Application. -- [#40](https://github.com/kobsio/kobs/pull/39): Prometheus plugin metric name suggestions. +- [#40](https://github.com/kobsio/kobs/pull/40): Add metric name suggestions for Prometheus plugin. +- [#41](https://github.com/kobsio/kobs/pull/41): Add overview and Pods tab for resource details. ### Fixed diff --git a/app/src/components/resources/ResourceDetails.tsx b/app/src/components/resources/ResourceDetails.tsx index 9a81c3dd5..31a6149f7 100644 --- a/app/src/components/resources/ResourceDetails.tsx +++ b/app/src/components/resources/ResourceDetails.tsx @@ -31,6 +31,8 @@ import { Plugin as IPlugin } from 'proto/plugins_grpc_web_pb'; import Plugin from 'components/plugins/Plugin'; import ResourceEvents from 'components/resources/ResourceEvents'; import ResourceLogs from 'components/resources/ResourceLogs'; +import ResourceOverview from 'components/resources/ResourceOverview'; +import ResourcePods from 'components/resources/ResourcePods'; import Title from 'components/Title'; import { plugins as pluginsDefinition } from 'utils/plugins'; @@ -65,6 +67,27 @@ const interpolate = (str: string, resource: any, interpolator: string[] = ['<<', .join(''); }; +// getSelector is used to get the label selector for various resources as string. The returned string can be used in +// a Kubernetes API request to get the all pods, which are matching the label selector. +const getSelector = (resource: IRow): string => { + if (resource.props && resource.props.apiVersion && resource.props.kind) { + if ( + (resource.props.apiVersion === 'apps/v1' && resource.props.kind === 'Deployment') || + (resource.props.apiVersion === 'apps/v1' && resource.props.kind === 'DaemonSet') || + (resource.props.apiVersion === 'apps/v1' && resource.props.kind === 'StatefulSet') || + (resource.props.apiVersion === 'batch/v1' && resource.props.kind === 'Job') + ) { + return resource.props?.spec?.selector?.matchLabels + ? Object.keys(resource.props.spec.selector.matchLabels) + .map((key) => `${key}=${resource.props.spec.selector.matchLabels[key]}`) + .join(',') + : ''; + } + } + + return ''; +}; + interface IApplications { namespace?: string; name: string; @@ -84,9 +107,11 @@ const ResourceDetails: React.FunctionComponent = ({ resource, close, }: IResourceDetailsProps) => { - const [activeTab, setActiveTab] = useState('yaml'); + const [activeTab, setActiveTab] = useState('overview'); const pluginsContext = useContext(PluginsContext); + const podSelector = getSelector(resource); + let applications: IApplications[] = []; let teams: string[] = []; const plugins: IPlugin.AsObject[] = []; @@ -226,6 +251,12 @@ const ResourceDetails: React.FunctionComponent = ({ isFilled={true} mountOnEnter={true} > + Overview}> +
+ +
+
+ Yaml}>
@@ -233,6 +264,7 @@ const ResourceDetails: React.FunctionComponent = ({
+ Events}>
= ({ ) : null} + {podSelector ? ( + Pods}> +
+ +
+
+ ) : null} + {pluginsError ? ( Plugins}>
diff --git a/app/src/components/resources/ResourceOverview.tsx b/app/src/components/resources/ResourceOverview.tsx new file mode 100644 index 000000000..20d29e1e0 --- /dev/null +++ b/app/src/components/resources/ResourceOverview.tsx @@ -0,0 +1,152 @@ +import { + Card, + CardBody, + DescriptionList, + DescriptionListDescription, + DescriptionListGroup, + DescriptionListTerm, +} from '@patternfly/react-core'; +import { IRow } from '@patternfly/react-table'; +import React from 'react'; +import { V1OwnerReference } from '@kubernetes/client-node'; + +import Conditions from 'components/resources/overview/Conditions'; +import CronJob from 'components/resources/overview/CronJob'; +import DaemonSet from 'components/resources/overview/DaemonSet'; +import Deployment from 'components/resources/overview/Deployment'; +import Job from 'components/resources/overview/Job'; +import Pod from 'components/resources/overview/Pod'; +import StatefulSet from 'components/resources/overview/StatefulSet'; +import { timeDifference } from 'utils/helpers'; + +interface IResourceOverviewProps { + resource: IRow; +} + +// ResourceOverview is the overview tab for a resource. It shows the metadata of a resource in a clear way. We can also +// add some additional fields on a per resource basis. +const ResourceOverview: React.FunctionComponent = ({ resource }: IResourceOverviewProps) => { + // additions contains a React component with additional details for a resource. The default component just renders the + // conditions of a resource. + let additions = + resource.props && resource.props.status && resource.props.status.conditions ? ( + + ) : null; + + // Overwrite the additions for several resources. + if (resource.props && resource.props.apiVersion && resource.props.kind) { + if (resource.props.apiVersion === 'v1' && resource.props.kind === 'Pod') { + additions = ; + } else if (resource.props.apiVersion === 'apps/v1' && resource.props.kind === 'Deployment') { + additions = ( + + ); + } else if (resource.props.apiVersion === 'apps/v1' && resource.props.kind === 'DaemonSet') { + additions = ( + + ); + } else if (resource.props.apiVersion === 'apps/v1' && resource.props.kind === 'StatefulSet') { + additions = ( + + ); + } else if (resource.props.apiVersion === 'batch/v1beta1' && resource.props.kind === 'CronJob') { + additions = ; + } else if (resource.props.apiVersion === 'batch/v1' && resource.props.kind === 'Job') { + additions = ; + } + } + + return ( + + + + {resource.name?.title && ( + + Name + {resource.name?.title} + + )} + {resource.namespace?.title && ( + + Namespace + {resource.namespace?.title} + + )} + {resource.cluster?.title && ( + + Cluster + {resource.cluster?.title} + + )} + {resource.props?.metadata?.labels && ( + + Labels + + {Object.keys(resource.props?.metadata?.labels).map((key) => ( +
+ + {key}: {resource.props?.metadata?.labels[key]} + +
+ ))} +
+
+ )} + {resource.props?.metadata?.annotations && ( + + Annotations + + {Object.keys(resource.props?.metadata?.annotations).map((key) => ( +
+ + {key}: {resource.props?.metadata?.annotations[key]} + +
+ ))} +
+
+ )} + {resource.props?.metadata?.creationTimestamp && ( + + Age + + {timeDifference( + new Date().getTime(), + new Date(resource.props.metadata.creationTimestamp.toString()).getTime(), + )} + + ({resource.props.metadata.creationTimestamp}) + + + + )} + {resource.props?.metadata?.ownerReferences && ( + + Crontrolled By + + {resource.props?.metadata?.ownerReferences.map((owner: V1OwnerReference, index: number) => ( +
+ {owner.kind} + ({owner.name}) +
+ ))} +
+
+ )} + + {additions} +
+
+
+ ); +}; + +export default ResourceOverview; diff --git a/app/src/components/resources/ResourcePods.tsx b/app/src/components/resources/ResourcePods.tsx new file mode 100644 index 000000000..f49b402e9 --- /dev/null +++ b/app/src/components/resources/ResourcePods.tsx @@ -0,0 +1,76 @@ +import { Card, Flex, FlexItem } from '@patternfly/react-core'; +import { IRow, Table, TableBody, TableHeader } from '@patternfly/react-table'; +import React, { useCallback, useEffect, useState } from 'react'; + +import { ClustersPromiseClient, GetResourcesRequest, GetResourcesResponse } from 'proto/clusters_grpc_web_pb'; +import { emptyState, resources } from 'utils/resources'; +import { apiURL } from 'utils/constants'; + +// clustersService is the Clusters gRPC service, which is used to get a list of pods. +const clustersService = new ClustersPromiseClient(apiURL, null, null); + +interface IResourcePodsProps { + cluster: string; + namespace: string; + selector: string; +} + +// ResourcePods is the pods tab for various resources. It can be used to show the pods for a deployment, statefulset, +// etc. within the tabs. +const ResourcePods: React.FunctionComponent = ({ + cluster, + namespace, + selector, +}: IResourcePodsProps) => { + const [pods, setPods] = useState(emptyState(resources.pods.columns.length, '')); + + // fetchPods fetches the pods for the given cluster, namespace and label selector. + const fetchPods = useCallback(async () => { + try { + const getResourcesRequest = new GetResourcesRequest(); + getResourcesRequest.setClustersList([cluster]); + getResourcesRequest.setNamespacesList([namespace]); + getResourcesRequest.setPath(resources.pods.path); + getResourcesRequest.setResource(resources.pods.resource); + getResourcesRequest.setParamname('labelSelector'); + getResourcesRequest.setParam(selector); + + const getResourcesResponse: GetResourcesResponse = await clustersService.getResources(getResourcesRequest, null); + const resourceList = getResourcesResponse.getResourcesList(); + + if (resourceList.length === 1) { + setPods(resources.pods.rows(resourceList)); + } else { + setPods(emptyState(resources.pods.columns.length, '')); + } + } catch (err) { + setPods(emptyState(resources.pods.columns.length, err.message)); + } + }, [cluster, namespace, selector]); + + useEffect(() => { + fetchPods(); + }, [fetchPods]); + + return ( + + + + + + +
+
+
+
+ ); +}; + +export default ResourcePods; diff --git a/app/src/components/resources/Resources.tsx b/app/src/components/resources/Resources.tsx index 10adacff8..6ee22e957 100644 --- a/app/src/components/resources/Resources.tsx +++ b/app/src/components/resources/Resources.tsx @@ -8,7 +8,8 @@ import { PageSectionVariants, Title, } from '@patternfly/react-core'; -import React, { useContext, useState } from 'react'; +import React, { useContext, useEffect, useState } from 'react'; +import { useHistory, useLocation } from 'react-router-dom'; import { IRow } from '@patternfly/react-table'; import { ClustersContext, IClusterContext } from 'context/ClustersContext'; @@ -48,6 +49,27 @@ const checkRequiredData = ( return true; }; +// getResourcesFromSearch returns the clusters, namespaces, kind and selector for the resources state from a given +// search location. +export const getResourcesFromSearch = (search: string): IResources => { + const params = new URLSearchParams(search); + const clusters = params.getAll('cluster'); + const namespaces = params.getAll('namespace'); + const kinds = params.getAll('kind'); + const selector = params.get('selector'); + + return { + clusters: clusters, + resources: [ + { + kindsList: kinds, + namespacesList: namespaces, + selector: selector ? selector : '', + }, + ], + }; +}; + export interface IResources { clusters: string[]; resources: IApplicationResources.AsObject[]; @@ -56,10 +78,34 @@ export interface IResources { // Resources is the the component for the resources page. The user can select a list of clusters, resources and // namespaces he wants to retrieve from the toolbar. The resources are then displayed in a list of tables. const Resources: React.FunctionComponent = () => { + const history = useHistory(); + const location = useLocation(); const clustersContext = useContext(ClustersContext); - const [resources, setResources] = useState(undefined); + const [resources, setResources] = useState(getResourcesFromSearch(location.search)); const [selectedResource, setSelectedResource] = useState(undefined); + // changeResources is used to set the provided resources as query parameters in the current URL. This used, so that a + // user can share the URL of his view with other users. + const changeResources = (res: IResources): void => { + if (res.resources.length === 1) { + const c = res.clusters.map((cluster) => `&cluster=${cluster}`); + const n = res.resources[0].namespacesList.map((namespace) => `&namespace=${namespace}`); + const k = res.resources[0].kindsList.map((kind) => `&kind=${kind}`); + + history.push({ + pathname: location.pathname, + search: `?selector=${res.resources[0].selector}${c.length > 0 ? c.join('') : ''}${ + n.length > 0 ? n.join('') : '' + }${k.length > 0 ? k.join('') : ''}`, + }); + } + }; + + // useEffect is used to change the resources state everytime the location.search parameter changes. + useEffect(() => { + setResources(getResourcesFromSearch(location.search)); + }, [location.search]); + return ( @@ -67,7 +113,7 @@ const Resources: React.FunctionComponent = () => { Resources

{resourcesDescription}

- +
diff --git a/app/src/components/resources/ResourcesToolbar.tsx b/app/src/components/resources/ResourcesToolbar.tsx index d8c13a340..6e00bf195 100644 --- a/app/src/components/resources/ResourcesToolbar.tsx +++ b/app/src/components/resources/ResourcesToolbar.tsx @@ -17,18 +17,27 @@ import ToolbarItemNamespaces from 'components/resources/ToolbarItemNamespaces'; import ToolbarItemResources from 'components/resources/ToolbarItemResources'; interface IResourcesToolbarProps { + resources: IResources; setResources: (resources: IResources) => void; } // ResourcesToolbar is the toolbar where the user can select a list of clusters, namespaces and resource. When the user // clicks the search button the setResources function is called with the selected clusters, namespaces and resources. const ResourcesToolbar: React.FunctionComponent = ({ + resources, setResources, }: IResourcesToolbarProps) => { + const initialResources = resources.resources.length === 1 ? resources.resources[0] : undefined; const clustersContext = useContext(ClustersContext); - const [selectedClusters, setSelectedClusters] = useState([clustersContext.clusters[0]]); - const [selectedResources, setSelectedResources] = useState([]); - const [selectedNamespaces, setSelectedNamespaces] = useState([]); + const [selectedClusters, setSelectedClusters] = useState( + resources.clusters.length > 0 ? resources.clusters : [clustersContext.clusters[0]], + ); + const [selectedResources, setSelectedResources] = useState( + initialResources ? initialResources.kindsList : [], + ); + const [selectedNamespaces, setSelectedNamespaces] = useState( + initialResources ? initialResources.namespacesList : [], + ); // selectCluster adds/removes the given cluster to the list of selected clusters. When the cluster value is an empty // string the selected clusters list is cleared. diff --git a/app/src/components/resources/overview/Conditions.tsx b/app/src/components/resources/overview/Conditions.tsx new file mode 100644 index 000000000..9d0f8b7b0 --- /dev/null +++ b/app/src/components/resources/overview/Conditions.tsx @@ -0,0 +1,59 @@ +import { DescriptionListDescription, DescriptionListGroup, DescriptionListTerm, Tooltip } from '@patternfly/react-core'; +import { + V1DeploymentCondition, + V1JobCondition, + V1NodeCondition, + V1PersistentVolumeClaimCondition, + V1PodCondition, + V1ReplicaSetCondition, + V1ReplicationControllerCondition, + V1StatefulSetCondition, +} from '@kubernetes/client-node'; +import React from 'react'; + +export type TCondition = + | V1DeploymentCondition + | V1JobCondition + | V1NodeCondition + | V1PodCondition + | V1PersistentVolumeClaimCondition + | V1ReplicaSetCondition + | V1ReplicationControllerCondition + | V1StatefulSetCondition; + +interface IConditionsProps { + conditions: TCondition[]; +} + +const Conditions: React.FunctionComponent = ({ conditions }: IConditionsProps) => { + return ( + + Conditions + + {conditions.map( + (condition, index) => + condition.status === 'True' && ( + + {condition.lastTransitionTime} + {condition.reason ? ` - ${condition.reason}` : ''} + {condition.message ?
{condition.message}
: ''} +
+ } + > +
+ + {condition.type} + +
+ + ), + )} + + + ); +}; + +export default Conditions; diff --git a/app/src/components/resources/overview/Container.tsx b/app/src/components/resources/overview/Container.tsx new file mode 100644 index 000000000..e36e6ceec --- /dev/null +++ b/app/src/components/resources/overview/Container.tsx @@ -0,0 +1,189 @@ +import { DescriptionListDescription, DescriptionListGroup, DescriptionListTerm, Title } from '@patternfly/react-core'; +import { V1Container, V1ContainerState, V1ContainerStatus, V1EnvVarSource, V1Probe } from '@kubernetes/client-node'; +import React from 'react'; + +const getContainerStatus = (state: V1ContainerState): string => { + if (state.running) { + return `Started at ${state.running.startedAt}`; + } else if (state.waiting) { + return `Waiting: ${state.waiting.message}`; + } else if (state.terminated) { + return `Terminated with ${state.terminated.exitCode} at ${state.terminated.finishedAt}: ${state.terminated.reason}`; + } + + return 'Indeterminate'; +}; + +const getValueFrom = (valueFrom: V1EnvVarSource): string => { + if (valueFrom.configMapKeyRef) { + return `configMapKeyRef(${valueFrom.configMapKeyRef.name}: ${valueFrom.configMapKeyRef.key})`; + } + + if (valueFrom.fieldRef) { + return `fieldRef(${valueFrom.fieldRef.apiVersion}: ${valueFrom.fieldRef.fieldPath})`; + } + + if (valueFrom.secretKeyRef) { + return `secretKeyRef(${valueFrom.secretKeyRef.name}: ${valueFrom.secretKeyRef.key})`; + } + + return '-'; +}; + +const getPrope = (title: string, probe: V1Probe): JSX.Element => { + return ( + + {title} + + {probe.exec && ( +
+ + {probe.exec.command?.join(' ')} + +
+ )} + {probe.httpGet && ( +
+ + {probe.httpGet.scheme?.toLowerCase()}:// + {probe.httpGet.host}:{probe.httpGet.port} + {probe.httpGet.path} + +
+ )} + {probe.initialDelaySeconds && ( +
+ + delay={probe.initialDelaySeconds}s + +
+ )} + {probe.timeoutSeconds && ( +
+ + timeout={probe.timeoutSeconds}s + +
+ )} + {probe.periodSeconds && ( +
+ + period={probe.periodSeconds}s + +
+ )} + {probe.successThreshold && ( +
+ + #success={probe.successThreshold} + +
+ )} + {probe.failureThreshold && ( +
+ + #failure={probe.failureThreshold} + +
+ )} +
+
+ ); +}; + +interface IContainerProps { + container: V1Container; + containerStatus?: V1ContainerStatus; +} + +const Container: React.FunctionComponent = ({ container, containerStatus }: IContainerProps) => { + return ( + + + {container.name} + + + Status + + {containerStatus && containerStatus.state ? getContainerStatus(containerStatus.state) : '-'} + + + + Ready + + {containerStatus && containerStatus.ready ? 'True' : 'False'} + + + + Image + {container.image} + + {container.command && ( + + Command + {container.command} + + )} + {container.command && ( + + Command + {container.command.join(' ')} + + )} + {container.args && ( + + Command + {container.args.join(' ')} + + )} + {container.ports && ( + + Ports + + {container.ports.map((port, index) => ( +
+ + {port.containerPort} + {port.protocol ? `/${port.protocol}` : ''} + {port.name ? ` (${port.name})` : ''} + +
+ ))} +
+
+ )} + {container.env && ( + + Environment + + {container.env.map((env, index) => ( +
+ {env.name}: + + {env.value ? env.value : env.valueFrom ? getValueFrom(env.valueFrom) : '-'} + +
+ ))} +
+
+ )} + {container.volumeMounts && ( + + Mounts + + {container.volumeMounts.map((mount, index) => ( +
+ {mount.name}:{mount.mountPath} +
+ ))} +
+
+ )} + {container.livenessProbe && getPrope('Liveness Probe', container.livenessProbe)} + {container.readinessProbe && getPrope('Readiness Probe', container.readinessProbe)} + {container.startupProbe && getPrope('Startup Probe', container.startupProbe)} +
+ ); +}; + +export default Container; diff --git a/app/src/components/resources/overview/CronJob.tsx b/app/src/components/resources/overview/CronJob.tsx new file mode 100644 index 000000000..d7dbe75ec --- /dev/null +++ b/app/src/components/resources/overview/CronJob.tsx @@ -0,0 +1,56 @@ +import { DescriptionListDescription, DescriptionListGroup, DescriptionListTerm } from '@patternfly/react-core'; +import React from 'react'; +import { V1beta1CronJob } from '@kubernetes/client-node'; + +import { timeDifference } from 'utils/helpers'; + +interface ICronJobProps { + cronJob: V1beta1CronJob; +} + +const CronJob: React.FunctionComponent = ({ cronJob }: ICronJobProps) => { + return ( + + + Schedule + {cronJob.spec?.schedule ? cronJob.spec?.schedule : '-'} + + + Suspend + {cronJob.spec?.suspend ? 'True' : 'False'} + + + History Limit + +
+ + success={cronJob.spec?.successfulJobsHistoryLimit ? cronJob.spec?.successfulJobsHistoryLimit : 0} + +
+
+ + failed={cronJob.spec?.failedJobsHistoryLimit ? cronJob.spec?.failedJobsHistoryLimit : 0} + +
+
+
+ + Active + {cronJob.status?.active ? 'True' : 'False'} + + + Last Schedule + {cronJob.status?.lastScheduleTime ? ( + + {timeDifference(new Date().getTime(), new Date(cronJob.status.lastScheduleTime.toString()).getTime())} + ({cronJob.status.lastScheduleTime}) + + ) : ( + - + )} + +
+ ); +}; + +export default CronJob; diff --git a/app/src/components/resources/overview/DaemonSet.tsx b/app/src/components/resources/overview/DaemonSet.tsx new file mode 100644 index 000000000..34a96f884 --- /dev/null +++ b/app/src/components/resources/overview/DaemonSet.tsx @@ -0,0 +1,71 @@ +import { DescriptionListDescription, DescriptionListGroup, DescriptionListTerm } from '@patternfly/react-core'; +import React from 'react'; +import { V1DaemonSet } from '@kubernetes/client-node'; + +import Conditions from 'components/resources/overview/Conditions'; +import Selector from 'components/resources/overview/Selector'; + +interface IDaemonSetProps { + cluster: string; + namespace: string; + daemonSet: V1DaemonSet; +} + +const DaemonSet: React.FunctionComponent = ({ cluster, namespace, daemonSet }: IDaemonSetProps) => { + return ( + + + Replicas + +
+ + {daemonSet.status?.desiredNumberScheduled ? daemonSet.status?.desiredNumberScheduled : 0} desired + +
+
+ + {daemonSet.status?.currentNumberScheduled ? daemonSet.status?.currentNumberScheduled : 0} current + +
+
+ + {daemonSet.status?.numberMisscheduled ? daemonSet.status?.numberMisscheduled : 0} misscheduled + +
+
+ + {daemonSet.status?.numberReady ? daemonSet.status?.numberReady : 0} ready + +
+
+ + {daemonSet.status?.updatedNumberScheduled ? daemonSet.status?.updatedNumberScheduled : 0} updated + +
+
+ + {daemonSet.status?.numberAvailable ? daemonSet.status?.numberAvailable : 0} available + +
+
+ + {daemonSet.status?.numberUnavailable ? daemonSet.status?.numberUnavailable : 0} unavailable + +
+
+
+ {daemonSet.spec?.updateStrategy?.type && ( + + Strategy + {daemonSet.spec.updateStrategy.type} + + )} + {daemonSet.spec?.selector && ( + + )} + {daemonSet.status?.conditions && } +
+ ); +}; + +export default DaemonSet; diff --git a/app/src/components/resources/overview/Deployment.tsx b/app/src/components/resources/overview/Deployment.tsx new file mode 100644 index 000000000..76a2e621c --- /dev/null +++ b/app/src/components/resources/overview/Deployment.tsx @@ -0,0 +1,65 @@ +import { DescriptionListDescription, DescriptionListGroup, DescriptionListTerm } from '@patternfly/react-core'; +import React from 'react'; +import { V1Deployment } from '@kubernetes/client-node'; + +import Conditions from 'components/resources/overview/Conditions'; +import Selector from 'components/resources/overview/Selector'; + +interface IDeploymentProps { + cluster: string; + namespace: string; + deployment: V1Deployment; +} + +const Deployment: React.FunctionComponent = ({ + cluster, + namespace, + deployment, +}: IDeploymentProps) => { + return ( + + + Replicas + +
+ + {deployment.status?.replicas ? deployment.status?.replicas : 0} desired + +
+
+ + {deployment.status?.updatedReplicas ? deployment.status?.updatedReplicas : 0} updated + +
+
+ + {deployment.status?.readyReplicas ? deployment.status?.readyReplicas : 0} ready + +
+
+ + {deployment.status?.availableReplicas ? deployment.status?.availableReplicas : 0} available + +
+
+ + {deployment.status?.unavailableReplicas ? deployment.status?.unavailableReplicas : 0} unavailable + +
+
+
+ {deployment.spec?.strategy?.type && ( + + Strategy + {deployment.spec.strategy.type} + + )} + {deployment.spec?.selector && ( + + )} + {deployment.status?.conditions && } +
+ ); +}; + +export default Deployment; diff --git a/app/src/components/resources/overview/Job.tsx b/app/src/components/resources/overview/Job.tsx new file mode 100644 index 000000000..ec7268c9b --- /dev/null +++ b/app/src/components/resources/overview/Job.tsx @@ -0,0 +1,54 @@ +import { DescriptionListDescription, DescriptionListGroup, DescriptionListTerm } from '@patternfly/react-core'; +import React from 'react'; +import { V1Job } from '@kubernetes/client-node'; + +import Conditions from 'components/resources/overview/Conditions'; +import Selector from 'components/resources/overview/Selector'; + +interface IJobProps { + cluster: string; + namespace: string; + job: V1Job; +} + +const Job: React.FunctionComponent = ({ cluster, namespace, job }: IJobProps) => { + return ( + + + Completions + {job.spec?.completions ? job.spec?.completions : 0} + + + + Backoff Limit + {job.spec?.backoffLimit ? job.spec?.backoffLimit : 0} + + + + Active + {job.status?.active ? 'True' : 'False'} + + + + Status + +
+ + succeeded={job.status?.succeeded ? job.status?.succeeded : 0} + +
+
+ + failed={job.status?.failed ? job.status?.failed : 0} + +
+
+
+ + {job.spec?.selector && } + {job.status?.conditions && } +
+ ); +}; + +export default Job; diff --git a/app/src/components/resources/overview/Pod.tsx b/app/src/components/resources/overview/Pod.tsx new file mode 100644 index 000000000..6cd2b04e0 --- /dev/null +++ b/app/src/components/resources/overview/Pod.tsx @@ -0,0 +1,144 @@ +import { DescriptionListDescription, DescriptionListGroup, DescriptionListTerm, Tooltip } from '@patternfly/react-core'; +import { V1ContainerStatus, V1Pod } from '@kubernetes/client-node'; +import React from 'react'; +import yaml from 'js-yaml'; + +import Conditions from 'components/resources/overview/Conditions'; +import Container from 'components/resources/overview/Container'; + +const getContainerStatus = (name: string, status?: V1ContainerStatus[]): V1ContainerStatus | undefined => { + if (!status) { + return undefined; + } + + for (const s of status) { + if (s.name === name) { + return s; + } + } + + return undefined; +}; + +interface IPodProps { + pod: V1Pod; +} + +const Pod: React.FunctionComponent = ({ pod }: IPodProps) => { + const phase = pod.status && pod.status.phase ? pod.status.phase : 'Unknown'; + let reason = pod.status && pod.status.reason ? pod.status.reason : ''; + let shouldReady = 0; + let isReady = 0; + let restarts = 0; + + if (pod.status && pod.status.containerStatuses) { + for (const container of pod.status.containerStatuses) { + shouldReady = shouldReady + 1; + if (container.ready) { + isReady = isReady + 1; + } + + restarts = restarts + container.restartCount; + + if (container.state && container.state.waiting) { + reason = container.state.waiting.reason ? container.state.waiting.reason : ''; + break; + } + + if (container.state && container.state.terminated) { + reason = container.state.terminated.reason ? container.state.terminated.reason : ''; + break; + } + } + } + + return ( + + + Ready + + {isReady}/{shouldReady} + + + + Restarts + {restarts} + + + Status + {reason ? reason : phase} + + + Priority Class + + {pod.spec && pod.spec.priorityClassName ? pod.spec.priorityClassName : '-'} + + + + QoS Class + + {pod.status && pod.status.qosClass ? pod.status.qosClass : '-'} + + + + Node + {pod.spec?.nodeName ? pod.spec.nodeName : '-'} + + + Tolerations + + {pod.spec && pod.spec.tolerations ? ( + + {pod.spec.tolerations.map((toleration, index) => ( +
{yaml.dump(toleration)}
+ ))} +
+ } + > + {pod.spec.tolerations.length} + + ) : ( + 0 + )} + + + + Affinities + + {pod.spec && pod.spec.affinity ? ( + +
{yaml.dump(pod.spec.affinity)}
+ + } + > + Yes +
+ ) : ( + 'No' + )} +
+
+ {pod.status?.conditions && } + {pod.spec?.initContainers?.map((container, index) => ( + + ))} + {pod.spec?.containers?.map((container, index) => ( + + ))} + + ); +}; + +export default Pod; diff --git a/app/src/components/resources/overview/Selector.tsx b/app/src/components/resources/overview/Selector.tsx new file mode 100644 index 000000000..ee3374db0 --- /dev/null +++ b/app/src/components/resources/overview/Selector.tsx @@ -0,0 +1,37 @@ +import { DescriptionListDescription, DescriptionListGroup, DescriptionListTerm } from '@patternfly/react-core'; +import { Link } from 'react-router-dom'; +import React from 'react'; +import { V1LabelSelector } from '@kubernetes/client-node'; + +interface ISelectorProps { + cluster: string; + namespace: string; + selector: V1LabelSelector; +} + +const Selector: React.FunctionComponent = ({ cluster, namespace, selector }: ISelectorProps) => { + return ( + + Selector + + {selector.matchLabels && + Object.keys(selector.matchLabels).map((key) => ( + +
+ + {key}={selector.matchLabels ? selector.matchLabels[key] : ''} + +
+ + ))} +
+
+ ); +}; + +export default Selector; diff --git a/app/src/components/resources/overview/StatefulSet.tsx b/app/src/components/resources/overview/StatefulSet.tsx new file mode 100644 index 000000000..1505e3c4b --- /dev/null +++ b/app/src/components/resources/overview/StatefulSet.tsx @@ -0,0 +1,60 @@ +import { DescriptionListDescription, DescriptionListGroup, DescriptionListTerm } from '@patternfly/react-core'; +import React from 'react'; +import { V1StatefulSet } from '@kubernetes/client-node'; + +import Conditions from 'components/resources/overview/Conditions'; +import Selector from 'components/resources/overview/Selector'; + +interface IStatefulSetProps { + cluster: string; + namespace: string; + statefulSet: V1StatefulSet; +} + +const StatefulSet: React.FunctionComponent = ({ + cluster, + namespace, + statefulSet, +}: IStatefulSetProps) => { + return ( + + + Replicas + +
+ + {statefulSet.status?.replicas ? statefulSet.status?.replicas : 0} desired + +
+
+ + {statefulSet.status?.currentReplicas ? statefulSet.status?.currentReplicas : 0} current + +
+
+ + {statefulSet.status?.readyReplicas ? statefulSet.status?.readyReplicas : 0} ready + +
+
+ + {statefulSet.status?.updatedReplicas ? statefulSet.status?.updatedReplicas : 0} updated + +
+
+
+ {statefulSet.spec?.updateStrategy?.type && ( + + Strategy + {statefulSet.spec.updateStrategy.type} + + )} + {statefulSet.spec?.selector && ( + + )} + {statefulSet.status?.conditions && } +
+ ); +}; + +export default StatefulSet; diff --git a/app/src/plugins/prometheus/PrometheusPage.tsx b/app/src/plugins/prometheus/PrometheusPage.tsx index 465b45d4c..a2682f22a 100644 --- a/app/src/plugins/prometheus/PrometheusPage.tsx +++ b/app/src/plugins/prometheus/PrometheusPage.tsx @@ -13,8 +13,6 @@ import { useHistory, useLocation } from 'react-router-dom'; import { GetMetricsRequest, GetMetricsResponse, - MetricLookupRequest, - MetricLookupResponse, Metrics, PrometheusPromiseClient, Query, @@ -82,15 +80,6 @@ const PrometheusPage: React.FunctionComponent = ({ name, descr getMetricsRequest.setResolution(options.resolution); getMetricsRequest.setQueriesList(queries); - const metricLookupRequest = new MetricLookupRequest(); - metricLookupRequest.setName(name); - metricLookupRequest.setMatcher(options.queries[0]); - const metricLookupResponse: MetricLookupResponse = await prometheusService.metricLookup( - metricLookupRequest, - null, - ); - console.log(metricLookupResponse.getNamesList()); - const getMetricsResponse: GetMetricsResponse = await prometheusService.getMetrics(getMetricsRequest, null); setData({ error: '', isLoading: false, metrics: getMetricsResponse.toObject().metricsList }); } diff --git a/app/src/utils/resources.tsx b/app/src/utils/resources.tsx index 75a8ef9b8..4f14fbe74 100644 --- a/app/src/utils/resources.tsx +++ b/app/src/utils/resources.tsx @@ -71,7 +71,7 @@ export interface IResource { // resources is the list of Kubernetes standard resources. To generate the rows for a resource, we have to pass the // result from the gRPC API call to the rows function. The returned rows are mostly the same as they are also retunred // by kubectl. -const resources: IResources = { +export const resources: IResources = { // eslint-disable-next-line sort-keys cronjobs: { columns: ['Name', 'Namespace', 'Cluster', 'Schedule', 'Suspend', 'Active', 'Last Schedule', 'Age'], @@ -108,7 +108,7 @@ const resources: IResources = { lastSchedule, age, ], - props: cronJob, + props: { apiVersion: 'batch/v1beta1', kind: 'CronJob', ...cronJob }, }); } } @@ -172,7 +172,7 @@ const resources: IResources = { nodeSelector.join(', '), age, ], - props: daemonSet, + props: { apiVersion: 'apps/v1', kind: 'DaemonSet', ...daemonSet }, }); } } @@ -216,7 +216,7 @@ const resources: IResources = { available, age, ], - props: deployment, + props: { apiVersion: 'apps/v1', kind: 'Deployment', ...deployment }, }); } } @@ -262,7 +262,7 @@ const resources: IResources = { duration, age, ], - props: job, + props: { apiVersion: 'batch/v1', kind: 'Job', ...job }, }); } } @@ -410,7 +410,7 @@ const resources: IResources = { upToDate, age, ], - props: statefulSet, + props: { apiVersion: 'apps/v1', kind: 'StatefulSet', ...statefulSet }, }); } }