Unverified Commit 056651c7 authored by Joaquim Rocha's avatar Joaquim Rocha Committed by GitHub
Browse files

Merge pull request #402 from kinvolk/better-info-for-workloads

Better info for workloads
parents ee54c1a8 b7656764
Showing with 321 additions and 45 deletions
+321 -45
......@@ -10005,6 +10005,11 @@
"warning": "^4.0.3"
}
},
"cronstrue": {
"version": "1.123.0",
"resolved": "https://registry.npmjs.org/cronstrue/-/cronstrue-1.123.0.tgz",
"integrity": "sha512-hVu9yNYRYr+jj5KET1p7FaBxFwtCHM1ByffP9lZ6yJ6p53u4VEmzH8117v9PUydxWNzc8Eq+sCZEzsKcB3ckiA=="
},
"cross-env": {
"version": "7.0.3",
"resolved": "https://registry.npmjs.org/cross-env/-/cross-env-7.0.3.tgz",
......@@ -28,6 +28,7 @@
"@typescript-eslint/eslint-plugin": "^4.33.0",
"@typescript-eslint/parser": "^4.33.0",
"ansi-to-react": "^6.1.6",
"cronstrue": "^1.123.0",
"cross-env": "^7.0.3",
"eslint-config-prettier": "^8.3.0",
"eslint-plugin-import": "^2.24.2",
......
......@@ -119,11 +119,14 @@ interface MetadataDictGridProps {
[index: number]: string;
};
showKeys?: boolean;
gridProps?: {
[index: string]: any;
};
}
export function MetadataDictGrid(props: MetadataDictGridProps) {
const classes = useMetadataDisplayStyles({});
const { dict, showKeys = true } = props;
const { dict, showKeys = true, gridProps } = props;
const [expanded, setExpanded] = React.useState(false);
const defaultNumShown = 20;
......@@ -176,6 +179,7 @@ export function MetadataDictGrid(props: MetadataDictGridProps) {
style={{
maxWidth: '80%',
}}
{...gridProps}
>
{/* Limit the size to two entries until the user chooses to expand the whole section */}
{keys.slice(0, expanded ? keys.length : defaultNumShown).map((key, i) => (
......
import React from 'react';
import { useTranslation } from 'react-i18next';
import { useParams } from 'react-router-dom';
import DetailsViewPluginRenderer from '../../helpers/renderHelpers';
import CronJob from '../../lib/k8s/cronJob';
import Job from '../../lib/k8s/job';
import { DetailsGrid } from '../common/Resource';
import { JobsListRenderer } from '../job/List';
import { getLastScheduleTime, getSchedule } from './List';
export default function CronJobDetails() {
const { namespace, name } = useParams<{ namespace: string; name: string }>();
const { t, i18n } = useTranslation('glossary');
const [jobs, jobsError] = Job.useList();
function filterOwnedJobs(jobs?: Job[] | null) {
if (!jobs) {
return null;
}
return jobs.filter(job => {
type OwnerRef = {
name: string;
kind: string;
};
return !!job.metadata?.ownerReferences?.find(
(ownerRef: OwnerRef) => ownerRef.kind === 'CronJob' && ownerRef.name === name
);
});
}
const ownedJobs = filterOwnedJobs(jobs);
return (
<DetailsGrid
resourceType={CronJob}
name={name}
namespace={namespace}
extraInfo={item =>
item && [
{
name: t('Schedule'),
value: getSchedule(item, i18n.language),
},
{
name: t('Suspend'),
value: item.spec.suspend.toString(),
},
{
name: t('Starting deadline'),
value: `${item.spec.startingDeadlineSeconds}s`,
hide: !item.spec.startingDeadlineSeconds,
},
{
name: t('Last Schedule'),
value: getLastScheduleTime(item),
},
]
}
sectionsFunc={item => (
<>
{item && <JobsListRenderer jobs={ownedJobs} error={jobsError} />}
<DetailsViewPluginRenderer resource={item} />
</>
)}
/>
);
}
import cronstrue from 'cronstrue/i18n';
import React from 'react';
import { useTranslation } from 'react-i18next';
import CronJob from '../../lib/k8s/cronJob';
import { useFilterFunc } from '../../lib/util';
import { Link } from '../common';
import { DateLabel, HoverInfoLabel, Link } from '../common';
import { SectionBox } from '../common/SectionBox';
import SectionFilterHeader from '../common/SectionFilterHeader';
import SimpleTable from '../common/SimpleTable';
export default function CronJobList() {
const [cronJobs, error] = CronJob.useList();
const filterFunc = useFilterFunc();
const { t } = useTranslation('glossary');
export function getSchedule(cronJob: CronJob, locale: string) {
const { schedule } = cronJob.spec;
const described = schedule.startsWith('@') ? '' : cronstrue.toString(schedule, { locale });
return <HoverInfoLabel label={schedule} hoverInfo={described} />;
}
function getSchedule(cronJob: CronJob) {
const { schedule } = cronJob.spec;
if (!schedule.startsWith('@')) {
return t('frequent|never');
}
return schedule;
export function getLastScheduleTime(cronJob: CronJob) {
const { lastScheduleTime } = cronJob.status;
if (!lastScheduleTime) {
return '';
}
return <DateLabel date={lastScheduleTime} />;
}
function getLastScheduleTime(cronJob: CronJob) {
const { lastScheduleTime } = cronJob.status;
if (!lastScheduleTime) {
return t('frequent|N/A');
}
const oneDay = 24 * 60 * 60 * 1000;
return t('frequent|{{ age }} days', {
age: new Date().getTime() - new Date(lastScheduleTime).getTime() / oneDay,
});
}
export default function CronJobList() {
const [cronJobs, error] = CronJob.useList();
const filterFunc = useFilterFunc();
const { t, i18n } = useTranslation('glossary');
return (
<SectionBox title={<SectionFilterHeader title={t('Cron Jobs')} />}>
......@@ -57,11 +53,11 @@ export default function CronJobList() {
},
{
label: t('Schedule'),
getter: cronJob => getSchedule(cronJob),
getter: cronJob => getSchedule(cronJob, i18n.language),
},
{
label: t('Suspend'),
getter: cronJob => cronJob.spec.schedule.toString(),
getter: cronJob => cronJob.spec.suspend.toString(),
},
{
label: t('Last Schedule'),
......
......@@ -3,6 +3,7 @@ import { useTranslation } from 'react-i18next';
import { useParams } from 'react-router-dom';
import DetailsViewPluginRenderer from '../../helpers/renderHelpers';
import DaemonSet from '../../lib/k8s/daemonSet';
import { SectionBox, SimpleTable } from '../common';
import {
ContainersSection,
DetailsGrid,
......@@ -10,9 +11,63 @@ import {
OwnedPodsSection,
} from '../common/Resource';
interface TolerationsSection {
resource: DaemonSet;
t: (...args: any[]) => string;
}
function TolerationsSection(props: TolerationsSection) {
const { resource, t } = props;
if (!resource) {
return null;
}
const tolerations = resource.spec.template.spec?.tolerations || [];
function getEffectString(effect: string, seconds?: number) {
if (effect === 'NoExecute' && seconds === undefined) {
const secondsLabel = seconds === undefined ? 'forever' : `${seconds}s`;
return `${effect} (${secondsLabel})`;
}
return effect;
}
return (
<SectionBox title={t('Tolerations')}>
<SimpleTable
data={tolerations}
columns={[
{
label: t('frequent|Key'),
getter: toleration => toleration.key,
sort: true,
},
{
label: t('frequent|Operator'),
getter: toleration => toleration.operator,
sort: true,
},
{
label: t('frequent|Value'),
getter: toleration => toleration.value,
sort: true,
},
{
label: t('frequent|Effect'),
getter: toleration => getEffectString(toleration.effect, toleration.tolerationSeconds),
sort: true,
},
]}
/>
</SectionBox>
);
}
export default function DaemonSetDetails() {
const { namespace, name } = useParams<{ namespace: string; name: string }>();
const { t } = useTranslation('glossary');
const { t } = useTranslation(['glossary', 'frequent']);
return (
<DetailsGrid
......@@ -29,6 +84,10 @@ export default function DaemonSetDetails() {
name: t('Selector'),
value: <MetadataDictGrid dict={item.spec.selector.matchLabels || {}} />,
},
{
name: t('Node Selector'),
value: <MetadataDictGrid dict={item.spec.template.spec.nodeSelector || {}} />,
},
]
}
sectionsFunc={item => (
......@@ -36,6 +95,7 @@ export default function DaemonSetDetails() {
{item && (
<>
<OwnedPodsSection resource={item?.jsonData} />
<TolerationsSection resource={item} t={t} />
<ContainersSection resource={item?.jsonData} />
</>
)}
......
import alertIcon from '@iconify/icons-mdi/alert-outline';
import checkIcon from '@iconify/icons-mdi/check-bold';
import pauseIcon from '@iconify/icons-mdi/pause';
import Icon from '@iconify/react';
import { Box } from '@material-ui/core';
import React from 'react';
import { useTranslation } from 'react-i18next';
import { ApiError } from '../../lib/k8s/apiProxy';
import Job from '../../lib/k8s/job';
import { useFilterFunc } from '../../lib/util';
import { Link } from '../common';
import { LightTooltip, Link, StatusLabel, StatusLabelProps } from '../common';
import { SectionBox } from '../common/SectionBox';
import SectionFilterHeader from '../common/SectionFilterHeader';
import SimpleTable from '../common/SimpleTable';
export function makePodStatusLabel(job: Job) {
if (!job?.status?.conditions) {
return null;
}
const condition = job.status.conditions.find(
({ status }: { status: string }) => status === 'True'
);
const tooltip = '';
const conditionOptions = {
Failed: {
status: 'error',
icon: alertIcon,
},
Complete: {
status: 'success',
icon: checkIcon,
},
Suspended: {
status: '',
icon: pauseIcon,
},
};
const conditionInfo = conditionOptions[(condition.type as 'Complete' | 'Failed') || 'Suspended'];
return (
<LightTooltip title={tooltip} interactive>
<Box display="inline">
<StatusLabel status={conditionInfo.status as StatusLabelProps['status']}>
{condition.type}
<Box
aria-label="hidden"
display="inline"
paddingTop={1}
paddingLeft={0.5}
style={{ verticalAlign: 'text-top' }}
>
<Icon icon={conditionInfo.icon} width="1.2rem" height="1.2rem" />
</Box>
</StatusLabel>
</Box>
</LightTooltip>
);
}
export default function JobsList() {
const [jobs, error] = Job.useList();
const filterFunc = useFilterFunc();
return <JobsListRenderer jobs={jobs} error={error} />;
}
export interface JobsListRendererProps {
jobs?: Job[] | null;
error?: ApiError | null;
}
export function JobsListRenderer(props: JobsListRendererProps) {
const { jobs = null, error } = props;
const { t } = useTranslation('glossary');
const filterFunc = useFilterFunc();
function getCompletions(job: Job) {
return `${job.spec.completions}/${job.spec.parallelism}`;
......@@ -24,15 +88,6 @@ export default function JobsList() {
return parallelismSorted;
}
function getCondition(job: Job) {
const { conditions } = job.status;
if (!conditions) {
return null;
}
return conditions.find(({ status }: { status: string }) => status === 'True').type;
}
return (
<SectionBox title={<SectionFilterHeader title={t('Jobs')} />}>
<SimpleTable
......@@ -64,7 +119,7 @@ export default function JobsList() {
},
{
label: t('Conditions'),
getter: job => getCondition(job),
getter: job => makePodStatusLabel(job),
},
{
label: t('frequent|Age'),
......
......@@ -16,7 +16,7 @@ import Pod from '../../lib/k8s/pod';
import { SectionBox, SimpleTable } from '../common';
import Link from '../common/Link';
import { LogViewer, LogViewerProps } from '../common/LogViewer';
import { ContainersSection, DetailsGrid } from '../common/Resource';
import { ConditionsSection, ContainersSection, DetailsGrid } from '../common/Resource';
import Terminal from '../common/Terminal';
import { makePodStatusLabel } from './List';
......@@ -207,6 +207,7 @@ export default function PodDetails() {
sectionsFunc={item =>
item && (
<>
<ConditionsSection resource={item?.jsonData} />
<ContainersSection resource={item?.jsonData} />
<VolumeDetails volumes={item?.jsonData?.spec.volumes} />
<PodLogViewer
......
......@@ -22,7 +22,7 @@ export function makePodStatusLabel(pod: Pod) {
status = 'error';
} else if (phase === 'Succeeded' || phase === 'Running') {
const readyCondition = pod.status.conditions.find(condition => condition.type === 'Ready');
if (readyCondition?.status === 'True') {
if (readyCondition?.status === 'True' || phase === 'Succeeded') {
status = 'success';
} else {
status = 'warning';
......
import React from 'react';
import { useTranslation } from 'react-i18next';
import { useParams } from 'react-router-dom';
import DetailsViewPluginRenderer from '../../helpers/renderHelpers';
import { StringDict } from '../../lib/k8s/cluster';
import StatefulSet from '../../lib/k8s/statefulSet';
import { DetailsGrid, MetadataDictGrid } from '../common/Resource';
import {
ContainersSection,
DetailsGrid,
MetadataDictGrid,
OwnedPodsSection,
} from '../common/Resource';
export default function StatefulSetDetails() {
const { namespace, name } = useParams<{ namespace: string; name: string }>();
......@@ -26,6 +32,17 @@ export default function StatefulSetDetails() {
},
]
}
sectionsFunc={item => (
<>
{item && (
<>
<OwnedPodsSection resource={item?.jsonData} />
<ContainersSection resource={item?.jsonData} />
</>
)}
<DetailsViewPluginRenderer resource={item} />
</>
)}
/>
);
}
......@@ -2,7 +2,7 @@ import React from 'react';
import { useTranslation } from 'react-i18next';
import { NavLinkProps, useLocation, useParams } from 'react-router-dom';
import DetailsViewPluginRenderer from '../../helpers/renderHelpers';
import { KubeObject } from '../../lib/k8s/cluster';
import { KubeObject, Workload } from '../../lib/k8s/cluster';
import {
ConditionsSection,
ContainersSection,
......@@ -21,6 +21,63 @@ export default function WorkloadDetails(props: WorkloadDetailsProps) {
const { workloadKind } = props;
const { t } = useTranslation('glossary');
function renderUpdateStrategy(item: Workload) {
if (!item?.spec?.strategy) {
return null;
}
if (item.spec.strategy.type === 'RollingUpdate') {
const rollingUpdate = item.spec.strategy.rollingUpdate;
return t('RollingUpdate. Max unavailable: {{ maxUnavailable }}, max surge: {{ maxSurge }}', {
maxUnavailable: rollingUpdate.maxUnavailable,
maxSurge: rollingUpdate.maxSurge,
});
}
return item.spec.strategy.type;
}
function showReplicas(item: Workload) {
return (
item.kind === 'Deployment' &&
(item.spec?.status?.replicas !== undefined || item.spec?.replicas !== undefined)
);
}
function renderReplicas(item: Workload) {
if (!showReplicas(item)) {
return null;
}
let values: { [key: string]: string } = {
[t('Desired')]: item.spec.replicas,
[t('Ready')]: item.status.readyReplicas,
[t('Up to date')]: item.status.updatedReplicas,
[t('Available')]: item.status.availableReplicas,
[t('Total')]: item.status.replicas,
};
const validEntries = Object.entries(values).filter(
([key]: string[]) => values[key] !== undefined
);
values = Object.fromEntries(validEntries);
if (Object.values(values).length === 0) {
return null;
}
return (
<MetadataDictGrid
dict={values}
gridProps={{
direction: 'column',
justifyContent: 'flex-start',
alignItems: 'flex-start',
}}
/>
);
}
return (
<DetailsGrid
resourceType={workloadKind}
......@@ -31,7 +88,7 @@ export default function WorkloadDetails(props: WorkloadDetailsProps) {
item && [
{
name: t('Strategy Type'),
value: item.spec.strategy && item.spec.strategy.type,
value: renderUpdateStrategy(item),
hide: !item.spec.strategy,
},
{
......@@ -42,6 +99,11 @@ export default function WorkloadDetails(props: WorkloadDetailsProps) {
/>
),
},
{
name: t('Replicas'),
value: renderReplicas(item),
hide: !showReplicas(item),
},
]
}
sectionsFunc={item => (
......
......@@ -66,7 +66,7 @@ export interface KubeObjectIface<T extends KubeObjectInterface | KubeEvent> {
useList: (
onList?: (...arg: any[]) => any
) => [any[], ApiError | null, (items: any[]) => void, (err: ApiError | null) => void];
getErrorMessage: (err: ApiError | null) => string | null;
getErrorMessage: (err?: ApiError | null) => string | null;
new (json: T): any;
className: string;
[prop: string]: any;
......
......@@ -3,6 +3,12 @@ import { KubeObjectInterface, makeKubeObject } from './cluster';
export interface KubeCronJob extends KubeObjectInterface {
spec: {
suspend: boolean;
schedule: string;
startingDeadlineSeconds: string;
successfulJobsHistoryLimit: string;
failedJobsHistoryLimit: string;
concurrencyPolicy: string;
[otherProps: string]: any;
};
status: {
......
......@@ -11,6 +11,7 @@ import ConfigMapList from '../components/configmap/List';
import CustomResourceDetails from '../components/crd/CustomResourceDetails';
import CustomResourceDefinitionDetails from '../components/crd/Details';
import CustomResourceDefinitionList from '../components/crd/List';
import CronJobDetails from '../components/cronjob/Details';
import CronJobList from '../components/cronjob/List';
import DaemonSetDetails from '../components/daemonset/Details';
import DaemonSetList from '../components/daemonset/List';
......@@ -48,7 +49,6 @@ import WorkloadDetails from '../components/workload/Details';
import WorkloadOverview from '../components/workload/Overview';
import LocaleSelect from '../i18n/LocaleSelect/LocaleSelect';
import store from '../redux/stores/store';
import CronJob from './k8s/cronJob';
import Deployment from './k8s/deployment';
import Job from './k8s/job';
import ReplicaSet from './k8s/replicaSet';
......@@ -194,7 +194,7 @@ export const ROUTES: {
path: '/cronjobs/:namespace/:name',
exact: true,
sidebar: 'CronJobs',
component: () => <WorkloadDetails workloadKind={CronJob} />,
component: () => <CronJobDetails />,
},
Pods: {
path: '/pods',
......
Supports Markdown
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment