Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(ui): Display pretty cron schedule #5088

Merged
merged 4 commits into from
Feb 11, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions ui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
"chartjs-plugin-annotation": "^0.5.7",
"classnames": "^2.2.5",
"cron-parser": "^2.16.3",
"cronstrue": "^1.109.0",
"dagre": "^0.8.5",
"formik": "^2.1.2",
"history": "^4.7.2",
Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,9 @@
import {Page, SlidingPanel} from 'argo-ui';
import {Page, SlidingPanel, Ticker} from 'argo-ui';
import * as React from 'react';
import {Link, RouteComponentProps} from 'react-router-dom';
import * as models from '../../../../models';
import {uiUrl} from '../../../shared/base';
import {BasePage} from '../../../shared/components/base-page';
import {DurationFromNow} from '../../../shared/components/duration-panel';
import {ErrorNotice} from '../../../shared/components/error-notice';
import {ExampleManifests} from '../../../shared/components/example-manifests';
import {InfoIcon} from '../../../shared/components/fa-icons';
Expand All @@ -18,6 +17,7 @@ import {Footnote} from '../../../shared/footnote';
import {services} from '../../../shared/services';
import {Utils} from '../../../shared/utils';
import {CronWorkflowCreator} from '../cron-workflow-creator';
import {PrettySchedule} from '../pretty-schedule';

require('./cron-workflow-list.scss');

Expand Down Expand Up @@ -125,9 +125,10 @@ export class CronWorkflowList extends BasePage<RouteComponentProps<any>, State>
<div className='columns small-1' />
<div className='columns small-3'>NAME</div>
<div className='columns small-2'>NAMESPACE</div>
<div className='columns small-2'>SCHEDULE</div>
<div className='columns small-2'>CREATED</div>
<div className='columns small-2'>NEXT RUN</div>
<div className='columns small-1'>SCHEDULE</div>
<div className='columns small-3' />
<div className='columns small-1'>CREATED</div>
<div className='columns small-1'>NEXT RUN</div>
</div>
{this.state.cronWorkflows.map(w => (
<Link
Expand All @@ -137,12 +138,15 @@ export class CronWorkflowList extends BasePage<RouteComponentProps<any>, State>
<div className='columns small-1'>{w.spec.suspend ? <i className='fa fa-pause' /> : <i className='fa fa-clock' />}</div>
<div className='columns small-3'>{w.metadata.name}</div>
<div className='columns small-2'>{w.metadata.namespace}</div>
<div className='columns small-2'>{w.spec.schedule}</div>
<div className='columns small-2'>
<div className='columns small-1'>{w.spec.schedule}</div>
<div className='columns small-3'>
<PrettySchedule schedule={w.spec.schedule} />
</div>
<div className='columns small-1'>
<Timestamp date={w.metadata.creationTimestamp} />
</div>
<div className='columns small-2'>
{w.spec.suspend ? '' : <DurationFromNow getDate={() => getNextScheduledTime(w.spec.schedule, w.spec.timezone)} />}
<div className='columns small-1'>
{w.spec.suspend ? '' : <Ticker intervalMs={1000}>{() => <Timestamp date={getNextScheduledTime(w.spec.schedule, w.spec.timezone)} />}</Ticker>}
</div>
</Link>
))}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import * as React from 'react';
import {ConcurrencyPolicy, CronWorkflowSpec} from '../../../models';
import {NumberInput} from '../../shared/components/number-input';
import {TextInput} from '../../shared/components/text-input';
import {ScheduleValidator} from './schedule-validator';

