1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-06-23 01:16:27 +02:00
This commit is contained in:
sveisvei 2016-12-04 11:56:41 +01:00
parent 49ba034cfc
commit eeb40113c5
29 changed files with 617 additions and 306 deletions

View File

@ -2,12 +2,15 @@
<html> <html>
<head> <head>
<title>Unleash Admin</title> <title>Unleash Admin</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="stylesheet" href="/node_modules/react-mdl/extra/material.min.css">
<link rel="stylesheet" href="/static/bundle.css" /> <link rel="stylesheet" href="/static/bundle.css" />
<link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet"> <link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet">
<link href="https://fonts.googleapis.com/css?family=Roboto:300,400,500,700" rel="stylesheet"> <link href="https://fonts.googleapis.com/css?family=Roboto:300,400,500,700" rel="stylesheet">
</head> </head>
<body> <body>
<div id='app'></div> <div id='app'></div>
<script src="/node_modules/react-mdl/extra/material.min.js"></script>
<script src="/static/bundle.js"></script> <script src="/static/bundle.js"></script>
</body> </body>
</html> </html>

View File

@ -34,12 +34,13 @@
"immutability-helper": "^2.0.0", "immutability-helper": "^2.0.0",
"immutable": "^3.8.1", "immutable": "^3.8.1",
"normalize.css": "^5.0.0", "normalize.css": "^5.0.0",
"percent": "^2.0.0",
"react": "^15.3.1", "react": "^15.3.1",
"react-addons-css-transition-group": "^15.3.1", "react-addons-css-transition-group": "^15.3.1",
"react-dom": "^15.3.1", "react-dom": "^15.3.1",
"react-mdl": "^1.9.0",
"react-redux": "^4.4.5", "react-redux": "^4.4.5",
"react-router": "^3.0.0", "react-router": "^3.0.0",
"react-toolbox": "^1.2.1",
"redux": "^3.6.0", "redux": "^3.6.0",
"redux-thunk": "^2.1.0", "redux-thunk": "^2.1.0",
"whatwg-fetch": "^2.0.0" "whatwg-fetch": "^2.0.0"

View File

