You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@aurora.apache.org by dm...@apache.org on 2017/10/10 17:15:00 UTC
aurora git commit: Implement Update and Updates pages in React.
Repository: aurora
Updated Branches:
refs/heads/master 2df250e3e -> 4a1fba3c8
Implement Update and Updates pages in React.
Reviewed at https://reviews.apache.org/r/62763/
Project: http://git-wip-us.apache.org/repos/asf/aurora/repo
Commit: http://git-wip-us.apache.org/repos/asf/aurora/commit/4a1fba3c
Tree: http://git-wip-us.apache.org/repos/asf/aurora/tree/4a1fba3c
Diff: http://git-wip-us.apache.org/repos/asf/aurora/diff/4a1fba3c
Branch: refs/heads/master
Commit: 4a1fba3c8ae1dd9a302590f3fdfcc8852636c0bf
Parents: 2df250e
Author: David McLaughlin <da...@dmclaughlin.com>
Authored: Tue Oct 10 10:14:50 2017 -0700
Committer: David McLaughlin <da...@dmclaughlin.com>
Committed: Tue Oct 10 10:14:50 2017 -0700
----------------------------------------------------------------------
ui/.eslintrc | 10 +-
ui/package.json | 6 +-
ui/src/main/js/components/InstanceViz.js | 17 ++
ui/src/main/js/components/Layout.js | 18 +-
ui/src/main/js/components/Pagination.js | 10 +-
ui/src/main/js/components/TaskConfig.js | 5 +
ui/src/main/js/components/Time.js | 6 +
ui/src/main/js/components/UpdateConfig.js | 12 +
ui/src/main/js/components/UpdateDetails.js | 28 ++
.../main/js/components/UpdateInstanceEvents.js | 101 +++++++
.../main/js/components/UpdateInstanceSummary.js | 22 ++
ui/src/main/js/components/UpdateList.js | 47 +++
ui/src/main/js/components/UpdateSettings.js | 30 ++
ui/src/main/js/components/UpdateStateMachine.js | 21 ++
ui/src/main/js/components/UpdateStatus.js | 22 ++
ui/src/main/js/components/UpdateTime.js | 33 +++
ui/src/main/js/components/UpdateTitle.js | 26 ++
.../js/components/__tests__/InstanceViz-test.js | 41 +++
.../js/components/__tests__/Pagination-test.js | 25 ++
.../__tests__/UpdateInstanceEvents-test.js | 40 +++
.../js/components/__tests__/UpdateList-test.js | 17 ++
.../components/__tests__/UpdateStatus-test.js | 23 ++
ui/src/main/js/index.js | 9 +-
ui/src/main/js/pages/Update.js | 54 ++++
ui/src/main/js/pages/Updates.js | 66 +++++
ui/src/main/js/pages/__tests__/Update-test.js | 56 ++++
ui/src/main/js/pages/__tests__/Updates-test.js | 33 +++
ui/src/main/js/test-utils/UpdateBuilders.js | 86 ++++++
ui/src/main/js/utils/Common.js | 16 +
ui/src/main/js/utils/Thrift.js | 43 ++-
ui/src/main/js/utils/Update.js | 163 +++++++++++
ui/src/main/js/utils/__tests__/Update-test.js | 291 +++++++++++++++++++
ui/src/main/sass/app.scss | 5 +-
ui/src/main/sass/components/_instance-viz.scss | 78 +++++
ui/src/main/sass/components/_layout.scss | 85 ++++++
ui/src/main/sass/components/_update-list.scss | 38 +++
ui/src/main/sass/components/_update-page.scss | 151 ++++++++++
ui/test-setup.js | 37 ++-
38 files changed, 1759 insertions(+), 12 deletions(-)
----------------------------------------------------------------------
http://git-wip-us.apache.org/repos/asf/aurora/blob/4a1fba3c/ui/.eslintrc
----------------------------------------------------------------------
diff --git a/ui/.eslintrc b/ui/.eslintrc
index 84a6d37..f7ac075 100644
--- a/ui/.eslintrc
+++ b/ui/.eslintrc
@@ -10,10 +10,16 @@
},
"globals": {
"ACTIVE_STATES": true,
- "Thrift": true,
+ "ACTIVE_JOB_UPDATE_STATES": true,
+ "JobKey": true,
+ "JobUpdateAction": true,
+ "JobUpdateKey": true,
+ "JobUpdateQuery": true,
+ "JobUpdateStatus": true,
"ReadOnlySchedulerClient": true,
"ScheduleStatus": true,
- "TaskQuery": true
+ "TaskQuery": true,
+ "Thrift": true
},
"plugins": [
"chai-friendly"
http://git-wip-us.apache.org/repos/asf/aurora/blob/4a1fba3c/ui/package.json
----------------------------------------------------------------------
diff --git a/ui/package.json b/ui/package.json
index 6e8ad7a..f4532df 100644
--- a/ui/package.json
+++ b/ui/package.json
@@ -4,6 +4,7 @@
"description": "UI project for Apache Aurora",
"main": "index.js",
"dependencies": {
+ "es6-shim": "^0.35.3",
"moment": "^2.18.1",
"react": "^16.0.0",
"react-dom": "^16.0.0",
@@ -40,7 +41,10 @@
"webpack": "^2.6.1"
},
"jest": {
- "moduleDirectories": ["./src/main/js", "node_modules"],
+ "moduleDirectories": [
+ "./src/main/js",
+ "node_modules"
+ ],
"setupFiles": [
"./test-setup.js"
]
http://git-wip-us.apache.org/repos/asf/aurora/blob/4a1fba3c/ui/src/main/js/components/InstanceViz.js
----------------------------------------------------------------------
diff --git a/ui/src/main/js/components/InstanceViz.js b/ui/src/main/js/components/InstanceViz.js
new file mode 100644
index 0000000..99efec4
--- /dev/null
+++ b/ui/src/main/js/components/InstanceViz.js
@@ -0,0 +1,17 @@
+import React from 'react';
+import { Link } from 'react-router-dom';
+
+export default function InstanceViz({ instances, jobKey }) {
+ const {job: {role, environment, name}} = jobKey;
+ const className = (instances.length > 1000)
+ ? 'small'
+ : (instances.length > 100) ? 'medium' : 'big';
+
+ return (<ul className={`instance-grid ${className}`}>
+ {instances.map((i) => {
+ return (<Link key={i} to={`/beta/scheduler/${role}/${environment}/${name}/${i.instanceId}`}>
+ <li className={i.className} title={i.title} />
+ </Link>);
+ })}
+ </ul>);
+}
http://git-wip-us.apache.org/repos/asf/aurora/blob/4a1fba3c/ui/src/main/js/components/Layout.js
----------------------------------------------------------------------
diff --git a/ui/src/main/js/components/Layout.js b/ui/src/main/js/components/Layout.js
index 50d63e6..b63b0c7 100644
--- a/ui/src/main/js/components/Layout.js
+++ b/ui/src/main/js/components/Layout.js
@@ -1,8 +1,10 @@
import React from 'react';
+import Icon from 'components/Icon';
+
import { addClass } from 'utils/Common';
-function ContentPanel({ children }) {
+export function ContentPanel({ children }) {
return <div className='content-panel'>{children}</div>;
}
@@ -10,6 +12,18 @@ export function StandardPanelTitle({ title }) {
return <div className='content-panel-title'>{title}</div>;
}
+export function PanelSubtitle({ title }) {
+ return <div className='content-panel-subtitle'>{title}</div>;
+}
+
+export function IconPanelTitle({ title, className, icon }) {
+ return (<div className={`content-icon-title ${className}`}>
+ <div className='content-icon-title-text'>
+ <Icon name={icon} /> {title}
+ </div>
+ </div>);
+}
+
export default function PanelGroup({ children, title, noPadding }) {
const extraClass = noPadding ? ' content-panel-fluid' : '';
return (<div className={addClass('content-panel-group', extraClass)}>
@@ -25,7 +39,7 @@ export function PanelRow({ children }) {
}
export function Container({ children, className }) {
- const width = 12 / children.length;
+ const width = 12 / (children.length || 1);
return (<div className={addClass('container', className)}>
<div className='row'>
{React.Children.map(children, (c) => <div className={`col-md-${width}`}>{c}</div>)}
http://git-wip-us.apache.org/repos/asf/aurora/blob/4a1fba3c/ui/src/main/js/components/Pagination.js
----------------------------------------------------------------------
diff --git a/ui/src/main/js/components/Pagination.js b/ui/src/main/js/components/Pagination.js
index 7bf2c04..0aac09d 100644
--- a/ui/src/main/js/components/Pagination.js
+++ b/ui/src/main/js/components/Pagination.js
@@ -54,6 +54,10 @@ export default class Pagination extends React.Component {
sort(data) {
const { reverseSort, sortBy } = this.props;
+ if (!sortBy) {
+ return data;
+ }
+
const gte = reverseSort ? -1 : 1;
const lte = reverseSort ? 1 : -1;
if (typeof sortBy === 'function') {
@@ -68,7 +72,7 @@ export default class Pagination extends React.Component {
render() {
const that = this;
- const { data, isTable, maxPages, numberPerPage, renderer } = this.props;
+ const { data, isTable, maxPages, numberPerPage, renderer, hideIfSinglePage } = this.props;
const { page } = this.state;
// Apply the filter before we try to paginate.
@@ -86,8 +90,10 @@ export default class Pagination extends React.Component {
// but first attempts at this broke shallow rendering in enzyme.
const elements = currentPageItems.map(renderer);
+ const numPages = Math.ceil(filtered.length / numberPerPage);
+
// The clickable page list.
- const pagination = <PageNavigation
+ const pagination = (numPages === 1 && hideIfSinglePage) ? '' : <PageNavigation
currentPage={page}
maxPages={maxPages || 8}
numPages={Math.ceil(filtered.length / numberPerPage)}
http://git-wip-us.apache.org/repos/asf/aurora/blob/4a1fba3c/ui/src/main/js/components/TaskConfig.js
----------------------------------------------------------------------
diff --git a/ui/src/main/js/components/TaskConfig.js b/ui/src/main/js/components/TaskConfig.js
new file mode 100644
index 0000000..b8531cb
--- /dev/null
+++ b/ui/src/main/js/components/TaskConfig.js
@@ -0,0 +1,5 @@
+import React from 'react';
+
+export default function TaskConfig({ config }) {
+ return <pre>{JSON.stringify(config, null, 2)}</pre>;
+}
http://git-wip-us.apache.org/repos/asf/aurora/blob/4a1fba3c/ui/src/main/js/components/Time.js
----------------------------------------------------------------------
diff --git a/ui/src/main/js/components/Time.js b/ui/src/main/js/components/Time.js
new file mode 100644
index 0000000..0e8d984
--- /dev/null
+++ b/ui/src/main/js/components/Time.js
@@ -0,0 +1,6 @@
+import moment from 'moment';
+import React from 'react';
+
+export function RelativeTime({ ts }) {
+ return <span>{moment(ts).fromNow()}</span>;
+}
http://git-wip-us.apache.org/repos/asf/aurora/blob/4a1fba3c/ui/src/main/js/components/UpdateConfig.js
----------------------------------------------------------------------
diff --git a/ui/src/main/js/components/UpdateConfig.js b/ui/src/main/js/components/UpdateConfig.js
new file mode 100644
index 0000000..fac3c88
--- /dev/null
+++ b/ui/src/main/js/components/UpdateConfig.js
@@ -0,0 +1,12 @@
+import React from 'react';
+
+import PanelGroup, { Container, StandardPanelTitle } from 'components/Layout';
+import TaskConfig from 'components/TaskConfig';
+
+export default function UpdateConfig({ update }) {
+ return (<Container>
+ <PanelGroup noPadding title={<StandardPanelTitle title='Update Config' />}>
+ <TaskConfig config={update.update.instructions.desiredState.task} />
+ </PanelGroup>
+ </Container>);
+}
http://git-wip-us.apache.org/repos/asf/aurora/blob/4a1fba3c/ui/src/main/js/components/UpdateDetails.js
----------------------------------------------------------------------
diff --git a/ui/src/main/js/components/UpdateDetails.js b/ui/src/main/js/components/UpdateDetails.js
new file mode 100644
index 0000000..b9dd565
--- /dev/null
+++ b/ui/src/main/js/components/UpdateDetails.js
@@ -0,0 +1,28 @@
+import React from 'react';
+
+import { Container, ContentPanel, StandardPanelTitle, PanelSubtitle } from 'components/Layout';
+import UpdateInstanceEvents from 'components/UpdateInstanceEvents';
+import UpdateInstanceSummary from 'components/UpdateInstanceSummary';
+import UpdateSettings from 'components/UpdateSettings';
+import UpdateStateMachine from 'components/UpdateStateMachine';
+import UpdateStatus from 'components/UpdateStatus';
+import UpdateTitle from 'components/UpdateTitle';
+
+export default function UpdateDetails({ update }) {
+ return (<Container>
+ <div className='content-panel-group'>
+ <UpdateTitle update={update} />
+ <ContentPanel><UpdateStatus update={update} /></ContentPanel>
+ <PanelSubtitle title='Update History' />
+ <ContentPanel><UpdateStateMachine update={update} /></ContentPanel>
+ <PanelSubtitle title='Update Settings' />
+ <ContentPanel><UpdateSettings update={update} /></ContentPanel>
+ </div>
+ <div className='content-panel-group'>
+ <StandardPanelTitle title='Instance Overview' />
+ <ContentPanel><UpdateInstanceSummary update={update} /></ContentPanel>
+ <PanelSubtitle title='Instance Events' />
+ <div className='content-panel fluid'><UpdateInstanceEvents update={update} /></div>
+ </div>
+ </Container>);
+}
http://git-wip-us.apache.org/repos/asf/aurora/blob/4a1fba3c/ui/src/main/js/components/UpdateInstanceEvents.js
----------------------------------------------------------------------
diff --git a/ui/src/main/js/components/UpdateInstanceEvents.js b/ui/src/main/js/components/UpdateInstanceEvents.js
new file mode 100644
index 0000000..f0dfae2
--- /dev/null
+++ b/ui/src/main/js/components/UpdateInstanceEvents.js
@@ -0,0 +1,101 @@
+import moment from 'moment';
+import React from 'react';
+import { Link } from 'react-router-dom';
+
+import Icon from 'components/Icon';
+import Pagination from 'components/Pagination';
+import StateMachine from 'components/StateMachine';
+
+import { addClass, sort } from 'utils/Common';
+import { UPDATE_ACTION } from 'utils/Thrift';
+import { actionDispatcher, getClassForUpdateAction } from 'utils/Update';
+
+const instanceEventIcon = actionDispatcher({
+ success: (e) => <Icon name='ok' />,
+ warning: (e) => <Icon name='warning-sign' />,
+ error: (e) => <Icon name='remove' />,
+ inProgress: (e) => <Icon name='play-circle' />
+});
+
+export class InstanceEvent extends React.Component {
+ constructor(props) {
+ super(props);
+ this.state = { expanded: props.expanded || false };
+ }
+
+ _stateMachine(events) {
+ const states = events.map((e, i) => {
+ return {
+ className: addClass(
+ getClassForUpdateAction(e.action),
+ (i === events.length - 1) ? ' active' : ''),
+ state: UPDATE_ACTION[e.action],
+ timestamp: e.timestampMs
+ };
+ });
+
+ return (<div className='update-instance-history'>
+ <StateMachine
+ className={getClassForUpdateAction(events[events.length - 1].action)}
+ states={states} />
+ </div>);
+ }
+
+ expand() {
+ this.setState({ expanded: !this.state.expanded });
+ }
+
+ render() {
+ const {events, instanceId, jobKey: {job: {role, environment, name}}} = this.props;
+ const sorted = sort(events, (e) => e.timestampMs);
+ const stateMachine = this.state.expanded ? this._stateMachine(sorted) : '';
+ const icon = this.state.expanded ? <Icon name='chevron-down' /> : <Icon name='chevron-right' />;
+ const latestEvent = sorted[sorted.length - 1];
+ return (<div className='update-instance-event-container'>
+ <div className='update-instance-event' onClick={(e) => this.expand()}>
+ {icon}
+ <span className='update-instance-event-id'>
+ <Link to={`/beta/scheduler/${role}/${environment}/${name}/${instanceId}`}>
+ #{instanceId}
+ </Link>
+ </span>
+ <span className='update-instance-event-status'>
+ {UPDATE_ACTION[latestEvent.action]}
+ <span className={getClassForUpdateAction(latestEvent.action)}>
+ {instanceEventIcon(latestEvent)}
+ </span>
+ </span>
+ <span className='update-instance-event-time'>
+ {moment(latestEvent.timestampMs).utc().format('HH:mm:ss') + ' UTC'}
+ </span>
+ </div>
+ {stateMachine}
+ </div>);
+ }
+};
+
+export default function UpdateInstanceEvents({ update }) {
+ const sortedEvents = sort(update.instanceEvents, (e) => e.timestampMs, true);
+ const instanceMap = {};
+ const eventOrder = [];
+ sortedEvents.forEach((e) => {
+ const existing = instanceMap[e.instanceId];
+ if (existing) {
+ instanceMap[e.instanceId].push(e);
+ } else {
+ eventOrder.push(e.instanceId);
+ instanceMap[e.instanceId] = [e];
+ }
+ });
+
+ return (<div className='instance-events'>
+ <Pagination
+ data={eventOrder}
+ hideIfSinglePage
+ numberPerPage={10}
+ renderer={(instanceId) => <InstanceEvent
+ events={instanceMap[instanceId]}
+ instanceId={instanceId}
+ jobKey={update.update.summary.key} />} />
+ </div>);
+}
http://git-wip-us.apache.org/repos/asf/aurora/blob/4a1fba3c/ui/src/main/js/components/UpdateInstanceSummary.js
----------------------------------------------------------------------
diff --git a/ui/src/main/js/components/UpdateInstanceSummary.js b/ui/src/main/js/components/UpdateInstanceSummary.js
new file mode 100644
index 0000000..3cbc28e
--- /dev/null
+++ b/ui/src/main/js/components/UpdateInstanceSummary.js
@@ -0,0 +1,22 @@
+import React from 'react';
+
+import InstanceViz from 'components/InstanceViz';
+
+import { instanceSummary, updateStats } from 'utils/Update';
+
+function UpdateStats({ update }) {
+ const stats = updateStats(update);
+ return (<div className='update-summary-stats'>
+ <h5>Instance Summary</h5>
+ <span className='stats'>
+ {stats.instancesUpdated} / {stats.totalInstancesToBeUpdated} ({stats.progress}%)
+ </span>
+ </div>);
+};
+
+export default function UpdateInstanceSummary({ update }) {
+ return (<div>
+ <UpdateStats update={update} />
+ <InstanceViz instances={instanceSummary(update)} jobKey={update.update.summary.key} />
+ </div>);
+}
http://git-wip-us.apache.org/repos/asf/aurora/blob/4a1fba3c/ui/src/main/js/components/UpdateList.js
----------------------------------------------------------------------
diff --git a/ui/src/main/js/components/UpdateList.js b/ui/src/main/js/components/UpdateList.js
new file mode 100644
index 0000000..3f57669
--- /dev/null
+++ b/ui/src/main/js/components/UpdateList.js
@@ -0,0 +1,47 @@
+import React from 'react';
+import { Link } from 'react-router-dom';
+
+import Loading from 'components/Loading';
+import Pagination from 'components/Pagination';
+import { RelativeTime } from 'components/Time';
+
+import { isNully } from 'utils/Common';
+import { UPDATE_STATUS } from 'utils/Thrift';
+import { getClassForUpdateStatus } from 'utils/Update';
+
+function UpdateListItem({ summary }) {
+ const {job: {role, environment, name}, id} = summary.key;
+ return (<div className='update-list-item'>
+ <span className={`img-circle ${getClassForUpdateStatus(summary.state.status)}`} />
+ <div className='update-list-item-details'>
+ <span className='update-list-item-status'>
+ <Link
+ className='update-list-job'
+ to={`/beta/scheduler/${role}/${environment}/${name}/update/${id}`}>
+ {role}/{environment}/{name}
+ </Link> • <span className='update-list-status'>
+ {UPDATE_STATUS[summary.state.status]}
+ </span>
+ </span>
+ started by <span className='update-list-user'>
+ {summary.user} </span> <RelativeTime ts={summary.state.createdTimestampMs} />
+ </div>
+ <span className='update-list-last-updated'>
+ updated <RelativeTime ts={summary.state.lastModifiedTimestampMs} />
+ </span>
+ </div>);
+}
+
+export default function UpdateList({ updates }) {
+ if (isNully(updates)) {
+ return <Loading />;
+ }
+
+ return (<div className='update-list'>
+ <Pagination
+ data={updates}
+ hideIfSinglePage
+ numberPerPage={25}
+ renderer={(u) => <UpdateListItem key={u.key.id} summary={u} />} />
+ </div>);
+}
http://git-wip-us.apache.org/repos/asf/aurora/blob/4a1fba3c/ui/src/main/js/components/UpdateSettings.js
----------------------------------------------------------------------
diff --git a/ui/src/main/js/components/UpdateSettings.js b/ui/src/main/js/components/UpdateSettings.js
new file mode 100644
index 0000000..d756f59
--- /dev/null
+++ b/ui/src/main/js/components/UpdateSettings.js
@@ -0,0 +1,30 @@
+import moment from 'moment';
+import React from 'react';
+
+export default function UpdateSettings({ update }) {
+ const settings = update.update.instructions.settings;
+ return (<div>
+ <table className='update-settings'>
+ <tr>
+ <td>Batch Size</td>
+ <td>{settings.updateGroupSize}</td>
+ </tr>
+ <tr>
+ <td>Max Failures Per Instance</td>
+ <td>{settings.maxPerInstanceFailures}</td>
+ </tr>
+ <tr>
+ <td>Max Failed Instances</td>
+ <td>{settings.maxFailedInstances}</td>
+ </tr>
+ <tr>
+ <td>Minimum Waiting Time in Running</td>
+ <td>{moment.duration(settings.minWaitInInstanceRunningMs).humanize()}</td>
+ </tr>
+ <tr>
+ <td>Rollback On Failure?</td>
+ <td>{settings.rollbackOnFailure ? 'yes' : 'no'}</td>
+ </tr>
+ </table>
+ </div>);
+}
http://git-wip-us.apache.org/repos/asf/aurora/blob/4a1fba3c/ui/src/main/js/components/UpdateStateMachine.js
----------------------------------------------------------------------
diff --git a/ui/src/main/js/components/UpdateStateMachine.js b/ui/src/main/js/components/UpdateStateMachine.js
new file mode 100644
index 0000000..ab1e85a
--- /dev/null
+++ b/ui/src/main/js/components/UpdateStateMachine.js
@@ -0,0 +1,21 @@
+import React from 'react';
+
+import StateMachine from 'components/StateMachine';
+
+import { addClass } from 'utils/Common';
+import { UPDATE_STATUS } from 'utils/Thrift';
+import { getClassForUpdateStatus } from 'utils/Update';
+
+export default function UpdateStateMachine({ update }) {
+ const events = update.updateEvents;
+ const states = events.map((e, i) => ({
+ className: addClass(
+ getClassForUpdateStatus(e.status),
+ (i === events.length - 1) ? ' active' : ''),
+ state: UPDATE_STATUS[e.status],
+ message: e.message,
+ timestamp: e.timestampMs
+ }));
+ const className = getClassForUpdateStatus(events[events.length - 1].status);
+ return <StateMachine className={addClass('update-state-machine', className)} states={states} />;
+}
http://git-wip-us.apache.org/repos/asf/aurora/blob/4a1fba3c/ui/src/main/js/components/UpdateStatus.js
----------------------------------------------------------------------
diff --git a/ui/src/main/js/components/UpdateStatus.js b/ui/src/main/js/components/UpdateStatus.js
new file mode 100644
index 0000000..7d37430
--- /dev/null
+++ b/ui/src/main/js/components/UpdateStatus.js
@@ -0,0 +1,22 @@
+import React from 'react';
+
+import UpdateTime from 'components/UpdateTime';
+
+import { UPDATE_STATUS } from 'utils/Thrift';
+import { isInProgressUpdate } from 'utils/Update';
+
+export default function UpdateStatus({ update }) {
+ const time = isInProgressUpdate(update) ? '' : <UpdateTime update={update} />;
+ return (<div>
+ <div className='update-byline'>
+ <span>
+ Update started by <strong>{update.update.summary.user}</strong>
+ </span>
+ <span>•</span>
+ <span>
+ Status: <strong>{UPDATE_STATUS[update.update.summary.state.status]}</strong>
+ </span>
+ </div>
+ {time}
+ </div>);
+}
http://git-wip-us.apache.org/repos/asf/aurora/blob/4a1fba3c/ui/src/main/js/components/UpdateTime.js
----------------------------------------------------------------------
diff --git a/ui/src/main/js/components/UpdateTime.js b/ui/src/main/js/components/UpdateTime.js
new file mode 100644
index 0000000..e55d376
--- /dev/null
+++ b/ui/src/main/js/components/UpdateTime.js
@@ -0,0 +1,33 @@
+import moment from 'moment';
+import React from 'react';
+
+import { RelativeTime } from 'components/Time';
+
+function UpdateTimeDisplay({ timestamp }) {
+ return (<div className='update-time'>
+ <span>{moment(timestamp).utc().format('ddd, MMM Do')}</span>
+ <h4>{moment(timestamp).utc().format('HH:mm')}</h4>
+ <span className='time-ago'><RelativeTime ts={timestamp} /></span>
+ </div>);
+};
+
+function UpdateDuration({ update }) {
+ const duration = (update.update.summary.state.lastModifiedTimestampMs -
+ update.update.summary.state.createdTimestampMs);
+ return <div className='update-duration'>Duration: {moment.duration(duration).humanize()}</div>;
+};
+
+function UpdateTimeRange({ update }) {
+ return (<div className='update-time-range'>
+ <UpdateTimeDisplay timestamp={update.update.summary.state.createdTimestampMs} />
+ <h5>~</h5>
+ <UpdateTimeDisplay timestamp={update.update.summary.state.lastModifiedTimestampMs} />
+ </div>);
+};
+
+export default function UpdateTime({ update }) {
+ return (<div>
+ <UpdateTimeRange update={update} />
+ <UpdateDuration update={update} />
+ </div>);
+}
http://git-wip-us.apache.org/repos/asf/aurora/blob/4a1fba3c/ui/src/main/js/components/UpdateTitle.js
----------------------------------------------------------------------
diff --git a/ui/src/main/js/components/UpdateTitle.js b/ui/src/main/js/components/UpdateTitle.js
new file mode 100644
index 0000000..cdeac6a
--- /dev/null
+++ b/ui/src/main/js/components/UpdateTitle.js
@@ -0,0 +1,26 @@
+import React from 'react';
+
+import { IconPanelTitle } from 'components/Layout';
+
+import { statusDispatcher } from 'utils/Update';
+
+const titleDispatch = {
+ success: (update) => {
+ return <IconPanelTitle className='success' icon='ok-sign' title='Update Successful' />;
+ },
+ warning: (update) => {
+ return <IconPanelTitle className='attention' icon='warning-sign' title='Update Paused' />;
+ },
+ error: (update) => {
+ return <IconPanelTitle className='error' icon='remove-sign' title='Update Failed' />;
+ },
+ inProgress: (update) => {
+ return <IconPanelTitle className='highlight' icon='play-circle' title='Update In Progress' />;
+ }
+};
+
+const titleDispatcher = statusDispatcher(titleDispatch);
+
+export default function UpdateTitle({ update }) {
+ return titleDispatcher(update);
+}
http://git-wip-us.apache.org/repos/asf/aurora/blob/4a1fba3c/ui/src/main/js/components/__tests__/InstanceViz-test.js
----------------------------------------------------------------------
diff --git a/ui/src/main/js/components/__tests__/InstanceViz-test.js b/ui/src/main/js/components/__tests__/InstanceViz-test.js
new file mode 100644
index 0000000..25efbf5
--- /dev/null
+++ b/ui/src/main/js/components/__tests__/InstanceViz-test.js
@@ -0,0 +1,41 @@
+import React from 'react';
+import { shallow } from 'enzyme';
+
+import InstanceViz from '../InstanceViz';
+
+import { range } from 'utils/Common';
+
+function generateInstances(n) {
+ return range(0, n - 1).map((i) => {
+ return {
+ className: 'okay',
+ instanceId: i,
+ title: `test-${i}`
+ };
+ });
+}
+
+const jobKey = {job: {role: 'test', environment: 'test', name: 'test'}};
+
+describe('InstanceViz', () => {
+ it('Should apply the small class to large numbers of instances', () => {
+ const el = shallow(<InstanceViz instances={generateInstances(1001)} jobKey={jobKey} />);
+ expect(el.find('ul.small').length).toBe(1);
+ expect(el.find('ul.medium').length).toBe(0);
+ expect(el.find('ul.big').length).toBe(0);
+ });
+
+ it('Should apply the medium class to medium numbers of instances', () => {
+ const el = shallow(<InstanceViz instances={generateInstances(101)} jobKey={jobKey} />);
+ expect(el.find('ul.small').length).toBe(0);
+ expect(el.find('ul.medium').length).toBe(1);
+ expect(el.find('ul.big').length).toBe(0);
+ });
+
+ it('Should apply the big class to small numbers of instances', () => {
+ const el = shallow(<InstanceViz instances={generateInstances(100)} jobKey={jobKey} />);
+ expect(el.find('ul.small').length).toBe(0);
+ expect(el.find('ul.medium').length).toBe(0);
+ expect(el.find('ul.big').length).toBe(1);
+ });
+});
http://git-wip-us.apache.org/repos/asf/aurora/blob/4a1fba3c/ui/src/main/js/components/__tests__/Pagination-test.js
----------------------------------------------------------------------
diff --git a/ui/src/main/js/components/__tests__/Pagination-test.js b/ui/src/main/js/components/__tests__/Pagination-test.js
index f2b72e9..8426527 100644
--- a/ui/src/main/js/components/__tests__/Pagination-test.js
+++ b/ui/src/main/js/components/__tests__/Pagination-test.js
@@ -53,6 +53,20 @@ describe('Pagination', () => {
el.containsAllMatchingElements([<PageNavigation currentPage={1} numPages={1} />])).toBe(true);
});
+ it('Should not show PageNavigation when hide single page is set', () => {
+ const el = shallow(
+ <Pagination data={data} hideIfSinglePage numberPerPage={25} renderer={render} />);
+ expect(el.find(Row).length).toBe(10);
+ expect(el.find(PageNavigation).length).toBe(0);
+ });
+
+ it('Should show PageNavigation when hide single page is set, but theres multiple pages', () => {
+ const el = shallow(
+ <Pagination data={data} hideIfSinglePage numberPerPage={2} renderer={render} />);
+ expect(el.find(Row).length).toBe(2);
+ expect(el.find(PageNavigation).length).toBe(1);
+ });
+
it('Should sort correctly', () => {
const el = shallow(
<Pagination data={data} numberPerPage={3} renderer={render} sortBy='name' />);
@@ -75,6 +89,17 @@ describe('Pagination', () => {
<PageNavigation currentPage={1} numPages={4} />])).toBe(true);
});
+ it('Should respect natural order when sortBy is omitted', () => {
+ const el = shallow(
+ <Pagination data={data} numberPerPage={3} renderer={render} />);
+ expect(el.find(Row).length).toBe(3);
+ expect(el.containsAllMatchingElements([
+ <Row key={1} />,
+ <Row key={2} />,
+ <Row key={3} />,
+ <PageNavigation currentPage={1} numPages={4} />])).toBe(true);
+ });
+
it('Should filter correctly', () => {
const el = shallow(<Pagination
data={data}
http://git-wip-us.apache.org/repos/asf/aurora/blob/4a1fba3c/ui/src/main/js/components/__tests__/UpdateInstanceEvents-test.js
----------------------------------------------------------------------
diff --git a/ui/src/main/js/components/__tests__/UpdateInstanceEvents-test.js b/ui/src/main/js/components/__tests__/UpdateInstanceEvents-test.js
new file mode 100644
index 0000000..4cbad09
--- /dev/null
+++ b/ui/src/main/js/components/__tests__/UpdateInstanceEvents-test.js
@@ -0,0 +1,40 @@
+import React from 'react';
+import { shallow } from 'enzyme';
+
+import Pagination from '../Pagination';
+import StateMachine from '../StateMachine';
+import UpdateInstanceEvents, { InstanceEvent } from '../UpdateInstanceEvents';
+
+import { InstanceUpdateEventBuilder, UpdateDetailsBuilder } from 'test-utils/UpdateBuilders';
+
+describe('UpdateInstanceEvents', () => {
+ it('Should reverse sort each instance by the latest instance event', () => {
+ const update = UpdateDetailsBuilder.instanceEvents([
+ InstanceUpdateEventBuilder.instanceId(0).timestampMs(1).build(),
+ InstanceUpdateEventBuilder.instanceId(1).timestampMs(0).build(),
+ InstanceUpdateEventBuilder.instanceId(2).timestampMs(0).build(),
+ InstanceUpdateEventBuilder.instanceId(2).timestampMs(2).build(),
+ InstanceUpdateEventBuilder.instanceId(0).timestampMs(5).build(),
+ InstanceUpdateEventBuilder.instanceId(3).timestampMs(0).build(),
+ InstanceUpdateEventBuilder.instanceId(3).timestampMs(20).build(),
+ InstanceUpdateEventBuilder.instanceId(4).timestampMs(3).build()]).build();
+
+ const el = shallow(<UpdateInstanceEvents update={update} />);
+ expect(el.find(Pagination).first().props().data).toEqual([3, 0, 4, 2, 1]);
+ });
+});
+
+describe('InstanceEvent', () => {
+ const jobKey = {job: {role: 'role', environment: 'env', name: 'name'}};
+
+ it('Should support expand toggle', () => {
+ const events = [
+ InstanceUpdateEventBuilder.instanceId(0).timestampMs(1).build(),
+ InstanceUpdateEventBuilder.instanceId(0).timestampMs(5).build()];
+
+ const el = shallow(<InstanceEvent events={events} instanceId={0} jobKey={jobKey} />);
+ expect(el.find(StateMachine).length).toBe(0);
+ el.find('.update-instance-event').simulate('click');
+ expect(el.find(StateMachine).length).toBe(1);
+ });
+});
http://git-wip-us.apache.org/repos/asf/aurora/blob/4a1fba3c/ui/src/main/js/components/__tests__/UpdateList-test.js
----------------------------------------------------------------------
diff --git a/ui/src/main/js/components/__tests__/UpdateList-test.js b/ui/src/main/js/components/__tests__/UpdateList-test.js
new file mode 100644
index 0000000..584df9d
--- /dev/null
+++ b/ui/src/main/js/components/__tests__/UpdateList-test.js
@@ -0,0 +1,17 @@
+import React from 'react';
+import { shallow } from 'enzyme';
+
+import Loading from '../Loading';
+import UpdateList from '../UpdateList';
+
+describe('UpdateList', () => {
+ it('Handles null by showing Loading', () => {
+ const el = shallow(<UpdateList />);
+ expect(el.contains(<Loading />)).toBe(true);
+ });
+
+ it('Does not show loading when data is passed, even an empty list', () => {
+ const el = shallow(<UpdateList updates={[]} />);
+ expect(el.contains(<Loading />)).toBe(false);
+ });
+});
http://git-wip-us.apache.org/repos/asf/aurora/blob/4a1fba3c/ui/src/main/js/components/__tests__/UpdateStatus-test.js
----------------------------------------------------------------------
diff --git a/ui/src/main/js/components/__tests__/UpdateStatus-test.js b/ui/src/main/js/components/__tests__/UpdateStatus-test.js
new file mode 100644
index 0000000..0bd14b2
--- /dev/null
+++ b/ui/src/main/js/components/__tests__/UpdateStatus-test.js
@@ -0,0 +1,23 @@
+import React from 'react';
+import { shallow } from 'enzyme';
+
+import UpdateStatus from '../UpdateStatus';
+import UpdateTime from '../UpdateTime';
+
+import { builderWithStatus } from 'test-utils/UpdateBuilders';
+
+describe('UpdateStatus', () => {
+ it('Should show UpdateTime when update terminal', () => {
+ const update = builderWithStatus(JobUpdateStatus.ROLLED_FORWARD).build();
+
+ const el = shallow(<UpdateStatus update={update} />);
+ expect(el.contains(<UpdateTime update={update} />)).toBe(true);
+ });
+
+ it('Should NOT show UpdateTime when update in-progress', () => {
+ const update = builderWithStatus(JobUpdateStatus.ROLLING_FORWARD).build();
+
+ const el = shallow(<UpdateStatus update={update} />);
+ expect(el.find(UpdateTime).length).toBe(0);
+ });
+});
http://git-wip-us.apache.org/repos/asf/aurora/blob/4a1fba3c/ui/src/main/js/index.js
----------------------------------------------------------------------
diff --git a/ui/src/main/js/index.js b/ui/src/main/js/index.js
index 8f07734..30646f7 100644
--- a/ui/src/main/js/index.js
+++ b/ui/src/main/js/index.js
@@ -7,6 +7,8 @@ import Navigation from 'components/Navigation';
import Home from 'pages/Home';
import Instance from 'pages/Instance';
import Jobs from 'pages/Jobs';
+import Update from 'pages/Update';
+import Updates from 'pages/Updates';
import styles from '../sass/app.scss'; // eslint-disable-line no-unused-vars
@@ -24,8 +26,11 @@ const SchedulerUI = () => (
component={injectApi(Instance)}
exact
path='/beta/scheduler/:role/:environment/:name/:instance' />
- <Route component={Home} exact path='/beta/scheduler/:role/:environment/:name/update/:uid' />
- <Route component={Home} exact path='/beta/updates' />
+ <Route
+ component={injectApi(Update)}
+ exact
+ path='/beta/scheduler/:role/:environment/:name/update/:uid' />
+ <Route component={injectApi(Updates)} exact path='/beta/updates' />
</div>
</Router>
);
http://git-wip-us.apache.org/repos/asf/aurora/blob/4a1fba3c/ui/src/main/js/pages/Update.js
----------------------------------------------------------------------
diff --git a/ui/src/main/js/pages/Update.js b/ui/src/main/js/pages/Update.js
new file mode 100644
index 0000000..c900269
--- /dev/null
+++ b/ui/src/main/js/pages/Update.js
@@ -0,0 +1,54 @@
+import React from 'react';
+
+import Breadcrumb from 'components/Breadcrumb';
+import Loading from 'components/Loading';
+import UpdateConfig from 'components/UpdateConfig';
+import UpdateDetails from 'components/UpdateDetails';
+
+export default class Update extends React.Component {
+ constructor(props) {
+ super(props);
+ this.state = { loading: true };
+ }
+
+ componentWillMount() {
+ const { role, environment, name, uid } = this.props.match.params;
+
+ const job = new JobKey();
+ job.role = role;
+ job.environment = environment;
+ job.name = name;
+
+ const key = new JobUpdateKey();
+ key.job = job;
+ key.id = uid;
+
+ const query = new JobUpdateQuery();
+ query.key = key;
+
+ const that = this;
+ this.props.api.getJobUpdateDetails(null, query, (response) => {
+ const update = response.result.getJobUpdateDetailsResult.detailsList[0];
+ that.setState({ cluster: response.serverInfo.clusterName, loading: false, update });
+ });
+ }
+
+ render() {
+ const { role, environment, name, uid } = this.props.match.params;
+
+ if (this.state.loading) {
+ return <Loading />;
+ }
+
+ return (<div className='update-page'>
+ <Breadcrumb
+ cluster={this.state.cluster}
+ env={environment}
+ name={name}
+ role={role}
+ update={uid} />
+ <UpdateDetails update={this.state.update} />
+ <UpdateConfig update={this.state.update} />
+ </div>);
+ }
+}
http://git-wip-us.apache.org/repos/asf/aurora/blob/4a1fba3c/ui/src/main/js/pages/Updates.js
----------------------------------------------------------------------
diff --git a/ui/src/main/js/pages/Updates.js b/ui/src/main/js/pages/Updates.js
new file mode 100644
index 0000000..44b9a11
--- /dev/null
+++ b/ui/src/main/js/pages/Updates.js
@@ -0,0 +1,66 @@
+import React from 'react';
+
+import Breadcrumb from 'components/Breadcrumb';
+import PanelGroup, { Container, StandardPanelTitle } from 'components/Layout';
+import UpdateList from 'components/UpdateList';
+
+import { getInProgressStates, getTerminalStates } from 'utils/Update';
+
+export const MAX_QUERY_SIZE = 100;
+
+export class UpdatesFetcher extends React.Component {
+ constructor(props) {
+ super(props);
+ this.state = { updates: null };
+ }
+
+ componentWillMount() {
+ const that = this;
+ const query = new JobUpdateQuery();
+ query.updateStatuses = this.props.states;
+ query.limit = MAX_QUERY_SIZE;
+ this.props.api.getJobUpdateSummaries(query, (response) => {
+ const updates = response.result.getJobUpdateSummariesResult.updateSummaries;
+ that.setState({updates});
+ that.props.clusterFn(response.serverInfo.clusterName);
+ });
+ }
+
+ render() {
+ return (<Container>
+ <PanelGroup noPadding title={<StandardPanelTitle title={this.props.title} />}>
+ <UpdateList updates={this.state.updates} />
+ </PanelGroup>
+ </Container>);
+ }
+}
+
+export default class Updates extends React.Component {
+ constructor(props) {
+ super(props);
+ this.state = { cluster: null };
+ this.clusterFn = this.setCluster.bind(this);
+ }
+
+ setCluster(cluster) {
+ // TODO(dmcg): We should just have the Scheduler return the cluster as a global.
+ this.setState({cluster});
+ }
+
+ render() {
+ const api = this.props.api;
+ return (<div className='update-page'>
+ <Breadcrumb cluster={this.state.cluster} />
+ <UpdatesFetcher
+ api={api}
+ clusterFn={this.clusterFn}
+ states={getInProgressStates()}
+ title='Updates In Progress' />
+ <UpdatesFetcher
+ api={api}
+ clusterFn={this.clusterFn}
+ states={getTerminalStates()}
+ title='Recently Completed Updates' />
+ </div>);
+ }
+}
http://git-wip-us.apache.org/repos/asf/aurora/blob/4a1fba3c/ui/src/main/js/pages/__tests__/Update-test.js
----------------------------------------------------------------------
diff --git a/ui/src/main/js/pages/__tests__/Update-test.js b/ui/src/main/js/pages/__tests__/Update-test.js
new file mode 100644
index 0000000..570a999
--- /dev/null
+++ b/ui/src/main/js/pages/__tests__/Update-test.js
@@ -0,0 +1,56 @@
+import React from 'react';
+import { shallow } from 'enzyme';
+
+import Update from '../Update';
+
+import Breadcrumb from 'components/Breadcrumb';
+import Loading from 'components/Loading';
+import UpdateConfig from 'components/UpdateConfig';
+import UpdateDetails from 'components/UpdateDetails';
+
+const TEST_CLUSTER = 'test-cluster';
+
+const params = {
+ role: 'test-role',
+ environment: 'test-env',
+ name: 'test-job',
+ instance: '1',
+ uid: 'update-id'
+};
+
+function createMockApi(update) {
+ const api = {};
+ api.getJobUpdateDetails = (id, query, handler) => handler({
+ result: {
+ getJobUpdateDetailsResult: {
+ detailsList: [update]
+ }
+ },
+ serverInfo: {
+ clusterName: TEST_CLUSTER
+ }
+ });
+ return api;
+}
+
+const update = {}; // only testing pass-through here...
+
+describe('Update', () => {
+ it('Should render Loading before data is fetched', () => {
+ expect(shallow(<Update
+ api={{getJobUpdateDetails: () => {}}}
+ match={{params: params}} />).contains(<Loading />)).toBe(true);
+ });
+
+ it('Should render page elements when update is fetched', () => {
+ const el = shallow(<Update api={createMockApi(update)} match={{params: params}} />);
+ expect(el.contains(<Breadcrumb
+ cluster={TEST_CLUSTER}
+ env={params.environment}
+ name={params.name}
+ role={params.role}
+ update={params.uid} />)).toBe(true);
+ expect(el.contains(<UpdateConfig update={update} />)).toBe(true);
+ expect(el.contains(<UpdateDetails update={update} />)).toBe(true);
+ });
+});
http://git-wip-us.apache.org/repos/asf/aurora/blob/4a1fba3c/ui/src/main/js/pages/__tests__/Updates-test.js
----------------------------------------------------------------------
diff --git a/ui/src/main/js/pages/__tests__/Updates-test.js b/ui/src/main/js/pages/__tests__/Updates-test.js
new file mode 100644
index 0000000..8cc3315
--- /dev/null
+++ b/ui/src/main/js/pages/__tests__/Updates-test.js
@@ -0,0 +1,33 @@
+import React from 'react';
+import { shallow } from 'enzyme';
+
+import { UpdatesFetcher } from '../Updates';
+import UpdateList from 'components/UpdateList';
+
+const TEST_CLUSTER = 'test-cluster';
+
+function createMockApi(updates) {
+ const api = {};
+ api.getJobUpdateSummaries = (query, handler) => handler({
+ result: {
+ getJobUpdateSummariesResult: {
+ updateSummaries: updates
+ }
+ },
+ serverInfo: {
+ clusterName: TEST_CLUSTER
+ }
+ });
+ return api;
+}
+
+const updates = [{}]; // only testing pass-through here...
+const states = [];
+
+describe('UpdatesFetcher', () => {
+ it('Should render update list with updates when mounted', () => {
+ const el = shallow(
+ <UpdatesFetcher api={createMockApi(updates)} clusterFn={() => {}} states={states} />);
+ expect(el.contains(<UpdateList updates={updates} />));
+ });
+});
http://git-wip-us.apache.org/repos/asf/aurora/blob/4a1fba3c/ui/src/main/js/test-utils/UpdateBuilders.js
----------------------------------------------------------------------
diff --git a/ui/src/main/js/test-utils/UpdateBuilders.js b/ui/src/main/js/test-utils/UpdateBuilders.js
new file mode 100644
index 0000000..2764f0e
--- /dev/null
+++ b/ui/src/main/js/test-utils/UpdateBuilders.js
@@ -0,0 +1,86 @@
+import createBuilder from 'test-utils/Builder';
+
+import { TaskConfigBuilder } from './TaskBuilders';
+
+const USER = 'update-user';
+const UPDATE_ID = 'update-id';
+const JOB_KEY = {
+ role: 'test-role',
+ environment: 'test-env',
+ name: 'test-name'
+};
+const UPDATE_KEY = {
+ job: JOB_KEY,
+ id: UPDATE_ID
+};
+
+export default {
+ USER
+};
+
+export const UpdateSettingsBuilder = createBuilder({
+ updateGroupSize: 1,
+ maxPerInstanceFailures: 0,
+ maxFailedInstances: 0,
+ minWaitInInstanceRunningMs: 1,
+ rollbackOnFailure: true,
+ updateOnlyTheseInstances: [],
+ waitForBatchCompletion: false
+});
+
+export const UpdateEventBuilder = createBuilder({
+ status: JobUpdateStatus.ROLLING_FORWARD,
+ timestampMs: 0,
+ user: USER,
+ message: ''
+});
+
+export const InstanceUpdateEventBuilder = createBuilder({
+ instanceId: 0,
+ timestampMs: 0,
+ action: JobUpdateAction.INSTANCE_UPDATING
+});
+
+export const InstanceTaskConfigBuilder = createBuilder({
+ task: TaskConfigBuilder.build(),
+ instances: [{first: 0, last: 0}]
+});
+
+export const UpdateInstructionsBuilder = createBuilder({
+ initialState: [InstanceTaskConfigBuilder.build()],
+ desiredState: InstanceTaskConfigBuilder.task(
+ TaskConfigBuilder.resources([{numCpus: 2, ramMb: 2048, diskMb: 2048}])).build(),
+ settings: UpdateSettingsBuilder.build()
+});
+
+export const UpdateStateBuilder = createBuilder({
+ status: JobUpdateStatus.ROLLING_FORWARD,
+ createdTimestampMs: 0,
+ lastModifiedTimestampMs: 60000
+});
+
+export const UpdateSummaryBuilder = createBuilder({
+ key: UPDATE_KEY,
+ user: USER,
+ state: UpdateStateBuilder.build(),
+ metadata: []
+});
+
+export const UpdateBuilder = createBuilder({
+ summary: UpdateSummaryBuilder.build(),
+ instructions: UpdateInstructionsBuilder.build()
+});
+
+export const UpdateDetailsBuilder = createBuilder({
+ update: UpdateBuilder.build(),
+ updateEvents: [UpdateEventBuilder.build()],
+ instanceEvents: [InstanceUpdateEventBuilder.build()]
+});
+
+export function builderWithStatus(updateStatus) {
+ return UpdateDetailsBuilder.update(
+ UpdateBuilder.summary(
+ UpdateSummaryBuilder.state(UpdateStateBuilder.status(updateStatus).build()).build()
+ ).build()
+ ).updateEvents([UpdateEventBuilder.status(updateStatus).build()]);
+}
http://git-wip-us.apache.org/repos/asf/aurora/blob/4a1fba3c/ui/src/main/js/utils/Common.js
----------------------------------------------------------------------
diff --git a/ui/src/main/js/utils/Common.js b/ui/src/main/js/utils/Common.js
index be8766c..8f2da7c 100644
--- a/ui/src/main/js/utils/Common.js
+++ b/ui/src/main/js/utils/Common.js
@@ -20,3 +20,19 @@ export function addClass(original, maybeClass) {
export function clone(obj) {
return JSON.parse(JSON.stringify(obj));
}
+
+export function sort(arr, prop, reverse = false) {
+ return arr.sort((a, b) => {
+ if (prop(a) === prop(b)) {
+ return 0;
+ }
+ if (prop(a) < prop(b)) {
+ return reverse ? 1 : -1;
+ }
+ return reverse ? -1 : 1;
+ });
+}
+
+export function range(start, end) {
+ return [...Array(1 + end - start).keys()].map((i) => start + i);
+}
http://git-wip-us.apache.org/repos/asf/aurora/blob/4a1fba3c/ui/src/main/js/utils/Thrift.js
----------------------------------------------------------------------
diff --git a/ui/src/main/js/utils/Thrift.js b/ui/src/main/js/utils/Thrift.js
index b247e36..4336bd1 100644
--- a/ui/src/main/js/utils/Thrift.js
+++ b/ui/src/main/js/utils/Thrift.js
@@ -1,6 +1,8 @@
import { invert } from 'utils/Common';
export const SCHEDULE_STATUS = invert(ScheduleStatus);
+export const UPDATE_STATUS = invert(JobUpdateStatus);
+export const UPDATE_ACTION = invert(JobUpdateAction);
export const OKAY_SCHEDULE_STATUS = [
ScheduleStatus.RUNNING,
@@ -25,9 +27,48 @@ export const ERROR_SCHEDULE_STATUS = [
ScheduleStatus.FAILED
];
+export const OKAY_UPDATE_STATUS = [
+ JobUpdateStatus.ROLLED_FORWARD
+];
+
+export const WARNING_UPDATE_STATUS = [
+ JobUpdateStatus.ROLL_FORWARD_AWAITING_PULSE,
+ JobUpdateStatus.ROLL_FORWARD_PAUSED
+];
+
+export const ERROR_UPDATE_STATUS = [
+ JobUpdateStatus.ROLLING_BACK,
+ JobUpdateStatus.ROLLED_BACK,
+ JobUpdateStatus.ROLL_BACK_PAUSED,
+ JobUpdateStatus.ABORTED,
+ JobUpdateStatus.ERROR,
+ JobUpdateStatus.FAILED,
+ JobUpdateStatus.ROLL_BACK_AWAITING_PULSE
+];
+
+export const OKAY_UPDATE_ACTION = [
+ JobUpdateAction.INSTANCE_UPDATED
+];
+
+export const WARNING_UPDATE_ACTION = [
+ JobUpdateAction.INSTANCE_ROLLING_BACK,
+ JobUpdateAction.INSTANCE_ROLLED_BACK
+];
+
+export const ERROR_UPDATE_ACTION = [
+ JobUpdateAction.INSTANCE_UPDATE_FAILED,
+ JobUpdateAction.INSTANCE_ROLLBACK_FAILED
+];
+
export default {
OKAY_SCHEDULE_STATUS,
WARNING_SCHEDULE_STATUS,
ERROR_SCHEDULE_STATUS,
- USER_WAIT_SCHEDULE_STATUS
+ USER_WAIT_SCHEDULE_STATUS,
+ OKAY_UPDATE_STATUS,
+ WARNING_UPDATE_STATUS,
+ ERROR_UPDATE_STATUS,
+ OKAY_UPDATE_ACTION,
+ WARNING_UPDATE_ACTION,
+ ERROR_UPDATE_ACTION
};
http://git-wip-us.apache.org/repos/asf/aurora/blob/4a1fba3c/ui/src/main/js/utils/Update.js
----------------------------------------------------------------------
diff --git a/ui/src/main/js/utils/Update.js b/ui/src/main/js/utils/Update.js
new file mode 100644
index 0000000..dee2393
--- /dev/null
+++ b/ui/src/main/js/utils/Update.js
@@ -0,0 +1,163 @@
+import { sort } from 'utils/Common';
+import Thrift, { UPDATE_ACTION } from 'utils/Thrift';
+
+export function isSuccessfulUpdate(update) {
+ return update.update.summary.state.status === JobUpdateStatus.ROLLED_FORWARD;
+}
+
+export function isInProgressUpdate(update) {
+ return update.update.summary.state.status === JobUpdateStatus.ROLLING_FORWARD;
+}
+
+function processInstanceIdsFromRanges(ranges, fn) {
+ ranges.forEach((r) => {
+ for (let i = r.first; i <= r.last; i++) {
+ fn(i);
+ }
+ });
+}
+
+function getAllInstanceIds(update) {
+ const allIds = {};
+ const newIds = {};
+ const oldIds = {};
+
+ processInstanceIdsFromRanges(update.instructions.desiredState.instances, (id) => {
+ newIds[id] = true;
+ allIds[id] = true;
+ });
+
+ update.instructions.initialState.forEach((task) => {
+ processInstanceIdsFromRanges(task.instances, (id) => {
+ oldIds[id] = true;
+ allIds[id] = true;
+ });
+ });
+ return { allIds, newIds, oldIds };
+}
+
+function getLatestInstanceEvents(instanceEvents, predicate = (e) => true) {
+ const events = sort(instanceEvents, (e) => e.timestampMs, true);
+ const instanceMap = {};
+ events.forEach((e) => {
+ if (!instanceMap.hasOwnProperty(e.instanceId) && predicate(e)) {
+ instanceMap[e.instanceId] = e;
+ }
+ });
+ return instanceMap;
+}
+
+export function instanceSummary(details) {
+ const instances = getAllInstanceIds(details.update);
+ const latestInstanceEvents = getLatestInstanceEvents(details.instanceEvents);
+ const allIds = Object.keys(instances.allIds);
+
+ return allIds.map((i) => {
+ // If there is an event, use the event to generate the instance status.
+ if (latestInstanceEvents.hasOwnProperty(i)) {
+ const latestEvent = latestInstanceEvents[i];
+ // If instance has been updated and is in initial state, but not in desired state,
+ // then it's a removed instance.
+ if (latestEvent.action === JobUpdateAction.INSTANCE_UPDATED &&
+ instances.oldIds.hasOwnProperty(i) &&
+ !instances.newIds.hasOwnProperty(i)) {
+ return {
+ instanceId: i,
+ className: 'removed',
+ title: 'Instance Removed'
+ };
+ }
+
+ // Normal case - the latest action is the current status
+ return {
+ instanceId: i,
+ className: getClassForUpdateAction(latestEvent.action),
+ title: UPDATE_ACTION[latestEvent.action]
+ };
+ } else {
+ // No event, so it's a pending instance.
+ return {
+ instanceId: i,
+ className: 'pending',
+ title: 'Pending'
+ };
+ }
+ });
+}
+
+function progressFromEvents(instanceEvents) {
+ const success = getLatestInstanceEvents(instanceEvents,
+ (e) => e.action === JobUpdateAction.INSTANCE_UPDATED);
+ return Object.keys(success).length;
+}
+
+export function updateStats(details) {
+ const allInstances = Object.keys(getAllInstanceIds(details.update).allIds);
+ const totalInstancesToBeUpdated = allInstances.length;
+ const instancesUpdated = progressFromEvents(details.instanceEvents);
+ const progress = Math.round((instancesUpdated / totalInstancesToBeUpdated) * 100);
+ return {
+ totalInstancesToBeUpdated,
+ instancesUpdated,
+ progress
+ };
+}
+
+export function getInProgressStates() {
+ return ACTIVE_JOB_UPDATE_STATES;
+}
+
+export function getTerminalStates() {
+ const active = new Set(ACTIVE_JOB_UPDATE_STATES);
+ return Object.values(JobUpdateStatus).filter((k) => !active.has(k));
+}
+
+export function getClassForUpdateStatus(status) {
+ if (Thrift.OKAY_UPDATE_STATUS.includes(status)) {
+ return 'okay';
+ } else if (Thrift.WARNING_UPDATE_STATUS.includes(status)) {
+ return 'attention';
+ } else if (Thrift.ERROR_UPDATE_STATUS.includes(status)) {
+ return 'error';
+ }
+ return 'in-progress';
+}
+
+export function getClassForUpdateAction(action) {
+ if (Thrift.OKAY_UPDATE_ACTION.includes(action)) {
+ return 'okay';
+ } else if (Thrift.WARNING_UPDATE_ACTION.includes(action)) {
+ return 'attention';
+ } else if (Thrift.ERROR_UPDATE_ACTION.includes(action)) {
+ return 'error';
+ }
+ return 'in-progress';
+}
+
+export function statusDispatcher(dispatch) {
+ return (update) => {
+ const status = update.update.summary.state.status;
+ if (Thrift.OKAY_UPDATE_STATUS.includes(status)) {
+ return dispatch.success(update);
+ } else if (Thrift.WARNING_UPDATE_STATUS.includes(status)) {
+ return dispatch.warning(update);
+ } else if (Thrift.ERROR_UPDATE_STATUS.includes(status)) {
+ return dispatch.error(update);
+ }
+ return dispatch.inProgress(update);
+ };
+}
+
+export function actionDispatcher(dispatch) {
+ return (event) => {
+ const action = event.action;
+ if (Thrift.OKAY_UPDATE_ACTION.includes(action)) {
+ return dispatch.success(event);
+ } else if (Thrift.WARNING_UPDATE_ACTION.includes(action)) {
+ return dispatch.warning(event);
+ } else if (Thrift.ERROR_UPDATE_ACTION.includes(action)) {
+ return dispatch.error(event);
+ }
+ return dispatch.inProgress(event);
+ };
+}
http://git-wip-us.apache.org/repos/asf/aurora/blob/4a1fba3c/ui/src/main/js/utils/__tests__/Update-test.js
----------------------------------------------------------------------
diff --git a/ui/src/main/js/utils/__tests__/Update-test.js b/ui/src/main/js/utils/__tests__/Update-test.js
new file mode 100644
index 0000000..88fa5f7
--- /dev/null
+++ b/ui/src/main/js/utils/__tests__/Update-test.js
@@ -0,0 +1,291 @@
+import {
+ actionDispatcher,
+ getClassForUpdateStatus,
+ getClassForUpdateAction,
+ instanceSummary,
+ statusDispatcher,
+ updateStats
+} from '../Update';
+
+import {
+ InstanceTaskConfigBuilder,
+ InstanceUpdateEventBuilder,
+ UpdateBuilder,
+ UpdateDetailsBuilder,
+ UpdateInstructionsBuilder,
+ builderWithStatus } from 'test-utils/UpdateBuilders';
+import {
+ ERROR_UPDATE_ACTION,
+ ERROR_UPDATE_STATUS,
+ WARNING_UPDATE_ACTION,
+ WARNING_UPDATE_STATUS } from 'utils/Thrift';
+
+function createDispatchSpies() {
+ return {
+ error: jest.fn(),
+ inProgress: jest.fn(),
+ success: jest.fn(),
+ warning: jest.fn()
+ };
+}
+
+function assertSpies(spies, expect) {
+ return ['error', 'inProgress', 'success', 'warning'].every((key) => {
+ const expected = expect[key] || 0;
+ return spies[key].mock.calls.length === expected;
+ });
+}
+
+describe('actionDispatcher', () => {
+ it('Should dispatch success events', () => {
+ const spies = createDispatchSpies();
+ const dispatcher = actionDispatcher(spies);
+ dispatcher(InstanceUpdateEventBuilder.action(JobUpdateAction.INSTANCE_UPDATED).build());
+ expect(assertSpies(spies, {success: 1})).toBe(true);
+ });
+
+ it('Should dispatch in-progress events', () => {
+ const spies = createDispatchSpies();
+ const dispatcher = actionDispatcher(spies);
+ dispatcher(InstanceUpdateEventBuilder.action(JobUpdateAction.INSTANCE_UPDATING).build());
+ expect(assertSpies(spies, {inProgress: 1})).toBe(true);
+ });
+
+ it('Should dispatch warning events', () => {
+ WARNING_UPDATE_ACTION.forEach((action) => {
+ const spies = createDispatchSpies();
+ const dispatcher = actionDispatcher(spies);
+ dispatcher(InstanceUpdateEventBuilder.action(action).build());
+ expect(assertSpies(spies, {warning: 1})).toBe(true);
+ });
+ });
+
+ it('Should dispatch error events', () => {
+ ERROR_UPDATE_ACTION.forEach((action) => {
+ const spies = createDispatchSpies();
+ const dispatcher = actionDispatcher(spies);
+ dispatcher(InstanceUpdateEventBuilder.action(action).build());
+ expect(assertSpies(spies, {error: 1})).toBe(true);
+ });
+ });
+});
+
+describe('getClassForUpdateAction', () => {
+ it('Should return okay class', () => {
+ expect(getClassForUpdateAction(JobUpdateAction.INSTANCE_UPDATED)).toBe('okay');
+ });
+
+ it('Should return in-progress class', () => {
+ expect(getClassForUpdateAction(JobUpdateAction.INSTANCE_UPDATING)).toBe('in-progress');
+ });
+
+ it('Should dispatch warning events', () => {
+ WARNING_UPDATE_ACTION.forEach((action) => {
+ expect(getClassForUpdateAction(action)).toBe('attention');
+ });
+ });
+
+ it('Should dispatch error events', () => {
+ ERROR_UPDATE_ACTION.forEach((action) => {
+ expect(getClassForUpdateAction(action)).toBe('error');
+ });
+ });
+});
+
+describe('getClassForUpdateStatus', () => {
+ it('Should return okay for successful updates', () => {
+ expect(getClassForUpdateStatus(JobUpdateStatus.ROLLED_FORWARD)).toBe('okay');
+ });
+
+ it('Should fire the in-progress callback for rolling forward updates', () => {
+ expect(getClassForUpdateStatus(JobUpdateStatus.ROLLING_FORWARD)).toBe('in-progress');
+ });
+
+ it('Should fire the error callback for all failed updates', () => {
+ ERROR_UPDATE_STATUS.forEach((status) => {
+ expect(getClassForUpdateStatus(status)).toBe('error');
+ });
+ });
+
+ it('Should fire the warning callback for all failed updates', () => {
+ WARNING_UPDATE_STATUS.forEach((status) => {
+ expect(getClassForUpdateStatus(status)).toBe('attention');
+ });
+ });
+});
+
+describe('instanceSummary', () => {
+ const instanceUpdated = InstanceUpdateEventBuilder.action(JobUpdateAction.INSTANCE_UPDATED);
+
+ it('Should return the correct data', () => {
+ const instructions = UpdateInstructionsBuilder
+ .initialState([InstanceTaskConfigBuilder.instances([{first: 0, last: 10}]).build()])
+ .desiredState(InstanceTaskConfigBuilder.instances([{first: 0, last: 9}]).build())
+ .build();
+
+ const update = UpdateDetailsBuilder
+ .update(UpdateBuilder.instructions(instructions).build())
+ .instanceEvents([
+ InstanceUpdateEventBuilder.action(JobUpdateAction.INSTANCE_UPDATE_FAILED).build(),
+ instanceUpdated.instanceId(0).timestampMs(1).build(),
+ instanceUpdated.instanceId(2).build(),
+ instanceUpdated.instanceId(3).build(),
+ instanceUpdated.instanceId(5).build(),
+ instanceUpdated.instanceId(9).build()
+ ])
+ .build();
+ const summary = instanceSummary(update);
+ expect(summary).toEqual([
+ {instanceId: '0', className: 'okay', title: 'INSTANCE_UPDATED'},
+ {instanceId: '1', className: 'pending', title: 'Pending'},
+ {instanceId: '2', className: 'okay', title: 'INSTANCE_UPDATED'},
+ {instanceId: '3', className: 'okay', title: 'INSTANCE_UPDATED'},
+ {instanceId: '4', className: 'pending', title: 'Pending'},
+ {instanceId: '5', className: 'okay', title: 'INSTANCE_UPDATED'},
+ {instanceId: '6', className: 'pending', title: 'Pending'},
+ {instanceId: '7', className: 'pending', title: 'Pending'},
+ {instanceId: '8', className: 'pending', title: 'Pending'},
+ {instanceId: '9', className: 'okay', title: 'INSTANCE_UPDATED'},
+ {instanceId: '10', className: 'pending', title: 'Pending'}
+ ]);
+ });
+
+ it('Should handle removed instances', () => {
+ const instructions = UpdateInstructionsBuilder
+ .initialState([InstanceTaskConfigBuilder.instances([{first: 0, last: 3}]).build()])
+ .desiredState(InstanceTaskConfigBuilder.instances([{first: 0, last: 2}]).build())
+ .build();
+
+ const update = UpdateDetailsBuilder
+ .update(UpdateBuilder.instructions(instructions).build())
+ .instanceEvents([
+ InstanceUpdateEventBuilder.action(JobUpdateAction.INSTANCE_UPDATE_FAILED).build(),
+ InstanceUpdateEventBuilder.instanceId(2)
+ .action(JobUpdateAction.INSTANCE_ROLLED_BACK)
+ .build(),
+ instanceUpdated.instanceId(3).build()
+ ])
+ .build();
+ const summary = instanceSummary(update);
+ expect(summary).toEqual([
+ {instanceId: '0', className: 'error', title: 'INSTANCE_UPDATE_FAILED'},
+ {instanceId: '1', className: 'pending', title: 'Pending'},
+ {instanceId: '2', className: 'attention', title: 'INSTANCE_ROLLED_BACK'},
+ {instanceId: '3', className: 'removed', title: 'Instance Removed'}
+ ]);
+ });
+});
+
+describe('statusDispatcher', () => {
+ it('Should fire the success callback for successful updates', () => {
+ const spies = createDispatchSpies();
+ const dispatcher = statusDispatcher(spies);
+ dispatcher(builderWithStatus(JobUpdateStatus.ROLLED_FORWARD).build());
+ expect(assertSpies(spies, {success: 1})).toBe(true);
+ });
+
+ it('Should fire the in-progress callback for rolling forward updates', () => {
+ const spies = createDispatchSpies();
+ const dispatcher = statusDispatcher(spies);
+ dispatcher(builderWithStatus(JobUpdateStatus.ROLLING_FORWARD).build());
+ expect(assertSpies(spies, {inProgress: 1})).toBe(true);
+ });
+
+ it('Should fire the error callback for all failed updates', () => {
+ ERROR_UPDATE_STATUS.forEach((status) => {
+ const spies = createDispatchSpies();
+ const dispatcher = statusDispatcher(spies);
+ dispatcher(builderWithStatus(status).build());
+ expect(assertSpies(spies, {error: 1})).toBe(true);
+ });
+ });
+
+ it('Should fire the warning callback for all failed updates', () => {
+ WARNING_UPDATE_STATUS.forEach((status) => {
+ const spies = createDispatchSpies();
+ const dispatcher = statusDispatcher(spies);
+ dispatcher(builderWithStatus(status).build());
+ expect(assertSpies(spies, {warning: 1})).toBe(true);
+ });
+ });
+});
+
+describe('updateStats', () => {
+ const instanceUpdated = InstanceUpdateEventBuilder.action(JobUpdateAction.INSTANCE_UPDATED);
+
+ it('Should return the correct stats for a job with some instances to be updated', () => {
+ const instructions = UpdateInstructionsBuilder
+ .initialState([InstanceTaskConfigBuilder.instances([{first: 0, last: 9}]).build()])
+ .desiredState(InstanceTaskConfigBuilder.instances([{first: 0, last: 9}]).build())
+ .build();
+
+ const update = UpdateDetailsBuilder
+ .update(UpdateBuilder.instructions(instructions).build())
+ .instanceEvents([
+ InstanceUpdateEventBuilder.action(JobUpdateAction.INSTANCE_UPDATE_FAILED).build(),
+ instanceUpdated.instanceId(0).build(),
+ instanceUpdated.instanceId(2).build(),
+ instanceUpdated.instanceId(3).build(),
+ instanceUpdated.instanceId(5).build(),
+ instanceUpdated.instanceId(9).build()
+ ])
+ .build();
+ const stats = updateStats(update);
+ expect(stats).toEqual({totalInstancesToBeUpdated: 10, instancesUpdated: 5, progress: 50});
+ });
+
+ it('Should respect added instances', () => {
+ const instructions = UpdateInstructionsBuilder
+ .initialState([InstanceTaskConfigBuilder.instances([{first: 0, last: 4}]).build()])
+ .desiredState(InstanceTaskConfigBuilder.instances([{first: 0, last: 9}]).build())
+ .build();
+
+ const update = UpdateDetailsBuilder
+ .update(UpdateBuilder.instructions(instructions).build())
+ .instanceEvents([
+ instanceUpdated.instanceId(7).build(),
+ instanceUpdated.instanceId(8).build()
+ ])
+ .build();
+ const stats = updateStats(update);
+ expect(stats).toEqual({totalInstancesToBeUpdated: 10, instancesUpdated: 2, progress: 20});
+ });
+
+ it('Should respect deleted instances', () => {
+ const instructions = UpdateInstructionsBuilder
+ .initialState([InstanceTaskConfigBuilder.instances([{first: 0, last: 9}]).build()])
+ .desiredState(InstanceTaskConfigBuilder.instances([{first: 0, last: 4}]).build())
+ .build();
+
+ const update = UpdateDetailsBuilder
+ .update(UpdateBuilder.instructions(instructions).build())
+ .instanceEvents([
+ instanceUpdated.instanceId(7).build(),
+ instanceUpdated.instanceId(8).build()
+ ])
+ .build();
+ const stats = updateStats(update);
+ expect(stats).toEqual({totalInstancesToBeUpdated: 10, instancesUpdated: 2, progress: 20});
+ });
+
+ it('Any instances updated should show up in stats, even if rolled back', () => {
+ const instructions = UpdateInstructionsBuilder
+ .initialState([InstanceTaskConfigBuilder.instances([{first: 0, last: 9}]).build()])
+ .desiredState(InstanceTaskConfigBuilder.instances([{first: 0, last: 9}]).build())
+ .build();
+
+ const update = UpdateDetailsBuilder
+ .update(UpdateBuilder.instructions(instructions).build())
+ .instanceEvents([
+ instanceUpdated.instanceId(0).build(),
+ instanceUpdated.instanceId(2).build(),
+ InstanceUpdateEventBuilder.instanceId(2)
+ .action(JobUpdateAction.INSTANCE_UPDATE_FAILED)
+ .timestampMs(2)
+ .build()
+ ])
+ .build();
+ const stats = updateStats(update);
+ expect(stats).toEqual({totalInstancesToBeUpdated: 10, instancesUpdated: 2, progress: 20});
+ });
+});
http://git-wip-us.apache.org/repos/asf/aurora/blob/4a1fba3c/ui/src/main/sass/app.scss
----------------------------------------------------------------------
diff --git a/ui/src/main/sass/app.scss b/ui/src/main/sass/app.scss
index e301d4c..315b666 100644
--- a/ui/src/main/sass/app.scss
+++ b/ui/src/main/sass/app.scss
@@ -6,12 +6,15 @@
/* Indiviudal Components */
@import 'components/breadcrumb';
+@import 'components/instance-viz';
@import 'components/navigation';
@import 'components/state-machine';
@import 'components/status';
@import 'components/tables';
+@import 'components/update-list';
/* Page Styles */
@import 'components/home-page';
@import 'components/instance-page';
-@import 'components/job-list-page';
\ No newline at end of file
+@import 'components/job-list-page';
+@import 'components/update-page';
\ No newline at end of file
http://git-wip-us.apache.org/repos/asf/aurora/blob/4a1fba3c/ui/src/main/sass/components/_instance-viz.scss
----------------------------------------------------------------------
diff --git a/ui/src/main/sass/components/_instance-viz.scss b/ui/src/main/sass/components/_instance-viz.scss
new file mode 100644
index 0000000..36a94a6
--- /dev/null
+++ b/ui/src/main/sass/components/_instance-viz.scss
@@ -0,0 +1,78 @@
+.instance-grid {
+ list-style-type: none;
+ margin: 0;
+ padding: 0;
+ margin-bottom: 20px;
+ overflow: hidden;
+
+ li {
+ padding: 0;
+ float: left;
+ margin-right: 2px;
+ margin-bottom: 2px;
+ }
+
+ .instance-id {
+ visibility: hidden;
+ }
+
+ &:after {
+ clear: both;
+ content: '#';
+ visibility: hidden;
+ }
+
+ .okay {
+ border: 1px solid $colors_success;
+ background-color: $colors_success_light;
+ }
+
+ .attention {
+ border: 1px solid $colors_warning;
+ background-color: $colors_warning_light;
+ }
+
+ .error {
+ border: 1px solid $colors_error;
+ background-color: $colors_error_light;
+ }
+
+ .in-progress {
+ border: 1px solid $colors_highlight;
+ background-color: $colors_highlight_light;
+ }
+
+ .removed {
+ border: 1px solid #444;
+ background-color: #999;
+ }
+
+ .instance-updated {
+ border: 1px solid $colors_success;
+ background-color: $colors_success_light;
+ }
+
+ .pending {
+ border: 1px solid $colors_success;
+ }
+
+ .instance-updating {
+ border: 1px solid $success_secondary_color;
+ background-color: $success_secondary_color;
+ }
+}
+
+.instance-grid.small li {
+ width: 3px;
+ height: 6px;
+}
+
+.instance-grid.medium li {
+ width: 6px;
+ height: 12px;
+}
+
+.instance-grid.big li {
+ width: 15px;
+ height: 25px;
+}
\ No newline at end of file
http://git-wip-us.apache.org/repos/asf/aurora/blob/4a1fba3c/ui/src/main/sass/components/_layout.scss
----------------------------------------------------------------------
diff --git a/ui/src/main/sass/components/_layout.scss b/ui/src/main/sass/components/_layout.scss
index 4ebec05..4818ab2 100644
--- a/ui/src/main/sass/components/_layout.scss
+++ b/ui/src/main/sass/components/_layout.scss
@@ -21,6 +21,16 @@
font-size: 18px;
}
+ .content-panel-subtitle {
+ padding: 15px 20px 5px 20px;
+ background-color: rgba(250, 250, 250, 1);
+ border-bottom: 1px solid $grid_color;
+ text-transform: uppercase;
+ color: #555;
+ font-size: 14px;
+ font-weight: 700;
+ }
+
.content-panel {
padding: 20px;
background-color: $content_box_color;
@@ -29,6 +39,81 @@
.content-panel + .content-panel {
margin-top: 1px;
}
+
+ .fluid {
+ padding: 0px;
+ }
+
+ .content-icon-title {
+ background-color: $content_box_color;
+
+ .content-icon-title-text {
+ background-color: $grid_color;
+ padding: 15px 10px;
+ margin-left: -20px;
+ display: block;
+ color: white;
+
+ font-weight: $heavy;
+ text-transform: uppercase;
+ font-size: 24px;
+
+ .glyphicon {
+ font-size: 0.8em;
+ }
+ }
+
+ &:after {
+ content: "";
+ width: 0;
+ height: 0;
+ display: block;
+ margin-left: -20px;
+ border-width: 0 20px 12px 0;
+ border-color: transparent $grid_color transparent transparent;
+ border-style: solid;
+ }
+ }
+}
+
+.success {
+ .content-icon-title-text {
+ background-color: $colors_success !important;
+ }
+
+ &:after {
+ border-color: transparent $colors_success_dark transparent transparent !important;
+ }
+}
+
+.error {
+ .content-icon-title-text {
+ background-color: $colors_error !important;
+ }
+
+ &:after {
+ border-color: transparent $colors_error_dark transparent transparent !important;
+ }
+}
+
+.highlight {
+ .content-icon-title-text {
+ background-color: $colors_highlight !important;
+ }
+
+ &:after {
+ border-color: transparent $colors_highlight_dark transparent transparent !important;
+ }
+}
+
+.attention {
+ .content-icon-title-text {
+ background-color: $colors_warning !important;
+ }
+
+ &:after {
+ border-color: transparent $colors_warning_dark transparent transparent !important;
+ }
}
.content-panel-fluid {
http://git-wip-us.apache.org/repos/asf/aurora/blob/4a1fba3c/ui/src/main/sass/components/_update-list.scss
----------------------------------------------------------------------
diff --git a/ui/src/main/sass/components/_update-list.scss b/ui/src/main/sass/components/_update-list.scss
new file mode 100644
index 0000000..83a1f5a
--- /dev/null
+++ b/ui/src/main/sass/components/_update-list.scss
@@ -0,0 +1,38 @@
+.update-list {
+ .update-list-item {
+ display: flex;
+ align-items: center;
+ padding: 10px 20px;
+ border-bottom: 1px solid $grid_color;
+
+ .img-circle {
+ margin-right: 10px;
+ }
+
+ a {
+ font-size: 16px;
+ font-weight: 600;
+ }
+
+ .update-list-item-status {
+ display: block;
+ }
+
+ &:hover {
+ background-color: #edf5fd;
+ }
+
+ .update-list-user {
+ font-weight: 600;
+ }
+
+ .update-list-status {
+ color: $secondary_font_color;
+ }
+
+ .update-list-last-updated {
+ margin-left: auto;
+ color: $secondary_font_color;
+ }
+ }
+}
http://git-wip-us.apache.org/repos/asf/aurora/blob/4a1fba3c/ui/src/main/sass/components/_update-page.scss
----------------------------------------------------------------------
diff --git a/ui/src/main/sass/components/_update-page.scss b/ui/src/main/sass/components/_update-page.scss
new file mode 100644
index 0000000..a0db0b4
--- /dev/null
+++ b/ui/src/main/sass/components/_update-page.scss
@@ -0,0 +1,151 @@
+.update-page {
+ pre {
+ border: 0;
+ background-color: $content_box_color;
+ }
+
+ .update-summary-stats {
+ display: flex;
+ justify-content: space-between;
+ align-items: baseline;
+ text-transform: uppercase;
+
+ h5 {
+ margin: 0;
+ font-size: 14px;
+ }
+
+ span {
+ font-size: 12px;
+ }
+ }
+
+ .update-settings {
+ width: 100%;
+ font-size: 14px;
+ text-transform: uppercase;
+
+ td {
+ padding: 3px;
+ }
+ }
+
+ .update-byline {
+ margin-top: -15px;
+ color: $secondary_font_color;
+
+ span + span {
+ margin: 0px 3px;
+ }
+
+ span:last-child {
+ margin: 0;
+ }
+ }
+
+ .update-time {
+ text-align: center;
+ text-transform: uppercase;
+ }
+
+ .update-time h4 {
+ font-size: 33px;
+ margin: 0;
+ }
+
+ .update-time .time-ago {
+ color: #999;
+ }
+
+ .time-divider {
+ text-align: center;
+ font-size: 70px;
+ font-weight: bold;
+ color: #ccc;
+ }
+
+ .time-display-duration {
+ text-transform: uppercase;
+ text-align: center;
+ color: #999;
+ margin-top: -20px;
+ margin-bottom: 20px;
+ }
+
+ .update-duration {
+ text-align: center;
+ color: #999;
+ text-transform: uppercase;
+ margin-bottom: 20px;
+ }
+
+ .update-time-range {
+ display: flex;
+ justify-content: space-around;
+ align-items: center;
+ margin-top: 20px;
+
+ h5 {
+ color: #CCC;
+ font-weight: 900;
+ font-size: 60px;
+ line-height: 40px;
+ }
+
+ .update-time, .update-duration {
+ font-size: 12px;
+ }
+ }
+
+ .instance-events {
+ .glyphicon-chevron-right {
+ color: $secondary_font_color;
+ font-size: 12px;
+ }
+
+ .update-instance-event-id {
+ font-weight: $heavy;
+ margin: 0 5px;
+ display: inline-block;
+ }
+
+ .update-instance-event-time {
+ float: right;
+ color: $secondary_font_color;
+ display: inline-block;
+ font-size: 14px;
+ }
+
+ .update-instance-event-status {
+ .glyphicon {
+ margin-left: 5px;
+ }
+
+ .okay {
+ color: $colors_success !important;
+ }
+
+ .attention {
+ color: $colors_warning !important;
+ }
+
+ .error {
+ color: $colors_error !important;
+ }
+
+ .in-progress {
+ color: $colors_highlight !important;
+ }
+ }
+
+ .update-instance-event-container {
+ border-bottom: 1px solid $grid_color;
+ padding: 5px 20px;
+
+ &:hover {
+ cursor: pointer;
+ cursor: hand;
+ }
+ }
+ }
+}
\ No newline at end of file
http://git-wip-us.apache.org/repos/asf/aurora/blob/4a1fba3c/ui/test-setup.js
----------------------------------------------------------------------
diff --git a/ui/test-setup.js b/ui/test-setup.js
index a403434..a86a89a 100644
--- a/ui/test-setup.js
+++ b/ui/test-setup.js
@@ -28,4 +28,39 @@ global.ScheduleStatus = {
};
global.ACTIVE_STATES = [9,17,6,0,13,12,2,1,16];
-global.TaskQuery = () => {};
\ No newline at end of file
+global.TaskQuery = () => {};
+global.JobKey = () => {};
+global.JobUpdateKey = () => {};
+global.JobUpdateQuery = () => {};
+
+global.JobUpdateStatus = {
+ 'ROLLING_FORWARD' : 0,
+ 'ROLLING_BACK' : 1,
+ 'ROLL_FORWARD_PAUSED' : 2,
+ 'ROLL_BACK_PAUSED' : 3,
+ 'ROLLED_FORWARD' : 4,
+ 'ROLLED_BACK' : 5,
+ 'ABORTED' : 6,
+ 'ERROR' : 7,
+ 'FAILED' : 8,
+ 'ROLL_FORWARD_AWAITING_PULSE' : 9,
+ 'ROLL_BACK_AWAITING_PULSE' : 10
+};
+
+global.ACTIVE_JOB_UPDATE_STATES = [
+ JobUpdateStatus.ROLLING_FORWARD,
+ JobUpdateStatus.ROLLING_BACK,
+ JobUpdateStatus.ROLL_FORWARD_PAUSED,
+ JobUpdateStatus.ROLL_BACK_PAUSED,
+ JobUpdateStatus.ROLL_FORWARD_AWAITING_PULSE,
+ JobUpdateStatus.ROLL_BACK_AWAITING_PULSE
+];
+
+global.JobUpdateAction = {
+ 'INSTANCE_UPDATED' : 1,
+ 'INSTANCE_ROLLED_BACK' : 2,
+ 'INSTANCE_UPDATING' : 3,
+ 'INSTANCE_ROLLING_BACK' : 4,
+ 'INSTANCE_UPDATE_FAILED' : 5,
+ 'INSTANCE_ROLLBACK_FAILED' : 6
+};