export const CronWorkflowSpecEditor = ({onChange, spec}: {spec: CronWorkflowSpec; onChange: (spec: CronWorkflowSpec) => void}) => {
return (
Expand All @@ -12,6 +13,7 @@ export const CronWorkflowSpecEditor = ({onChange, spec}: {spec: CronWorkflowSpec
<div className='columns small-3'>Schedule</div>
<div className='columns small-9'>
<TextInput value={spec.schedule} onChange={schedule => onChange({...spec, schedule})} />
<ScheduleValidator schedule={spec.schedule} />
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this should be inline with the input, not in a new line. The spec string will never be long enough to occupy even a tenth of the length of the input box, so no reason we can't use the remaining length more effectively with the validator

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd like to leave this, it is a small panel and I don't think it should be optimised.

</div>
</div>
<div className='row white-box__details-row'>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {CronWorkflowSpec, CronWorkflowStatus} from '../../../models';
import {Timestamp} from '../../shared/components/timestamp';
import {ConditionsPanel} from '../../shared/conditions-panel';
import {WorkflowLink} from '../../workflows/components/workflow-link';
import {PrettySchedule} from './pretty-schedule';

const parser = require('cron-parser');
export const CronWorkflowStatusViewer = ({spec, status}: {spec: CronWorkflowSpec; status: CronWorkflowStatus}) => {
Expand All @@ -15,6 +16,14 @@ export const CronWorkflowStatusViewer = ({spec, status}: {spec: CronWorkflowSpec
<div className='white-box__details'>
{[
{title: 'Active', value: status.active ? getCronWorkflowActiveWorkflowList(status.active) : <i>No Workflows Active</i>},
{
title: 'Schedule',
value: (
<>
<code>{spec.schedule}</code> <PrettySchedule schedule={spec.schedule} />
</>
)
},
{title: 'Last Scheduled Time', value: <Timestamp date={status.lastScheduledTime} />},
{
title: 'Next Scheduled Time',
Expand Down
21 changes: 21 additions & 0 deletions ui/src/app/cron-workflows/components/pretty-schedule.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import React = require('react');

const x = require('cronstrue');

/*
https://github.com/bradymholt/cRonstrue
vs
https://github.com/robfig/cron

I think we must assume that these libraries (or any two libraries) will never be exactly the same and accept that
sometime it'll not work as expected. Therefore, we must let the user know about this.
*/

export const PrettySchedule = ({schedule}: {schedule: string}) => {
try {
const pretty = x.toString(schedule);
return <span title={pretty}>{pretty}</span>;
} catch (e) {
return <>{e.toString()}</>;
}
};
20 changes: 20 additions & 0 deletions ui/src/app/cron-workflows/components/schedule-validator.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import React = require('react');
import {SuccessIcon, WarningIcon} from '../../shared/components/fa-icons';

const x = require('cronstrue');

export const ScheduleValidator = ({schedule}: {schedule: string}) => {
try {
return (
<span>
<SuccessIcon /> {x.toString(schedule)}
</span>
);
} catch (e) {
return (
<span>
<WarningIcon /> Schedule maybe invalid: {e.toString()}
</span>
);
}
};
18 changes: 0 additions & 18 deletions ui/src/app/shared/components/duration-panel.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,4 @@
import * as moment from 'moment';
import * as React from 'react';
import Moment from 'react-moment';
import {NODE_PHASE, NodePhase} from '../../../models';
import {formatDuration} from '../duration';
import {ProgressLine} from './progress-line';
Expand All @@ -19,19 +17,3 @@ export const DurationPanel = (props: {phase: NodePhase; duration: number; estima
}
return <>{formatDuration(props.duration)}</>;
};

export const DurationFromNow = ({getDate, frequency = 1000}: {getDate: () => string; frequency?: number}) => {
const [now, setNow] = React.useState(moment());
const [date, setDate] = React.useState(getDate);
React.useEffect(() => {
const interval = setInterval(() => {
setNow(moment());
setDate(getDate);
}, frequency);
return () => {
clearInterval(interval);
};
}, []);

return <Moment duration={now} date={date} format='dd:hh:mm:ss' />;
};
1 change: 1 addition & 0 deletions ui/src/app/shared/components/fa-icons.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import * as React from 'react';

export const InfoIcon = () => <i className='fa fa-info-circle' />;
export const SuccessIcon = () => <i className='fa fa-check-circle status-icon--success' />;
export const WarningIcon = () => <i className='fa fa-exclamation-triangle status-icon--pending' />;
export const ErrorIcon = () => <i className='fa fa-exclamation-circle status-icon--error' />;
11 changes: 6 additions & 5 deletions ui/src/app/shared/components/timestamp.tsx
Original file line number Diff line number Diff line change
@@ -1,15 +1,16 @@
import {Ticker} from 'argo-ui';
import * as React from 'react';
import Moment from 'react-moment';
import {ago} from '../duration';

export const Timestamp = ({date}: {date: string | number}) => {
export const Timestamp = ({date}: {date: Date | string | number}) => {
return (
<span>
{date === null ? (
'-'
) : (
<Moment fromNow={true} withTitle={true}>
{date}
</Moment>
<span title={date.toString()}>
<Ticker intervalMs={1000}>{() => ago(new Date(date))}</Ticker>
</span>
)}
</span>
);
Expand Down
6 changes: 3 additions & 3 deletions ui/src/app/shared/cron.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
import parser = require('cron-parser');

export function getNextScheduledTime(schedule: string, tz: string): string {
let out = '';
export function getNextScheduledTime(schedule: string, tz: string): Date {
let out: Date;
try {
out = parser
.parseExpression(schedule, {utc: !tz, tz})
.next()
.toISOString();
.toDate();
} catch (e) {
// Do nothing
}
Expand Down
16 changes: 13 additions & 3 deletions ui/src/app/shared/duration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,8 @@ import * as models from '../../models';
* @param sigfigs Level of significant figures to show
*/

export function formatDuration(seconds: number, sigfigs?: number) {
let remainingSeconds = Math.round(seconds);
export function formatDuration(seconds: number, sigfigs = 1) {
let remainingSeconds = Math.abs(Math.round(seconds));
let formattedDuration = '';
const figs = [];

Expand Down Expand Up @@ -37,7 +37,7 @@ export function formatDuration(seconds: number, sigfigs?: number) {
formattedDuration += remainingSeconds + 's';
}

if (sigfigs && sigfigs <= figs.length) {
if (sigfigs <= figs.length) {
formattedDuration = '';
for (let i = 0; i < sigfigs; i++) {
formattedDuration += figs[i];
Expand Down Expand Up @@ -67,3 +67,13 @@ export function wfDuration(status: models.WorkflowStatus) {
}
return ((status.finishedAt ? new Date(status.finishedAt) : new Date()).getTime() - new Date(status.startedAt).getTime()) / 1000;
}

export const ago = (date: Date) => {
const secondsAgo = (new Date().getTime() - date.getTime()) / 1000;
const duration = formatDuration(secondsAgo);
if (secondsAgo < 0) {
return 'in ' + duration;
} else {
return duration + ' ago';
}
};
2 changes: 2 additions & 0 deletions ui/src/app/shared/services/workflows-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ export class WorkflowsService {
'items.metadata.creationTimestamp',
'items.metadata.labels',
'items.status.phase',
'items.status.message',
'items.status.finishedAt',
'items.status.startedAt',
'items.status.estimatedDuration',
Expand Down Expand Up @@ -86,6 +87,7 @@ export class WorkflowsService {
'result.object.metadata.uid',
'result.object.status.finishedAt',
'result.object.status.phase',
'result.object.status.message',
'result.object.status.startedAt',
'result.object.status.estimatedDuration',
'result.object.status.progress',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -270,10 +270,11 @@ export class WorkflowsList extends BasePage<RouteComponentProps<any>, State> {
<div className='row small-11'>
<div className='columns small-3'>NAME</div>
<div className='columns small-2'>NAMESPACE</div>
<div className='columns small-2'>STARTED</div>
<div className='columns small-2'>FINISHED</div>
<div className='columns small-1'>STARTED</div>
<div className='columns small-1'>FINISHED</div>
<div className='columns small-1'>DURATION</div>
<div className='columns small-1'>PROGRESS</div>
<div className='columns small-2'>MESSAGE</div>
<div className='columns small-1'>DETAILS</div>
</div>
</div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,16 +50,17 @@ export class WorkflowsRow extends React.Component<WorkflowsRowProps, WorkflowRow
<Link to={uiUrl(`workflows/${wf.metadata.namespace}/${wf.metadata.name}`)} className='row small-11'>
<div className='columns small-3'>{wf.metadata.name}</div>
<div className='columns small-2'>{wf.metadata.namespace}</div>
<div className='columns small-2'>
<div className='columns small-1'>
<Timestamp date={wf.status.startedAt} />
</div>
<div className='columns small-2'>
<div className='columns small-1'>
<Timestamp date={wf.status.finishedAt} />
</div>
<div className='columns small-1'>
<Ticker>{() => <DurationPanel phase={wf.status.phase} duration={wfDuration(wf.status)} estimatedDuration={wf.status.estimatedDuration} />}</Ticker>
</div>
<div className='columns small-1'>{wf.status.progress || '-'}</div>
<div className='columns small-2'>{wf.status.message || '-'}</div>
<div className='columns small-1'>
<div className='workflows-list__labels-container'>
<div
Expand Down
5 changes: 5 additions & 0 deletions ui/yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -2970,6 +2970,11 @@ cron-parser@^2.16.3:
is-nan "^1.3.0"
moment-timezone "^0.5.31"

cronstrue@^1.109.0:
version "1.109.0"
resolved "https://registry.yarnpkg.com/cronstrue/-/cronstrue-1.109.0.tgz#9c17e0c392eb32ae6678b5f02d65042cfcea554a"
integrity sha512-l4ShtlLtQmg5Nc7kDyD0VekVHPw91sLVn8I57TFssnDmIA9G8BObNrkDLMS34+k7N7WgjQE9hCQfv7Zfv+jUHg==

cross-fetch@^3.0.6:
version "3.0.6"
resolved "https://registry.yarnpkg.com/cross-fetch/-/cross-fetch-3.0.6.tgz#3a4040bc8941e653e0e9cf17f29ebcd177d3365c"
Expand Down