@ -1,14 +1,18 @@
import React, { Component } from 'react'; import React, { Component } from 'react';
import { Layout, Panel, NavDrawer, AppBar } from 'react-toolbox'; import { Layout, Drawer, Header, Navigation, Content,
Footer, FooterSection, FooterDropDownSection, FooterLinkList,
Grid, Cell
} from 'react-mdl';
import style from './styles.scss'; import style from './styles.scss';
import ErrorContainer from './error/error-container'; import ErrorContainer from './error/error-container';
import UserContainer from './user/user-container'; import UserContainer from './user/user-container';
import ShowUserContainer from './user/show-user-container'; import ShowUserContainer from './user/show-user-container';
import Navigation from './nav';
export default class App extends Component { export default class App extends Component {
constructor (props) { constructor (props) {
super(props); super(props);
this.state = { drawerActive: false }; this.state = { drawerActive: false };
@ -17,14 +21,102 @@ export default class App extends Component {
this.setState({ drawerActive: !this.state.drawerActive }); this.setState({ drawerActive: !this.state.drawerActive });
}; };
} }
static contextTypes = {
router: React.PropTypes.object,
}
onOverlayClick = () => this.setState({ drawerActive: false }); onOverlayClick = () => this.setState({ drawerActive: false });
render () { render () {
const createListItem = (path, caption) =>
<a
href={this.context.router.createHref(path)}
className={this.context.router.isActive(path) ? style.active : ''}>
{caption}
</a>;
return (
<div style={{}}>
<UserContainer />
<Layout fixedHeader>
<Header title={<span><span style={{ color: '#ddd' }}>Unleash Admin / </span><strong>The Title</strong></span>}>
<Navigation>
<a href="https://github.com/Unleash" target="_blank">Github</a>
<ShowUserContainer />
</Navigation>
</Header>
<Drawer title="Unleash Admin">
<Navigation>
{createListItem('/features', 'Feature toggles')}
{createListItem('/strategies', 'Strategies')}
{createListItem('/history', 'Event history')}
{createListItem('/archive', 'Archived toggles')}
<hr />
{createListItem('/metrics', 'Client metrics')}
{createListItem('/client-strategies', 'Client strategies')}
{createListItem('/client-instances', 'Client instances')}
</Navigation>
</Drawer>
<Content>
<Grid noSpacing>
<Cell col={12}>
{this.props.children}
<ErrorContainer />
</Cell>
</Grid>
<Footer size="mega">
<FooterSection type="middle">
<FooterDropDownSection title="Menu">
<FooterLinkList>
{createListItem('/features', 'Feature toggles')}
{createListItem('/strategies', 'Strategies')}
{createListItem('/history', 'Event history')}
{createListItem('/archive', 'Archived toggles')}
</FooterLinkList>
</FooterDropDownSection>
<FooterDropDownSection title="Metrics">
<FooterLinkList>
{createListItem('/metrics', 'Client metrics')}
{createListItem('/client-strategies', 'Client strategies')}
{createListItem('/client-instances', 'Client instances')}
</FooterLinkList>
</FooterDropDownSection>
<FooterDropDownSection title="FAQ">
<FooterLinkList>
<a href="#">Help</a>
<a href="#">Privacy & Terms</a>
<a href="#">Questions</a>
<a href="#">Answers</a>
<a href="#">Contact Us</a>
</FooterLinkList>
</FooterDropDownSection>
<FooterDropDownSection title="Clients">
<FooterLinkList>
<a href="https://github.com/Unleash/unleash-node-client/">Node.js</a>
<a href="https://github.com/Unleash/unleash-java-client/">Java</a>
</FooterLinkList>
</FooterDropDownSection>
</FooterSection>
<FooterSection type="bottom" logo="Unleash Admin">
<FooterLinkList>
<a href="https://github.com/Unleash/unleash/" target="_blank">
GitHub
</a>
<a href="https://finn.no" target="_blank"><small>A product by</small> FINN.no</a>
</FooterLinkList>
</FooterSection>
</Footer>
</Content>
</Layout>
</div>
);
return ( return (
<div className={style.container}> <div className={style.container}>
<AppBar title="Unleash Admin" leftIcon="menu" onLeftIconClick={this.toggleDrawerActive} className={style.appBar}> <AppBar title="Unleash Admin" leftIcon="menu" onLeftIconClick={this.toggleDrawerActive} className={style.appBar}>
<ShowUserContainer />
</AppBar> </AppBar>
<div className={style.container} style={{ top: '6.4rem' }}> <div className={style.container} style={{ top: '6.4rem' }}>
<Layout> <Layout>
@ -33,11 +125,11 @@ export default class App extends Component {
</NavDrawer> </NavDrawer>
<Panel scrollY> <Panel scrollY>
<div style={{ padding: '1.8rem' }}> <div style={{ padding: '1.8rem' }}>
<UserContainer />
{this.props.children} {this.props.children}
</div> </div>
</Panel> </Panel>
<ErrorContainer />
</Layout> </Layout>
</div> </div>
</div> </div>

View File

@ -1,30 +1,5 @@
import React, { Component } from 'react'; import React, { Component } from 'react';
import { List, ListItem, ListSubHeader } from 'react-toolbox/lib/list'; import { DataTable, TableHeader, Chip, Switch, IconButton } from 'react-mdl';
import FontIcon from 'react-toolbox/lib/font_icon';
import Chip from 'react-toolbox/lib/chip';
import Switch from 'react-toolbox/lib/switch';
const ArchivedFeature = ({ feature, revive }) => {
const { name, description, enabled, strategies } = feature;
const actions = [
<div>{strategies && strategies.map(s => <Chip><small>{s.name}</small></Chip>)}</div>,
<FontIcon style={{ cursor: 'pointer' }} value="undo" onClick={() => revive(feature)} />,
];
const leftActions = [
<Switch disabled checked={enabled} />,
];
return (
<ListItem
key={name}
leftActions={leftActions}
rightActions={actions}
caption={name}
legend={(description && description.substring(0, 100)) || '-'}
/>
);
};
class ArchiveList extends Component { class ArchiveList extends Component {
componentDidMount () { componentDidMount () {
@ -34,12 +9,20 @@ class ArchiveList extends Component {
render () { render () {
const { archive, revive } = this.props; const { archive, revive } = this.props;
return ( return (
<List ripple > <div>
<ListSubHeader caption="Archive" /> <h6>Toggle Archive</h6>
{archive.length > 0 ?
archive.map((feature, i) => <ArchivedFeature key={i} feature={feature} revive={revive} />) : <DataTable
<ListItem caption="No archived feature toggles" />} rows={archive}
</List> style={{ width: '100%' }}>
<TableHeader style={{ width: '25px' }} name="strategies" cellFormatter={(name) => (
<IconButton colored name="undo" onClick={() => revive(name)} />
)}>Revive</TableHeader>
<TableHeader style={{ width: '25px' }} name="enabled" cellFormatter={(v) => (v ? 'Yes' : '-')}>Enabled</TableHeader>
<TableHeader name="name">Toggle name</TableHeader>
<TableHeader numeric name="createdAt">Created</TableHeader>
</DataTable>
</div>
); );
} }
} }

View File

@ -1,13 +1,5 @@
import React, { Component, PropTypes } from 'react'; import React, { Component, PropTypes } from 'react';
import Table from 'react-toolbox/lib/table'; import { DataTable, TableHeader } from 'react-mdl';
const Model = {
appName: { type: String, title: 'Application Name' },
instanceId: { type: String },
clientIp: { type: String },
createdAt: { type: String },
lastSeen: { type: String },
};
class ClientStrategies extends Component { class ClientStrategies extends Component {
static propTypes () { static propTypes () {
@ -25,11 +17,20 @@ class ClientStrategies extends Component {
const source = this.props.clientInstances; const source = this.props.clientInstances;
return ( return (
<Table <DataTable
model={Model} style={{ width: '100%' }}
source={source} rows={source}
selectable={false} selectable={false}
/> >
<TableHeader name="instanceId">Instance ID</TableHeader>
<TableHeader name="appName">Application name</TableHeader>
<TableHeader name="clientIp">IP</TableHeader>
<TableHeader name="createdAt">Created</TableHeader>
<TableHeader name="lastSeen">Last Seen</TableHeader>
</DataTable>
); );
} }
} }

View File

@ -1,10 +1,5 @@
import React, { Component } from 'react'; import React, { Component } from 'react';
import Table from 'react-toolbox/lib/table'; import { DataTable, TableHeader } from 'react-mdl';
const Model = {
appName: { type: String, title: 'Application Name' },
strategies: { type: String },
};
class ClientStrategies extends Component { class ClientStrategies extends Component {
@ -13,19 +8,25 @@ class ClientStrategies extends Component {
} }
render () { render () {
const source = this.props.clientStrategies.map(item => ( const source = this.props.clientStrategies
// temp hack for ignoring dumb data
.filter(item => item.strategies)
.map(item => (
{ {
appName: item.appName, appName: item.appName,
strategies: item.strategies.join(', '), strategies: item.strategies && item.strategies.join(', '),
}) })
); );
return ( return (
<Table <DataTable
model={Model} style={{ width: '100%' }}
source={source} rows={source}
selectable={false} selectable={false}
/> >
<TableHeader name="appName">Application name</TableHeader>
<TableHeader name="strategies">Strategies</TableHeader>
</DataTable>
); );
} }
} }

View File

@ -1,6 +1,7 @@
import Snackbar from 'react-toolbox/lib/snackbar';
import React, { PropTypes } from 'react'; import React, { PropTypes } from 'react';
import { Snackbar } from 'react-mdl';
class ErrorComponent extends React.Component { class ErrorComponent extends React.Component {
static propTypes () { static propTypes () {
return { return {

View File

@ -1,10 +1,10 @@
import React, { PropTypes } from 'react'; import React, { PropTypes } from 'react';
import { Link } from 'react-router'; import { Link } from 'react-router';
import FontIcon from 'react-toolbox/lib/font_icon'; import { Chip, Switch, Icon, Tooltip, IconButton, ChipContact } from 'react-mdl';
import Switch from 'react-toolbox/lib/switch'; import percentLib from 'percent';
import { ListItem } from 'react-toolbox/lib/list'; import Progress from './progress';
import Chip from 'react-toolbox/lib/chip';
import style from './feature.scss'; import style from './feature.scss';
@ -12,42 +12,55 @@ const Feature = ({
feature, feature,
onFeatureClick, onFeatureClick,
onFeatureRemove, onFeatureRemove,
metricsLastHour = { yes: 0, no: 0, hasData: false }, settings,
metricsLastMinute = { yes: 0, no: 0, hasData: false }, metricsLastHour = { yes: 0, no: 0, isFallback: true },
metricsLastMinute = { yes: 0, no: 0, isFallback: true },
}) => { }) => {
const { name, description, enabled, strategies, createdAt } = feature; const { name, description, enabled, strategies } = feature;
const created = new Date(createdAt);
const actions = [ const { showLastHour = false } = settings;
<div key="strategies">{strategies && strategies.map((s, i) => <Chip key={i}><small>{s.name}</small></Chip>)}</div>, const isStale = showLastHour ? metricsLastHour.isFallback : metricsLastMinute.isFallback;
<div key="created"><small>({created.toLocaleDateString('nb-NO')})</small></div>,
<Link key="change" to={`/features/edit/${name}`} title={`Edit ${name}`}>
<FontIcon value="edit" className={style.action} />
</Link>,
<Link key="history" to={`/history/${name}`} title={`History for ${name}`}>
<FontIcon value="history" className={style.action} />
</Link>,
<FontIcon key="delete" className={style.action} value="delete" onClick={() => onFeatureRemove(name)} />,
];
const leftActions = [
<Chip key="m.hour">
<span className={style.yes}>{metricsLastHour.yes}</span> / <span className={style.no}>{metricsLastHour.no}</span>
</Chip>,
<Chip key="m.min">
<span className={style.yes}>{metricsLastMinute.yes}</span> / <span className={style.no}>{metricsLastMinute.no}</span>
</Chip>,
<Switch key="left-actions" onChange={() => onFeatureClick(feature)} checked={enabled} />,
];
const percent = 1 * (showLastHour ?
percentLib.calc(metricsLastHour.yes, metricsLastHour.yes + metricsLastHour.no, 0) :
percentLib.calc(metricsLastMinute.yes, metricsLastMinute.yes + metricsLastMinute.no, 0)
);
return ( return (
<ListItem <li key={name} className="mdl-list__item">
key={name} <span className="mdl-list__item-primary-content">
leftActions={leftActions} <div style={{ width: '40px', textAlign: 'center' }}>
rightActions={actions} {
caption={name} isStale ?
legend={(description && description.substring(0, 100)) || '-'} <Icon style={{ width: '25px', marginTop: '4px', fontSize: '25px', color: '#ccc' }} name="report problem" title="No metrics avaiable" /> :
/> <div>
<Progress strokeWidth={15} percentage={percent} width="50" />
</div>
}
</div>
&nbsp;
<span style={{ display: 'inline-block', width: '45px' }} title={`Toggle ${name}`}>
<Switch title="test" key="left-actions" onChange={() => onFeatureClick(feature)} checked={enabled} />
</span>
<Link to={`/features/edit/${name}`} className={style.link}>
{name} <small>{(description && description.substring(0, 100)) || ''}</small>
</Link>
</span>
<span className={style.iconList} >
{strategies && strategies.map((s, i) => <Chip className={style.iconListItemChip} key={i}>
<small>{s.name}</small>
</Chip>)}
<Link to={`/features/edit/${name}`} title={`Edit ${name}`} className={style.iconListItem}>
<IconButton name="edit" />
</Link>
<Link to={`/history/${name}`} title={`History htmlFor ${name}`} className={style.iconListItem}>
<IconButton name="history" />
</Link>
<IconButton name="delete" onClick={() => onFeatureRemove(name)} className={style.iconListItem} />
</span>
</li>
); );
}; };

View File

@ -1,7 +1,3 @@
.link {
color: #212121;
}
.action { .action {
color: #aaa !important; color: #aaa !important;
cursor: pointer; cursor: pointer;
@ -14,3 +10,52 @@
.no { .no {
color: red; color: red;
} }
.link {
color: #212121;
text-decoration: none;
}
.link small {
color: #aaa;
font-weight: 100;
}
.link:hover {
color: #000;
}
.iconList {
display: flex;
}
.iconListItem {
flex: 1;
color: #bbb !important;
}
.iconListItem *:hover {
color: #333;
}
.iconListItemChip {
flex: 1;
margin-left: 5px !important;
}
.topList {
display: flex;
margin: 10px 10px 10px 10px;
}
.topListItem0 {
flex: 1;
flex-grow: 0;
}
.topListItem {
flex: 1;
}
.topListItem2 {
flex: 2;
}

View File

@ -3,7 +3,7 @@ import { hashHistory } from 'react-router';
import { requestUpdateFeatureToggle } from '../../store/feature-actions'; import { requestUpdateFeatureToggle } from '../../store/feature-actions';
import { createMapper, createActions } from '../input-helpers'; import { createMapper, createActions } from '../input-helpers';
import FormComponent from './form'; import EditAndView from './view-and-edit';
const ID = 'edit-feature-toggle'; const ID = 'edit-feature-toggle';
function getId (props) { function getId (props) {
@ -59,4 +59,4 @@ const actions = createActions({
prepare, prepare,
}); });
export default connect(mapStateToProps, actions)(FormComponent); export default connect(mapStateToProps, actions)(EditAndView);

View File

@ -1,7 +1,5 @@
import React, { Component, PropTypes } from 'react'; import React, { Component, PropTypes } from 'react';
import Input from 'react-toolbox/lib/input'; import { Textfield, Button, Switch } from 'react-mdl';
import Button from 'react-toolbox/lib/button';
import Switch from 'react-toolbox/lib/switch';
import StrategiesSection from './strategies-section-container'; import StrategiesSection from './strategies-section-container';
const trim = (value) => { const trim = (value) => {
@ -45,29 +43,31 @@ class AddFeatureToggleComponent extends Component {
return ( return (
<form onSubmit={onSubmit(input)}> <form onSubmit={onSubmit(input)}>
<section> <section>
<Input <Textfield
type="text"
label="Name" label="Name"
name="name" name="name"
disabled={editmode} disabled={editmode}
required required
value={name} value={name}
error={nameError} error={nameError}
onBlur={(v) => validateName(v)} onBlur={(v) => validateName(v.target.value)}
onChange={(v) => setValue('name', trim(v))} /> onChange={(v) => setValue('name', trim(v.target.value))} />
<Input <br />
type="text" <Textfield
multiline label="Description" rows={2}
label="Description"
required required
value={description} value={description}
onChange={(v) => setValue('description', v)} /> onChange={(v) => setValue('description', v.target.value)} />
<br /> <br />
<Switch <Switch
checked={enabled} checked={enabled}
label="Enabled" onChange={(v) => {
onChange={(v) => setValue('enabled', v)} /> // todo is wrong way to get value?
setValue('enabled', (console.log(v.target) && v.target.value === 'on'));
}}>Enabled</Switch>
<br /> <br />
</section> </section>
@ -78,12 +78,9 @@ class AddFeatureToggleComponent extends Component {
removeStrategy={removeStrategy} /> removeStrategy={removeStrategy} />
<br /> <br />
<Button type="submit" raised primary>{editmode ? 'Update' : 'Create'}</Button>
<hr />
<Button type="submit" raised primary label={editmode ? 'Update' : 'Create'} />
&nbsp; &nbsp;
<Button type="cancel" raised label="Cancel" onClick={onCancel} /> <Button type="cancel" raised onClick={onCancel}>Cancel</Button>
</form> </form>
); );
} }

View File

@ -1,6 +1,7 @@
import React, { PropTypes } from 'react'; import React, { PropTypes } from 'react';
import Dropdown from 'react-toolbox/lib/dropdown'; // import Dropdown from 'react-toolbox/lib/dropdown';
import FontIcon from 'react-toolbox/lib/font_icon'; // TODO use menu
import { Icon } from 'react-mdl';
class AddStrategy extends React.Component { class AddStrategy extends React.Component {
@ -41,7 +42,7 @@ class AddStrategy extends React.Component {
return ( return (
<div style={containerStyle}> <div style={containerStyle}>
<FontIcon value="add" /> <Icon value="add" />
<div style={contentStyle}> <div style={contentStyle}>
<strong>{item.name}</strong> <strong>{item.name}</strong>
<small>{item.description}</small> <small>{item.description}</small>
@ -56,8 +57,8 @@ class AddStrategy extends React.Component {
return s; return s;
}); });
return ( /*
<div style={{ maxWidth: '400px', marginTop: '20px' }}>
<Dropdown <Dropdown
allowBlank={false} allowBlank={false}
auto auto
@ -66,6 +67,12 @@ class AddStrategy extends React.Component {
label="Click to add activation strategy" label="Click to add activation strategy"
template={this.customItem} template={this.customItem}
/> />
*/
return (
<div style={{ maxWidth: '400px', marginTop: '20px' }}>
</div> </div>
); );
} }

View File

@ -1,6 +1,5 @@
import React, { PropTypes } from 'react'; import React, { PropTypes } from 'react';
import Input from 'react-toolbox/lib/input'; import { Textfield, Button } from 'react-mdl';
import Button from 'react-toolbox/lib/button';
class StrategyConfigure extends React.Component { class StrategyConfigure extends React.Component {
@ -30,7 +29,7 @@ class StrategyConfigure extends React.Component {
renderInputFields (strategyDefinition) { renderInputFields (strategyDefinition) {
if (strategyDefinition.parametersTemplate) { if (strategyDefinition.parametersTemplate) {
return Object.keys(strategyDefinition.parametersTemplate).map(field => ( return Object.keys(strategyDefinition.parametersTemplate).map(field => (
<Input <Textfield
type="text" type="text"
key={field} key={field}
name={field} name={field}

View File

@ -1,7 +1,9 @@
import React, { PropTypes } from 'react'; import React, { PropTypes } from 'react';
import Feature from './feature-component'; import Feature from './feature-component';
import { Link } from 'react-router'; import { Link } from 'react-router';
import { List, ListItem, ListSubHeader, ListDivider } from 'react-toolbox/lib/list'; import { Icon, Chip, ChipContact, IconButton, Button, FABButton, Textfield, Menu, MenuItem } from 'react-mdl';
import styles from './feature.scss';
export default class FeatureListComponent extends React.PureComponent { export default class FeatureListComponent extends React.PureComponent {
@ -32,25 +34,91 @@ export default class FeatureListComponent extends React.PureComponent {
clearInterval(this.timer); clearInterval(this.timer);
} }
toggleMetrics () {
this.props.updateSetting('showLastHour', !this.props.settings.showLastHour);
}
setFilter (v) {
this.props.updateSetting('filter', typeof v === 'string' ? v.trim() : '');
}
setSort (v) {
this.props.updateSetting('sort', typeof v === 'string' ? v.trim() : '');
}
render () { render () {
const { features, onFeatureClick, onFeatureRemove, featureMetrics } = this.props; const { features, onFeatureClick, onFeatureRemove, featureMetrics, settings } = this.props;
return ( return (
<List> <div>
<ListSubHeader caption="Feature toggles" /> <div className={styles.topList}>
<Chip onClick={() => this.toggleMetrics()} className={styles.topListItem0}>
{ settings.showLastHour &&
<ChipContact className="mdl-color--teal mdl-color-text--white">
<Icon name="hourglass_full" style={{ fontSize: '16px' }} />
</ChipContact> }
{ '1 hour' }
</Chip>
&nbsp;
<Chip onClick={() => this.toggleMetrics()} className={styles.topListItem0}>
{ !settings.showLastHour &&
<ChipContact className="mdl-color--teal mdl-color-text--white">
<Icon name="hourglass_empty" style={{ fontSize: '16px' }} />
</ChipContact> }
{ '1 minute' }
</Chip>
<div className={styles.topListItem2} style={{ margin: '-10px 10px 0 10px' }}>
<Textfield
floatingLabel
value={settings.filter}
onChange={(e) => { this.setFilter(e.target.value); }}
label="Filter toggles"
style={{ width: '100%' }}
/>
</div>
<div style={{ position: 'relative' }} className={styles.topListItem0}>
<IconButton name="sort" id="demo-menu-top-right" colored title="Sort" />
<Menu target="demo-menu-top-right" valign="bottom" align="right" ripple onClick={
(e) => this.setSort(e.target.getAttribute('data-target'))}>
<MenuItem disabled>Filter by:</MenuItem>
<MenuItem disabled={!settings.sort || settings.sort === 'nosort'} data-target="nosort">Default</MenuItem>
<MenuItem disabled={settings.sort === 'name'} data-target="name">Name</MenuItem>
<MenuItem disabled={settings.sort === 'enabled'} data-target="enabled">Enabled</MenuItem>
<MenuItem disabled={settings.sort === 'appName'} data-target="appName">Application name</MenuItem>
<MenuItem disabled={settings.sort === 'created'} data-target="created">Created</MenuItem>
<MenuItem disabled={settings.sort === 'strategies'} data-target="strategies">Strategies</MenuItem>
<MenuItem disabled={settings.sort === 'metrics'} data-target="metrics">Metrics</MenuItem>
</Menu>
</div>
<Link to="/features/create" className={styles.topListItem0}>
<FABButton ripple component="span" mini>
<Icon name="add" />
</FABButton>
</Link>
</div>
<ul className="demo-list-item mdl-list">
{features.map((feature, i) => {features.map((feature, i) =>
<Feature key={i} <Feature key={i}
settings={settings}
metricsLastHour={featureMetrics.lastHour[feature.name]} metricsLastHour={featureMetrics.lastHour[feature.name]}
metricsLastMinute={featureMetrics.lastMinute[feature.name]} metricsLastMinute={featureMetrics.lastMinute[feature.name]}
feature={feature} feature={feature}
onFeatureClick={onFeatureClick} onFeatureClick={onFeatureClick}
onFeatureRemove={onFeatureRemove}/> onFeatureRemove={onFeatureRemove}/>
)} )}
<ListDivider /> </ul>
<hr />
<Link to="/features/create"> <Link to="/features/create">
<ListItem caption="Create" legend="new feature toggle" leftIcon="add" /> <Icon name="add" />
<strong>Create </strong><small>new feature toggle</small>
</Link> </Link>
</List> </div>
); );
} }
} }

View File

@ -1,19 +1,79 @@
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import { toggleFeature, fetchFeatureToggles, removeFeatureToggle } from '../../store/feature-actions'; import { toggleFeature, fetchFeatureToggles, removeFeatureToggle } from '../../store/feature-actions';
import { fetchFeatureMetrics } from '../../store/feature-metrics-actions'; import { fetchFeatureMetrics } from '../../store/feature-metrics-actions';
import { updateSettingForGroup } from '../../store/settings/actions';
import FeatureListComponent from './list-component'; import FeatureListComponent from './list-component';
const mapStateToProps = (state) => ({ const mapStateToProps = (state) => {
features: state.features.toJS(), const featureMetrics = state.featureMetrics.toJS();
featureMetrics: state.featureMetrics.toJS(), const settings = state.settings.toJS().feature || {};
let features = state.features.toJS();
if (settings.filter) {
features = features.filter(feature =>
(
feature.name.indexOf(settings.filter) > -1 ||
feature.description.indexOf(settings.filter) > -1 ||
feature.strategies.some(s => s && s.name && s.name.indexOf(settings.filter) > -1)
)
);
}
if (settings.sort) {
if (settings.sort === 'enabled') {
features = features.sort((a, b) => (
// eslint-disable-next-line
a.enabled === b.enabled ? 0 : a.enabled ? -1 : 1
));
} else if (settings.sort === 'appName') {
// AppName
// features = features.sort((a, b) => {
// if (a.appName < b.appName) { return -1; }
// if (a.appName > b.appName) { return 1; }
// return 0;
// });
} else if (settings.sort === 'created') {
features = features.sort((a, b) => (
new Date(a.createdAt) > new Date(b.createdAt) ? -1 : 1
));
} else if (settings.sort === 'name') {
features = features.sort((a, b) => {
if (a.name < b.name) { return -1; }
if (a.name > b.name) { return 1; }
return 0;
}); });
} else if (settings.sort === 'strategies') {
features = features.sort((a, b) => (
a.strategies.length > b.strategies.length ? -1 : 1
));
} else if (settings.sort === 'metrics') {
const target = settings.showLastHour ? featureMetrics.lastHour : featureMetrics.lastMinute;
features = features.sort((a, b) => {
if (!target[a.name]) { return 1; }
if (!target[b.name]) { return -1; }
if (target[a.name].yes > target[b.name].yes) {
return -1;
}
return 1;
});
}
}
return {
features,
featureMetrics,
settings,
};
};
const mapDispatchToProps = { const mapDispatchToProps = {
onFeatureClick: toggleFeature, onFeatureClick: toggleFeature,
onFeatureRemove: removeFeatureToggle, onFeatureRemove: removeFeatureToggle,
fetchFeatureToggles, fetchFeatureToggles,
fetchFeatureMetrics, fetchFeatureMetrics,
updateSetting: updateSettingForGroup('feature'),
}; };
const FeatureListContainer = connect( const FeatureListContainer = connect(

View File

@ -0,0 +1,86 @@
import React, { PropTypes, Component } from 'react';
import styles from './progress.scss';
class Progress extends Component {
constructor (props) {
super(props);
this.state = {
percentage: props.initialAnimation ? 0 : props.percentage,
};
}
componentDidMount () {
if (this.props.initialAnimation) {
this.initialTimeout = setTimeout(() => {
this.requestAnimationFrame = window.requestAnimationFrame(() => {
this.setState({
percentage: this.props.percentage,
});
});
}, 0);
}
}
componentWillReceiveProps ({ percentage }) {
this.setState({ percentage });
}
componentWillUnmount () {
clearTimeout(this.initialTimeout);
window.cancelAnimationFrame(this.requestAnimationFrame);
}
render () {
const { strokeWidth, percentage } = this.props;
const radius = (50 - strokeWidth / 2);
const pathDescription = `
M 50,50 m 0,-${radius}
a ${radius},${radius} 0 1 1 0,${2 * radius}
a ${radius},${radius} 0 1 1 0,-${2 * radius}
`;
const diameter = Math.PI * 2 * radius;
const progressStyle = {
strokeDasharray: `${diameter}px ${diameter}px`,
strokeDashoffset: `${((100 - this.state.percentage) / 100 * diameter)}px`,
};
return (<svg viewBox="0 0 100 100">
<path
className={styles.trail}
d={pathDescription}
strokeWidth={strokeWidth}
fillOpacity={0}
/>
<path
className={styles.path}
d={pathDescription}
strokeWidth={strokeWidth}
fillOpacity={0}
style={progressStyle}
/>
<text
className={styles.text}
x={50}
y={50}
>{percentage}%</text>
</svg>);
}
}
Progress.propTypes = {
percentage: PropTypes.number.isRequired,
strokeWidth: PropTypes.number,
initialAnimation: PropTypes.bool,
textForPercentage: PropTypes.func,
};
Progress.defaultProps = {
strokeWidth: 8,
initialAnimation: false,
};
export default Progress;

View File

@ -0,0 +1,17 @@
.path {
stroke: #3f51b5;
stroke-linecap: round;
transition: stroke-dashoffset 5s ease 0s;
}
.trail {
stroke: #d6d6d6;
}
.text {
fill: rgba(0, 0, 0, 0.7);
font-size: 25px;
line-height: 25px;
dominant-baseline: middle;
text-anchor: middle;
}

View File

@ -0,0 +1,22 @@
import React, { PropTypes } from 'react';
import FormComponent from './form';
const Render = (props) => {
return (
<div>
<h1>{props.featureToggle.name}</h1>
<p>add metrics</p>
<p>add apps</p>
<p>add instances</p>
<hr />
<h5>Edit</h5>
<FormComponent {...props} />
</div>
);
};
export default Render;

View File

@ -1,6 +1,5 @@
import React, { PropTypes, PureComponent } from 'react'; import React, { PropTypes, PureComponent } from 'react';
import { Icon } from 'react-mdl';
import FontIcon from 'react-toolbox/lib/font_icon';
import style from './history.scss'; import style from './history.scss';
@ -110,7 +109,7 @@ class HistoryItem extends PureComponent {
<dd>{id}</dd> <dd>{id}</dd>
<dt>Type:</dt> <dt>Type:</dt>
<dd> <dd>
<FontIcon value={icon} title={type} style={{ fontSize: '1.6rem' }} /> <Icon name={icon} title={type} style={{ fontSize: '1.6rem' }} />
<span> {type}</span> <span> {type}</span>
</dd> </dd>
<dt>Timestamp:</dt> <dt>Timestamp:</dt>

View File

@ -1,7 +1,7 @@
import React, { Component } from 'react'; import React, { Component } from 'react';
import HistoryItemDiff from './history-item-diff'; import HistoryItemDiff from './history-item-diff';
import HistoryItemJson from './history-item-json'; import HistoryItemJson from './history-item-json';
import Switch from 'react-toolbox/lib/switch'; import { Switch } from 'react-mdl';
import style from './history.scss'; import style from './history.scss';
@ -28,11 +28,7 @@ class HistoryList extends Component {
return ( return (
<div className={style.history}> <div className={style.history}>
<Switch <Switch checked={showData} onChange={this.toggleShowDiff.bind(this)}>Show full events</Switch>
checked={showData}
label="Show full events"
onChange={this.toggleShowDiff.bind(this)}
/>
{entries} {entries}
</div> </div>
); );

View File

@ -1,6 +1,5 @@
import React, { Component } from 'react'; import React, { Component } from 'react';
import { List, ListItem, ListSubHeader, ListDivider } from 'react-toolbox/lib/list'; import { DataTable, TableHeader } from 'react-mdl';
import Chip from 'react-toolbox/lib/chip';
class Metrics extends Component { class Metrics extends Component {
@ -12,17 +11,22 @@ class Metrics extends Component {
const { globalCount, clientList } = this.props; const { globalCount, clientList } = this.props;
return ( return (
<List> <div>
<ListSubHeader caption={`Total of ${globalCount} toggles`} /> <h4>{`Total of ${globalCount} toggles`}</h4>
<ListDivider /> <DataTable
{clientList.map(({ name, count, ping, appName }, i) => style={{ width: '100%' }}
<ListItem rows={clientList}
leftActions={[<Chip>{count}</Chip>]} selectable={false}
key={name + i} >
caption={appName} <TableHeader name="name">Instance</TableHeader>
legend={`${name} pinged ${ping}`} /> <TableHeader name="appName">Application name</TableHeader>
)} <TableHeader numeric name="ping" cellFormatter={
</List> (v) => (v.toString())
}>Last seen</TableHeader>
<TableHeader numeric name="count">Counted</TableHeader>
</DataTable>
</div>
); );
} }
} }

View File

@ -1,44 +0,0 @@
import React, { Component } from 'react';
import { ListSubHeader, List, ListItem, ListDivider } from 'react-toolbox';
import style from './styles.scss';
export default class UnleashNav extends Component {
static contextTypes = {
router: React.PropTypes.object,
}
render () {
const createListItem = (path, caption) =>
<ListItem to={this.context.router.createHref(path)} caption={caption}
className={this.context.router.isActive(path) ? style.active : ''} />;
return (
<List selectable ripple className={style.navigation}>
{createListItem('/features', 'Feature toggles')}
{createListItem('/strategies', 'Strategies')}
{createListItem('/history', 'Event history')}
{createListItem('/archive', 'Archived toggles')}
<ListDivider />
<ListSubHeader caption="Clients" />
{createListItem('/applications', 'Client applications')}
{createListItem('/client-strategies', 'Client strategies')}
<ListDivider />
<ListSubHeader caption="Resources" />
{createListItem('/docs', 'Documentation')}
<a href="https://github.com/Unleash/unleash/" target="_blank">
<ListItem caption="GitHub" />
</a>
<ListDivider />
<ListItem selectable={false} ripple={false}>
<p>A product by <a href="https://finn.no" target="_blank">FINN.no</a></p>
</ListItem>
</List>
);
}
};

View File

@ -1,7 +1,6 @@
import React, { PropTypes } from 'react'; import React, { PropTypes } from 'react';
import Input from 'react-toolbox/lib/input'; import { Textfield, Button, IconButton } from 'react-mdl';
import Button from 'react-toolbox/lib/button';
const trim = (value) => { const trim = (value) => {
if (value && value.trim) { if (value && value.trim) {
@ -19,11 +18,10 @@ export const PARAM_PREFIX = 'param_';
const genParams = (input, num = 0, setValue) => (<div>{gerArrayWithEntries(num).map((v, i) => { const genParams = (input, num = 0, setValue) => (<div>{gerArrayWithEntries(num).map((v, i) => {
const key = `${PARAM_PREFIX}${i + 1}`; const key = `${PARAM_PREFIX}${i + 1}`;
return ( return (
<Input <Textfield
type="text"
label={`Parameter name ${i + 1}`} label={`Parameter name ${i + 1}`}
name={key} key={key} name={key} key={key}
onChange={(value) => setValue(key, value)} onChange={({ target }) => setValue(key, target.value)}
value={input[key]} /> value={input[key]} />
); );
})}</div>); })}</div>);
@ -38,22 +36,25 @@ const AddStrategy = ({
}) => ( }) => (
<form onSubmit={onSubmit(input)}> <form onSubmit={onSubmit(input)}>
<section> <section>
<Input type="text" label="Strategy name" <Textfield label="Strategy name"
name="name" required name="name" required
pattern="^[0-9a-zA-Z\.\-]+$" pattern="^[0-9a-zA-Z\.\-]+$"
onChange={(value) => setValue('name', trim(value))} onChange={({ target }) => setValue('name', trim(target.value))}
value={input.name} value={input.name}
/> />
<Input type="text" multiline label="Description" <br />
<Textfield
rows={2}
label="Description"
name="description" name="description"
onChange={(value) => setValue('description', value)} onChange={({ target }) => setValue('description', target.value)}
value={input.description} value={input.description}
/> />
</section> </section>
<section> <section>
{genParams(input, input._params, setValue)} {genParams(input, input._params, setValue)}
<Button icon="add" accent label="Add parameter" onClick={(e) => { <IconButton name="add" title="Add parameter" onClick={(e) => {
e.preventDefault(); e.preventDefault();
incValue('_params'); incValue('_params');
}}/> }}/>
@ -63,9 +64,9 @@ const AddStrategy = ({
<hr /> <hr />
<section> <section>
<Button type="submit" raised primary label="Create" /> <Button type="submit" raised primary >Create</Button>
&nbsp; &nbsp;
<Button type="cancel" raised label="Cancel" onClick={onCancel} /> <Button type="cancel" raised onClick={onCancel}>Cancel</Button>
</section> </section>
</form> </form>
); );

View File

@ -1,7 +1,6 @@
import React, { Component } from 'react'; import React, { Component } from 'react';
import { List, ListItem, ListSubHeader, ListDivider } from 'react-toolbox/lib/list';
import FontIcon from 'react-toolbox/lib/font_icon'; import { List, ListItem, ListItemContent, Icon, IconButton, Chip } from 'react-mdl';
import Chip from 'react-toolbox/lib/chip';
import style from './strategies.scss'; import style from './strategies.scss';
@ -25,27 +24,24 @@ class StrategiesListComponent extends Component {
const { strategies, removeStrategy } = this.props; const { strategies, removeStrategy } = this.props;
return ( return (
<List ripple > <div>
<ListSubHeader caption="Strategies" /> <h5>Strategies</h5>
<IconButton name="add" onClick={() => this.context.router.push('/strategies/create')} title="Add new strategy"/>
<hr />
<List>
{strategies.length > 0 ? strategies.map((strategy, i) => { {strategies.length > 0 ? strategies.map((strategy, i) => {
const actions = this.getParameterMap(strategy).concat([
<button className={style['non-style-button']} key="1" onClick={() => removeStrategy(strategy)}>
<FontIcon value="delete" />
</button>,
]);
return ( return (
<ListItem key={i} rightActions={actions} <ListItem key={i}>
caption={strategy.name} <ListItemContent><strong>{strategy.name}</strong> {strategy.description}</ListItemContent>
legend={strategy.description} /> <IconButton name="delete" onClick={() => removeStrategy(strategy)} />
</ListItem>
); );
}) : <ListItem caption="No entries" />} }) : <ListItem>No entries</ListItem>}
<ListDivider />
<ListItem
onClick={() => this.context.router.push('/strategies/create')}
caption="Add" legend="new strategy" leftIcon="add" />
</List> </List>
</div>
); );
} }
} }

View File

@ -18,7 +18,7 @@ export default class ShowUserComponent extends React.Component {
<div style={{ textAlign: 'right' }}> <div style={{ textAlign: 'right' }}>
<p> <p>
You are logged in as: You are logged in as:
<strong> <a href="#edit-user" onClick={this.openEdit}>{this.props.user.userName}</a></strong> <strong> <a href="#edit-user" onClick={this.openEdit}>{this.props.user.userName || 'unknown'}</a></strong>
</p> </p>
</div> </div>
); );

View File

@ -1,6 +1,5 @@
import React, { PropTypes } from 'react'; import React, { PropTypes } from 'react';
import Dialog from 'react-toolbox/lib/dialog'; import { Textfield, Dialog, DialogTitle, DialogContent, DialogActions, Button } from 'react-mdl';
import Input from 'react-toolbox/lib/input';
class EditUserComponent extends React.Component { class EditUserComponent extends React.Component {
static propTypes () { static propTypes () {
@ -16,31 +15,29 @@ class EditUserComponent extends React.Component {
} }
render () { render () {
const actions = [
{ label: 'Save', onClick: this.props.save },
];
return ( return (
<Dialog <div>
active={this.props.user.showDialog} <Dialog open={this.props.user.showDialog}>
title="Action required" <DialogTitle>Action required</DialogTitle>
actions={actions} <DialogContent>
>
<p> <p>
You hav to specify a username to use Unleash. This will allow us to track changes. You are logged in as:You hav to specify a username to use Unleash. This will allow us to track changes.
</p> </p>
<form onSubmit={this.handleSubmit}> <form onSubmit={this.handleSubmit}>
<Input <Textfield
type="text"
label="USERNAME" label="USERNAME"
name="username" name="username"
required required
value={this.props.user.userName} value={this.props.user.userName}
onChange={(v) => this.props.updateUserName(v)} onChange={(e) => this.props.updateUserName(e.target.value)}
/> />
</form> </form>
</DialogContent>
<DialogActions>
<Button onClick={this.props.save}>Save</Button>
</DialogActions>
</Dialog> </Dialog>
</div>
); );
} }
} }

View File

@ -10,10 +10,7 @@ export default class Features extends Component {
render () { render () {
return ( return (
<div>
<h6>Edit feature toggle</h6>
<EditFeatureToggleForm featureToggleName={this.props.params.name} /> <EditFeatureToggleForm featureToggleName={this.props.params.name} />
</div>
); );
} }
}; };

View File

@ -1,31 +0,0 @@
@import "~react-toolbox/lib/colors";
@import "~react-toolbox/lib/globals";
@import "~react-toolbox/lib/mixins";
@import "~react-toolbox/lib/commons";
$color-primary:$palette-blue-400;
$color-primary-dark: $palette-blue-700;
$navigation-drawer-desktop-width: 4 * $standard-increment-desktop !default;
$navigation-drawer-max-desktop-width: 70 * $unit !default;
// Mobile:
// Width = Screen width 56 dp
// Maximum width: 320dp
$navigation-drawer-mobile-width: 5 * $standard-increment-mobile !default;
// sass doesn't like use of variable here: calc(100% - $standard-increment-mobile);
$navigation-drawer-max-mobile-width: calc(100% - 5.6rem) !default;
.appBar {
.leftIcon {
transition-timing-function: $animation-curve-default;
transition-duration: $animation-duration;
transition-property: width, min-width;
}
@media screen and (min-width: $layout-breakpoint-sm) {
.leftIcon {
display: none;
}
}
}

View File

@ -54,7 +54,7 @@ module.exports = {
plugins, plugins,
sassLoader: { sassLoader: {
data: '@import "theme/_config.scss";', // data: '@import "theme/_config.scss";',
includePaths: [path.resolve(__dirname, './src')], includePaths: [path.resolve(__dirname, './src')],
}, },