1
0
mirror of https://github.com/Unleash/unleash.git synced 2024-12-22 19:07:54 +01:00
This commit is contained in:
ivaosthu 2016-11-10 14:26:24 +01:00
parent 54ec9de3cf
commit d21bf84a5b
87 changed files with 2688 additions and 0 deletions

8
frontend/.babelrc Normal file
View File

@ -0,0 +1,8 @@
{
"presets": ["react", "es2015", "stage-2"],
"env": {
"development": {
"presets": ["react-hmre"]
}
}
}

2
frontend/.eslintignore Normal file
View File

@ -0,0 +1,2 @@
node_modules
bundle.js

9
frontend/.eslintrc Normal file
View File

@ -0,0 +1,9 @@
{
"extends": [
"finn",
"finn/node"
],
"rules": {
"no-shadow": 0
}
}

3
frontend/.gitignore vendored
View File

@ -35,3 +35,6 @@ jspm_packages
# Optional REPL history
.node_repl_history
# bundled assets
dist

14
frontend/README.md Normal file
View File

@ -0,0 +1,14 @@
## Start developing:
1. start mock-api:
```bash
npm run start:api
```
2. start webpack-dev-server with hot-reload:
```bash
npm run start
```
Happy coding!

13
frontend/index.html Normal file
View File

@ -0,0 +1,13 @@
<!doctype html>
<html>
<head>
<title>Unleash Admin</title>
<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/css?family=Roboto:300,400,500,700" rel="stylesheet">
</head>
<body>
<div id='app'></div>
<script src="/static/bundle.js"></script>
</body>
</html>

7
frontend/index.js Normal file
View File

@ -0,0 +1,7 @@
'use strict';
const path = require('path');
module.exports = {
publicFolder: path.join(__dirname, 'dist'),
};

View File

@ -0,0 +1,9 @@
// preprocessor.js
'use strict';
const ReactTools = require('react-tools');
module.exports = {
process (src) {
return ReactTools.transform(src);
},
};

74
frontend/mock-api.json Normal file
View File

@ -0,0 +1,74 @@
{
"features": {
"version": 1,
"features": [
{
"name": "Feature.A",
"description": "lorem ipsum",
"enabled": false,
"strategies": [
{
"name": "default",
"parameters": {}
}
]
},
{
"name": "Feature.B",
"description": "lorem ipsum",
"enabled": true,
"strategies": [
{
"name": "ActiveForUserWithId",
"parameters": {
"userIdList": "123,221,998"
}
},
{
"name": "GradualRolloutRandom",
"parameters": {
"percentage": "10"
}
}
]
},
{
"name": "Feature.C",
"description": "lorem ipsum",
"enabled": false,
"strategies": [
{
"name": "default",
"parameters": {}
}
]
},
{
"name": "Feature.D",
"description": "lorem ipsum",
"enabled": true,
"strategies": [
{
"name": "default",
"parameters": {}
}
]
},
{
"name": "Feature.E",
"description": "lorem ipsum",
"enabled": true,
"strategies": [
{
"name": "default",
"parameters": {}
},
{
"name": "FancyStrat",
"parameters": {}
}
]
}
]
}
}

89
frontend/package.json Normal file
View File

@ -0,0 +1,89 @@
{
"name": "unleash-frontend-next",
"description": "unleash your features",
"version": "1.0.0",
"keywords": [
"unleash",
"feature toggle",
"feature",
"toggle"
],
"files": [
"public"
],
"repository": {
"type": "git",
"url": "ssh://git@github.com:finn-no/unleash.git"
},
"bugs": {
"url": "https://github.com/finn-no/unleash/issues"
},
"engines": {
"node": "6"
},
"scripts": {
"build": "webpack -p",
"start": "webpack-dev-server --config webpack.config.js --hot --progress --colors --port 3000",
"start:api": "json-server --watch mock-api.json -p 3001",
"lint": "eslint . --ext=js,jsx",
"test": "echo 'no test'",
"test:ci": "npm run test",
"prepublish": "npm run build"
},
"main": "./index.js",
"dependencies": {
"debug": "^2.2.0",
"immutability-helper": "^2.0.0",
"immutable": "^3.8.1",
"normalize.css": "^4.2.0",
"react": "^15.3.1",
"react-addons-css-transition-group": "^15.3.1",
"react-dom": "^15.3.1",
"react-redux": "^4.4.5",
"react-router": "^2.8.0",
"react-toolbox": "^1.2.1",
"redux": "^3.6.0",
"redux-thunk": "^2.1.0"
},
"devDependencies": {
"babel-core": "^6.14.0",
"babel-loader": "^6.2.5",
"babel-preset-es2015": "^6.14.0",
"babel-preset-react": "^6.11.1",
"babel-preset-react-hmre": "^1.1.1",
"babel-preset-stage-0": "^6.5.0",
"babel-preset-stage-2": "^6.13.0",
"css-loader": "^0.25.0",
"eslint": "^3.4.0",
"eslint-config-finn": "1.0.0-alpha.11",
"eslint-config-finn-react": "^1.0.0-alpha.2",
"eslint-plugin-react": "^6.2.0",
"extract-text-webpack-plugin": "^1.0.1",
"json-server": "^0.8.21",
"node-sass": "~3.7.0",
"postcss-loader": "^0.13.0",
"redux-devtools": "^3.3.1",
"sass-loader": "^4.0.2",
"style-loader": "^0.13.1",
"toolbox-loader": "0.0.3",
"webpack": "^1.13.2",
"webpack-dev-server": "^1.15.1"
},
"jest": {
"scriptPreprocessor": "<rootDir>/jest-preprocessor.js",
"modulePathIgnorePatterns": [
"<rootDir>/node_modules/npm"
],
"unmockedModulePathPatterns": [
"<rootDir>/node_modules/react",
"<rootDir>/node_modules/reflux"
],
"moduleFileExtensions": [
"jsx",
"js"
]
},
"pre-commit": [
"lint"
]
}

25
frontend/src/.eslintrc Normal file
View File

@ -0,0 +1,25 @@
{
"parser": "babel-eslint",
"extends": [
"finn",
"finn-react",
"finn/es-modules"
],
"env": {
"browser": true,
"commonjs": true,
"es6": true
},
"parserOptions": {
"ecmaVersion": 7,
"ecmaFeatures": {
"experimentalObjectRestSpread": true,
"classes":true,
"spread":true,
"restParams": true
}
},
"rules": {
"no-shadow": 0
}
}

View File

@ -0,0 +1,40 @@
import React, { Component } from 'react';
import { Layout, Panel, NavDrawer, AppBar } from 'react-toolbox';
import style from './styles.scss';
import ErrorContainer from './error/error-container';
import Navigation from './nav';
export default class App extends Component {
constructor (props) {
super(props);
this.state = { drawerActive: false };
this.toggleDrawerActive = () => {
this.setState({ drawerActive: !this.state.drawerActive });
};
}
onOverlayClick = () => this.setState({ drawerActive: false });
render () {
return (
<div className={style.container}>
<AppBar title="Unleash Admin" leftIcon="menu" onLeftIconClick={this.toggleDrawerActive} className={style.appBar} />
<div className={style.container} style={{ top: '6.4rem' }}>
<Layout>
<NavDrawer active={this.state.drawerActive} permanentAt="sm" onOverlayClick={this.onOverlayClick} >
<Navigation />
</NavDrawer>
<Panel scrollY>
<div style={{ padding: '1.8rem' }}>
{this.props.children}
</div>
</Panel>
<ErrorContainer />
</Layout>
</div>
</div>
);
}
};

View File

@ -0,0 +1,15 @@
import { connect } from 'react-redux';
import ListComponent from './archive-list-component';
import { fetchArchive, revive } from '../../store/archive-actions';
const mapStateToProps = (state) => {
const archive = state.archive.get('list').toArray();
return {
archive,
};
};
const ArchiveListContainer = connect(mapStateToProps, { fetchArchive, revive })(ListComponent);
export default ArchiveListContainer;

View File

@ -0,0 +1,48 @@
import React, { Component } from 'react';
import { List, ListItem, ListSubHeader } from 'react-toolbox/lib/list';
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="restore" 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 {
componentDidMount () {
this.props.fetchArchive();
}
render () {
const { archive, revive } = this.props;
return (
<List ripple >
<ListSubHeader caption="Archive" />
{archive.length > 0 ?
archive.map((feature, i) => <ArchivedFeature key={i} feature={feature} revive={revive} />) :
<ListItem caption="No archived feature toggles" />}
</List>
);
}
}
export default ArchiveList;

View File

@ -0,0 +1,38 @@
import React, { Component, PropTypes } from 'react';
import Table from 'react-toolbox/lib/table';
const Model = {
appName: { type: String, title: 'Application Name' },
instanceId: { type: String },
clientIp: { type: String },
createdAt: { type: String },
lastSeen: { type: String },
};
class ClientStrategies extends Component {
static propTypes () {
return {
fetchClientInstances: PropTypes.func.isRequired,
clientInstances: PropTypes.array.isRequired,
};
}
componentDidMount () {
this.props.fetchClientInstances();
}
render () {
const source = this.props.clientInstances;
return (
<Table
model={Model}
source={source}
selectable={false}
/>
);
}
}
export default ClientStrategies;

View File

@ -0,0 +1,9 @@
import { connect } from 'react-redux';
import ClientInstances from './client-instance-component';
import { fetchClientInstances } from '../../store/client-instance-actions';
const mapStateToProps = (state) => ({ clientInstances: state.clientInstances.toJS() });
const StrategiesContainer = connect(mapStateToProps, { fetchClientInstances })(ClientInstances);
export default StrategiesContainer;

View File

@ -0,0 +1,34 @@
import React, { Component } from 'react';
import Table from 'react-toolbox/lib/table';
const Model = {
appName: { type: String, title: 'Application Name' },
strategies: { type: String },
};
class ClientStrategies extends Component {
componentDidMount () {
this.props.fetchClientStrategies();
}
render () {
const source = this.props.clientStrategies.map(item => (
{
appName: item.appName,
strategies: item.strategies.join(', '),
})
);
return (
<Table
model={Model}
source={source}
selectable={false}
/>
);
}
}
export default ClientStrategies;

View File

@ -0,0 +1,9 @@
import { connect } from 'react-redux';
import ClientStrategies from './strategy-component';
import { fetchClientStrategies } from '../../store/client-strategy-actions';
const mapStateToProps = (state) => ({ clientStrategies: state.clientStrategies.toJS() });
const StrategiesContainer = connect(mapStateToProps, { fetchClientStrategies })(ClientStrategies);
export default StrategiesContainer;

View File

@ -0,0 +1,28 @@
import Snackbar from 'react-toolbox/lib/snackbar';
import React, { PropTypes } from 'react';
class ErrorComponent extends React.Component {
static propTypes () {
return {
errors: PropTypes.array.isRequired,
muteError: PropTypes.func.isRequired,
};
}
render () {
const showError = this.props.errors.length > 0;
const error = showError ? this.props.errors[0] : undefined;
return (
<Snackbar
action="Dismiss"
active={showError}
icon="question_answer"
label={error}
onClick={() => this.props.muteError(error)}
type="warning"
/>
);
}
}
export default ErrorComponent;

View File

@ -0,0 +1,14 @@
import { connect } from 'react-redux';
import ErrorComponent from './error-component';
import { muteError } from '../../store/error-actions';
const mapDispatchToProps = {
muteError,
};
const mapStateToProps = (state) => ({
errors: state.error.get('list').toArray(),
});
export default connect(mapStateToProps, mapDispatchToProps)(ErrorComponent);

View File

@ -0,0 +1,53 @@
import React, { PropTypes } from 'react';
import { Link } from 'react-router';
import FontIcon from 'react-toolbox/lib/font_icon';
import Switch from 'react-toolbox/lib/switch';
import { ListItem } from 'react-toolbox/lib/list';
import Chip from 'react-toolbox/lib/chip';
import style from './feature.scss';
const Feature = ({
feature,
onFeatureClick,
onFeatureRemove,
metricsLastHour = { yes: 0, no: 0, hasData: false },
metricsLastMinute = { yes: 0, no: 0, hasData: false },
}) => {
const { name, description, enabled, strategies, createdAt } = feature;
const created = new Date(createdAt);
const actions = [
<div key="strategies">{strategies && strategies.map((s, i) => <Chip key={i}><small>{s.name}</small></Chip>)}</div>,
<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>,
<FontIcon key="delete" className={style.action} value="delete" onClick={() => onFeatureRemove(name)} />,
];
const leftActions = [
<Chip><span className={style.yes}>{metricsLastHour.yes}</span> / <span className={style.no}>{metricsLastHour.no}</span></Chip>,
<Chip><span className={style.yes}>{metricsLastMinute.yes}</span> / <span className={style.no}>{metricsLastMinute.no}</span></Chip>,
<Switch key="left-actions" onChange={() => onFeatureClick(feature)} checked={enabled} />,
];
return (
<ListItem
key={name}
leftActions={leftActions}
rightActions={actions}
caption={name}
legend={(description && description.substring(0, 100)) || '-'}
/>
);
};
Feature.propTypes = {
feature: PropTypes.object,
onFeatureClick: PropTypes.func,
onFeatureRemove: PropTypes.func,
};
export default Feature;

View File

@ -0,0 +1,16 @@
.link {
color: #212121;
}
.action {
color: #aaa !important;
cursor: pointer;
}
.yes {
color: green;
}
.no {
color: red;
}

View File

@ -0,0 +1,47 @@
import { connect } from 'react-redux';
import { hashHistory } from 'react-router';
import { createFeatureToggles, validateName } from '../../store/feature-actions';
import { createMapper, createActions } from '../input-helpers';
import FormComponent from './form';
const ID = 'add-feature-toggle';
const mapStateToProps = createMapper({ id: ID });
const prepare = (methods, dispatch) => {
methods.onSubmit = (input) => (
(e) => {
e.preventDefault();
createFeatureToggles(input)(dispatch)
.then(() => methods.clear())
.then(() => hashHistory.push('/features'));
}
);
methods.onCancel = (evt) => {
evt.preventDefault();
hashHistory.push('/features');
};
methods.addStrategy = (v) => {
methods.pushToList('strategies', v);
};
methods.updateStrategy = (v, n) => {
methods.updateInList('strategies', v, n);
};
methods.removeStrategy = (v) => {
methods.removeFromList('strategies', v);
};
methods.validateName = (v) => {
const featureToggleName = v.target.value;
validateName(featureToggleName)
.then(() => methods.setValue('nameError', undefined))
.catch((err) => methods.setValue('nameError', err.message));
};
return methods;
};
const actions = createActions({ id: ID, prepare });
export default connect(mapStateToProps, actions)(FormComponent);

View File

@ -0,0 +1,70 @@
import { connect } from 'react-redux';
import { hashHistory } from 'react-router';
import { requestUpdateFeatureToggle } from '../../store/feature-actions';
import { createMapper, createActions } from '../input-helpers';
import FormComponent from './form';
const ID = 'edit-feature-toggle';
function getId (props) {
return [ID, props.featureToggleName];
}
// TODO: need to scope to the active featureToggle
// best is to emulate the "input-storage"?
const mapStateToProps = createMapper({
id: getId,
getDefault: (state, ownProps) => {
if (ownProps.featureToggleName) {
const match = state.features.findEntry((entry) => entry.get('name') === ownProps.featureToggleName);
if (match && match[1]) {
return match[1].toJS();
}
}
return {};
},
prepare: (props) => {
props.editmode = true;
return props;
},
});
const prepare = (methods, dispatch) => {
methods.onSubmit = (input) => (
(e) => {
e.preventDefault();
// TODO: should add error handling
requestUpdateFeatureToggle(input)(dispatch)
.then(() => methods.clear())
.then(() => window.history.back());
}
);
methods.onCancel = (evt) => {
evt.preventDefault();
hashHistory.push('/features');
};
methods.addStrategy = (v) => {
methods.pushToList('strategies', v);
};
methods.removeStrategy = (v) => {
methods.removeFromList('strategies', v);
};
methods.updateStrategy = (v, n) => {
methods.updateInList('strategies', v, n);
};
methods.validateName = () => {};
return methods;
};
const actions = createActions({
id: getId,
prepare,
});
export default connect(mapStateToProps, actions)(FormComponent);

View File

@ -0,0 +1,95 @@
import React, { Component, PropTypes } from 'react';
import Input from 'react-toolbox/lib/input';
import Button from 'react-toolbox/lib/button';
import Switch from 'react-toolbox/lib/switch';
import StrategiesSection from './strategies-section-container';
class AddFeatureToggleComponent extends Component {
componentWillMount () {
// TODO unwind this stuff
if (this.props.initCallRequired === true) {
this.props.init(this.props.input);
}
}
render () {
const {
input,
setValue,
validateName,
addStrategy,
removeStrategy,
updateStrategy,
onSubmit,
onCancel,
editmode = false,
} = this.props;
const {
name, // eslint-disable-line
nameError,
description,
enabled,
} = input;
const configuredStrategies = input.strategies || [];
return (
<form onSubmit={onSubmit(input)}>
<section>
<Input
type="text"
label="Name"
name="name"
disabled={editmode}
required
value={name}
error={nameError}
onBlur={(v) => validateName(v)}
onChange={(v) => setValue('name', v)} />
<Input
type="text"
multiline label="Description"
required
value={description}
onChange={(v) => setValue('description', v)} />
<br />
<Switch
checked={enabled}
label="Enabled"
onChange={(v) => setValue('enabled', v)} />
<br />
</section>
<StrategiesSection
configuredStrategies={configuredStrategies}
addStrategy={addStrategy}
updateStrategy={updateStrategy}
removeStrategy={removeStrategy} />
<br />
<Button type="submit" raised primary label={editmode ? 'Update' : 'Create'} />
&nbsp;
<Button type="cancel" raised label="Cancel" onClick={onCancel} />
</form>
);
}
};
AddFeatureToggleComponent.propTypes = {
input: PropTypes.object,
setValue: PropTypes.func.isRequired,
addStrategy: PropTypes.func.isRequired,
removeStrategy: PropTypes.func.isRequired,
updateStrategy: PropTypes.func.isRequired,
onSubmit: PropTypes.func.isRequired,
onCancel: PropTypes.func.isRequired,
validateName: PropTypes.func.isRequired,
editmode: PropTypes.bool,
};
export default AddFeatureToggleComponent;

View File

@ -0,0 +1,72 @@
import React, { PropTypes } from 'react';
import Dropdown from 'react-toolbox/lib/dropdown';
import FontIcon from 'react-toolbox/lib/font_icon';
class AddStrategy extends React.Component {
static propTypes () {
return {
strategies: PropTypes.array.isRequired,
addStrategy: PropTypes.func.isRequired,
fetchStrategies: PropTypes.func.isRequired,
};
}
addStrategy = (strategyName) => {
const selectedStrategy = this.props.strategies.find(s => s.name === strategyName);
const parameters = {};
const keys = Object.keys(selectedStrategy.parametersTemplate || {});
keys.forEach(prop => { parameters[prop] = ''; });
this.props.addStrategy({
name: selectedStrategy.name,
parameters,
});
};
customItem (item) {
const containerStyle = {
display: 'flex',
flexDirection: 'row',
};
const contentStyle = {
display: 'flex',
flexDirection: 'column',
flexGrow: 2,
marginLeft: '10px',
};
return (
<div style={containerStyle}>
<FontIcon value="add" />
<div style={contentStyle}>
<strong>{item.name}</strong>
<small>{item.description}</small>
</div>
</div>
);
}
render () {
const strats = this.props.strategies.map(s => {
s.value = s.name;
return s;
});
return (
<Dropdown
allowBlank={false}
source={strats}
onChange={this.addStrategy}
label="Add an activation strategy"
template={this.customItem}
/>
);
}
}
export default AddStrategy;

View File

@ -0,0 +1,43 @@
import React, { PropTypes } from 'react';
import ConfigureStrategy from './strategy-configure';
import { List } from 'react-toolbox/lib/list';
class StrategiesList extends React.Component {
static propTypes () {
return {
strategies: PropTypes.array.isRequired,
configuredStrategies: PropTypes.array.isRequired,
updateStrategy: PropTypes.func.isRequired,
removeStrategy: PropTypes.func.isRequired,
};
}
render () {
const {
strategies,
configuredStrategies,
} = this.props;
if (!configuredStrategies || configuredStrategies.length === 0) {
return <i>No strategies added</i>;
}
const blocks = configuredStrategies.map((strat, i) => (
<ConfigureStrategy
key={`${strat.name}-${i}`}
strategy={strat}
removeStrategy={this.props.removeStrategy}
updateStrategy={this.props.updateStrategy}
strategyDefinition={strategies.find(s => s.name === strat.name)} />
));
return (
<List>
{blocks}
</List>
);
}
}
export default StrategiesList;

View File

@ -0,0 +1,8 @@
import { connect } from 'react-redux';
import StrategiesSection from './strategies-section';
import { fetchStrategies } from '../../../store/strategy-actions';
export default connect((state) => ({
strategies: state.strategies.get('list').toArray(),
}), { fetchStrategies })(StrategiesSection);

View File

@ -0,0 +1,44 @@
import React, { PropTypes } from 'react';
import StrategiesList from './strategies-list';
import AddStrategy from './strategies-add';
const headerStyle = {
borderBottom: '1px solid rgba(0, 0, 0, 0.12)',
paddingBottom: '5px',
marginBottom: '10px',
};
class StrategiesSection extends React.Component {
static propTypes () {
return {
strategies: PropTypes.array.isRequired,
addStrategy: PropTypes.func.isRequired,
removeStrategy: PropTypes.func.isRequired,
updateStrategy: PropTypes.func.isRequired,
fetchStrategies: PropTypes.func.isRequired,
};
}
componentWillMount () {
this.props.fetchStrategies();
}
render () {
if (!this.props.strategies || this.props.strategies.length === 0) {
return <i>Loding available strategies</i>;
}
return (
<div>
<div>
<h5 style={headerStyle}>Strategies:</h5>
<StrategiesList {...this.props} />
<AddStrategy {...this.props} />
</div>
</div>
);
}
}
export default StrategiesSection;

View File

@ -0,0 +1,80 @@
import React, { PropTypes } from 'react';
import Input from 'react-toolbox/lib/input';
import Button from 'react-toolbox/lib/button';
import { ListItem } from 'react-toolbox/lib/list';
class StrategyConfigure extends React.Component {
static propTypes () {
return {
strategy: PropTypes.object.isRequired,
strategyDefinition: PropTypes.object.isRequired,
updateStrategy: PropTypes.func.isRequired,
removeStrategy: PropTypes.func.isRequired,
};
}
updateStrategy = (evt) => {
evt.preventDefault();
this.props.updateStrategy({
name: this.state.selectedStrategy.name,
parameters: this.state.parameters,
});
};
handleConfigChange = (key, value) => {
const parameters = {};
parameters[key] = value;
const updatedStrategy = Object.assign({}, this.props.strategy, { parameters });
this.props.updateStrategy(this.props.strategy, updatedStrategy);
};
handleRemove = (evt) => {
evt.preventDefault();
this.props.removeStrategy(this.props.strategy);
}
renderInputFields (strategyDefinition) {
if (strategyDefinition.parametersTemplate) {
return Object.keys(strategyDefinition.parametersTemplate).map(field => (
<Input
type="text"
key={field}
name={field}
label={field}
onChange={this.handleConfigChange.bind(this, field)}
value={this.props.strategy.parameters[field]}
/>
));
}
}
render () {
const leftActions = [
<Button key="remove" onClick={this.handleRemove} icon="remove" floating accent mini />,
];
if (!this.props.strategyDefinition) {
return (
<ListItem
leftActions={leftActions}
caption={<span style={{ color: 'red' }}>Strategy "{this.props.strategy.name}" deleted</span>}
/>
);
}
const inputFields = this.renderInputFields(this.props.strategyDefinition) || [];
return (
<ListItem leftActions={leftActions}
caption={this.props.strategy.name}
legend={this.props.strategyDefinition.description}
rightActions={inputFields}
/>
);
}
}
export default StrategyConfigure;

View File

@ -0,0 +1,55 @@
import React, { PropTypes } from 'react';
import Feature from './feature-component';
import { List, ListItem, ListSubHeader, ListDivider } from 'react-toolbox/lib/list';
export default class FeatureListComponent extends React.Component {
static propTypes () {
return {
onFeatureClick: PropTypes.func.isRequired,
onFeatureRemove: PropTypes.func.isRequired,
features: PropTypes.array.isRequired,
featureMetrics: PropTypes.object.isRequired,
fetchFeatureToggles: PropTypes.func.isRequired,
fetchFeatureMetrics: PropTypes.func.isRequired,
};
}
static contextTypes = {
router: React.PropTypes.object,
}
componentDidMount () {
this.props.fetchFeatureToggles();
this.props.fetchFeatureMetrics();
this.timer = setInterval(() => {
this.props.fetchFeatureMetrics();
}, 5000);
}
componentWillUnmount () {
clearInterval(this.timer);
}
render () {
const { features, onFeatureClick, onFeatureRemove, featureMetrics } = this.props;
return (
<List>
<ListSubHeader caption="Feature toggles" />
{features.map((feature, i) =>
<Feature key={i}
metricsLastHour={featureMetrics.lastHour[feature.name]}
metricsLastMinute={featureMetrics.lastMinute[feature.name]}
feature={feature}
onFeatureClick={onFeatureClick}
onFeatureRemove={onFeatureRemove}/>
)}
<ListDivider />
<ListItem
onClick={() => this.context.router.push('/features/create')}
caption="Add" legend="new feature toggle" leftIcon="add" />
</List>
);
}
}

View File

@ -0,0 +1,24 @@
import { connect } from 'react-redux';
import { toggleFeature, fetchFeatureToggles, removeFeatureToggle } from '../../store/feature-actions';
import { fetchFeatureMetrics } from '../../store/feature-metrics-actions';
import FeatureListComponent from './list-component';
const mapStateToProps = (state) => ({
features: state.features.toJS(),
featureMetrics: state.featureMetrics.toJS(),
});
const mapDispatchToProps = {
onFeatureClick: toggleFeature,
onFeatureRemove: removeFeatureToggle,
fetchFeatureToggles,
fetchFeatureMetrics,
};
const FeatureListContainer = connect(
mapStateToProps,
mapDispatchToProps
)(FeatureListComponent);
export default FeatureListContainer;

View File

@ -0,0 +1,15 @@
import { connect } from 'react-redux';
import ListComponent from './history-list-component';
import { fetchHistory } from '../../store/history-actions';
const mapStateToProps = (state) => {
const history = state.history.get('list').toArray();
return {
history,
};
};
const HistoryListContainer = connect(mapStateToProps, { fetchHistory })(ListComponent);
export default HistoryListContainer;

View File

@ -0,0 +1,58 @@
import React, { Component } from 'react';
import { List, ListItem, ListSubHeader } from 'react-toolbox/lib/list';
class HistoryList extends Component {
componentDidMount () {
this.props.fetchHistory();
}
getIcon (type) {
if (type.indexOf('created') > -1 ) {
return 'add';
}
if (type.indexOf('deleted') > -1 ) {
return 'remove';
}
if (type.indexOf('updated') > -1 ) {
return 'update';
}
if (type.indexOf('archived') > -1 ) {
return 'archived';
}
return 'bookmark';
}
render () {
const { history } = this.props;
return (
<List ripple >
<ListSubHeader caption="History" />
{history.length > 0 ? history.map((log, i) => {
const actions = [];
const icon = this.getIcon(log.type);
const caption = <div>{log.data.name} <small>{log.type}</small></div>;
return (
<ListItem key={i}
leftIcon={icon}
rightActions={actions}
caption={caption}
legend={log.createdAt} />
);
}) : <ListItem caption="No log entries" />}
</List>
);
}
}
export default HistoryList;

View File

@ -0,0 +1,68 @@
import {
createInc,
createClear,
createSet,
createPop,
createPush,
createUp,
createInit,
} from '../store/input-actions';
function getId (id, ownProps) {
if (typeof id === 'function') {
return id(ownProps); // should return array...
}
return [id];
}
export function createMapper ({ id, getDefault, prepare = (v) => v }) {
return (state, ownProps) => {
let input;
let initCallRequired = false;
const scope = getId(id, ownProps);
if (state.input.hasIn(scope)) {
input = state.input.getIn(scope).toJS();
} else {
initCallRequired = true;
input = getDefault ? getDefault(state, ownProps) : {};
}
return prepare({
initCallRequired,
input,
}, state, ownProps);
};
}
export function createActions ({ id, prepare = (v) => v }) {
return (dispatch, ownProps) => (prepare({
clear () {
dispatch(createClear({ id: getId(id, ownProps) }));
},
init (value) {
dispatch(createInit({ id: getId(id, ownProps), value }));
},
setValue (key, value) {
dispatch(createSet({ id: getId(id, ownProps), key, value }));
},
pushToList (key, value) {
dispatch(createPush({ id: getId(id, ownProps), key, value }));
},
removeFromList (key, value) {
dispatch(createPop({ id: getId(id, ownProps), key, value }));
},
updateInList (key, value, newValue) {
dispatch(createUp({ id: getId(id, ownProps), key, value, newValue }));
},
incValue (key) {
dispatch(createInc({ id: getId(id, ownProps), key }));
},
}, dispatch, ownProps));
}

View File

@ -0,0 +1,31 @@
import React, { Component } from 'react';
import { List, ListItem, ListSubHeader, ListDivider } from 'react-toolbox/lib/list';
import Chip from 'react-toolbox/lib/chip';
class Metrics extends Component {
componentDidMount () {
this.props.fetchMetrics();
}
render () {
const { globalCount, clientList } = this.props;
return (
<List>
<ListSubHeader caption={<span>Total of {globalCount} toggles </span>} />
<ListDivider />
{clientList.map(({ name, count, ping, appName }, i) =>
<ListItem
leftActions={[<Chip>{count}</Chip>]}
key={name + i}
caption={appName}
legend={`${name} pinged ${ping}`} />
)}
</List>
);
}
}
export default Metrics;

View File

@ -0,0 +1,39 @@
import { connect } from 'react-redux';
import Metrics from './metrics-component';
import { fetchMetrics } from '../../store/metrics-actions';
const mapStateToProps = (state) => {
const globalCount = state.metrics.get('globalCount');
const apps = state.metrics.get('apps').toArray();
const clients = state.metrics.get('clients').toJS();
const clientList = Object
.keys(clients)
.map((k) => {
const client = clients[k];
return {
name: k,
appName: client.appName,
count: client.count,
ping: new Date(client.ping),
};
})
.sort((a, b) => (a.ping > b.ping ? -1 : 1));
/*
Possible stuff to ask/answer:
* toggles in use but not in unleash-server
* nr of toggles using fallbackValue
* strategies implemented but not used
*/
return {
globalCount,
apps,
clientList,
};
};
const MetricsContainer = connect(mapStateToProps, { fetchMetrics })(Metrics);
export default MetricsContainer;

View File

@ -0,0 +1,45 @@
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('/metrics', 'Client metrics')}
{createListItem('/client-strategies', 'Client strategies')}
{createListItem('/client-instances', 'Client instances')}
<ListDivider />
<ListSubHeader caption="Resources" />
{createListItem('/docs', 'Documentation')}
<a href="https://github.com/finn-no/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

@ -0,0 +1,45 @@
import { connect } from 'react-redux';
import { createMapper, createActions } from '../input-helpers';
import { createStrategy } from '../../store/strategy-actions';
import AddStrategy, { PARAM_PREFIX } from './add-strategy';
const ID = 'add-strategy';
const prepare = (methods, dispatch) => {
methods.onSubmit = (input) => (
(e) => {
e.preventDefault();
const parametersTemplate = {};
Object.keys(input).forEach(key => {
if (key.startsWith(PARAM_PREFIX)) {
parametersTemplate[input[key]] = 'string';
}
});
input.parametersTemplate = parametersTemplate;
createStrategy(input)(dispatch)
.then(() => methods.clear())
// somewhat quickfix / hacky to go back..
.then(() => window.history.back());
}
);
methods.onCancel = (e) => {
e.preventDefault();
// somewhat quickfix / hacky to go back..
window.history.back();
};
return methods;
};
const actions = createActions({
id: ID,
prepare,
});
export default connect(createMapper({ id: ID }), actions)(AddStrategy);

View File

@ -0,0 +1,74 @@
import React, { PropTypes } from 'react';
import Input from 'react-toolbox/lib/input';
import Button from 'react-toolbox/lib/button';
function gerArrayWithEntries (num) {
return Array.from(Array(num));
}
export const PARAM_PREFIX = 'param_';
const genParams = (input, num = 0, setValue) => (<div>{gerArrayWithEntries(num).map((v, i) => {
const key = `${PARAM_PREFIX}${i + 1}`;
return (
<Input
type="text"
label={`Parameter name ${i + 1}`}
name={key} key={key}
onChange={(value) => setValue(key, value)}
value={input[key]} />
);
})}</div>);
const AddStrategy = ({
input,
setValue,
incValue,
// clear,
onCancel,
onSubmit,
}) => (
<form onSubmit={onSubmit(input)}>
<section>
<Input type="text" label="Strategy name"
name="name" required
pattern="^[0-9a-zA-Z\.\-]+$"
onChange={(value) => setValue('name', value)}
value={input.name}
/>
<Input type="text" multiline label="Description"
name="description"
onChange={(value) => setValue('description', value)}
value={input.description}
/>
</section>
<section>
{genParams(input, input._params, setValue)}
<Button icon="add" accent label="Add parameter" onClick={(e) => {
e.preventDefault();
incValue('_params');
}}/>
</section>
<br />
<hr />
<section>
<Button type="submit" raised primary label="Create" />
&nbsp;
<Button type="cancel" raised label="Cancel" onClick={onCancel} />
</section>
</form>
);
AddStrategy.propTypes = {
input: PropTypes.object,
setValue: PropTypes.func,
incValue: PropTypes.func,
clear: PropTypes.func,
onCancel: PropTypes.func,
onSubmit: PropTypes.func,
};
export default AddStrategy;

View File

@ -0,0 +1,54 @@
import React, { Component } from 'react';
import { List, ListItem, ListSubHeader, ListDivider } from 'react-toolbox/lib/list';
import FontIcon from 'react-toolbox/lib/font_icon';
import Chip from 'react-toolbox/lib/chip';
import style from './strategies.scss';
class StrategiesListComponent extends Component {
static contextTypes = {
router: React.PropTypes.object,
}
componentDidMount () {
this.props.fetchStrategies();
}
getParameterMap ({ parametersTemplate }) {
return Object.keys(parametersTemplate || {}).map(k => (
<Chip key={k}><small>{k}</small></Chip>
));
}
render () {
const { strategies, removeStrategy } = this.props;
return (
<List ripple >
<ListSubHeader caption="Strategies" />
{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 (
<ListItem key={i} rightActions={actions}
caption={strategy.name}
legend={strategy.description} />
);
}) : <ListItem caption="No entries" />}
<ListDivider />
<ListItem
onClick={() => this.context.router.push('/strategies/create')}
caption="Add" legend="new strategy" leftIcon="add" />
</List>
);
}
}
export default StrategiesListComponent;

View File

@ -0,0 +1,24 @@
import { connect } from 'react-redux';
import StrategiesListComponent from './list-component.jsx';
import { fetchStrategies, removeStrategy } from '../../store/strategy-actions';
const mapStateToProps = (state) => {
const list = state.strategies.get('list').toArray();
return {
strategies: list,
};
};
const mapDispatchToProps = (dispatch) => ({
removeStrategy: (strategy) => {
if (window.confirm('Are you sure you want to remove this strategy?')) { // eslint-disable-line no-alert
removeStrategy(strategy)(dispatch);
}
},
fetchStrategies: () => fetchStrategies()(dispatch),
});
const StrategiesListContainer = connect(mapStateToProps, mapDispatchToProps)(StrategiesListComponent);
export default StrategiesListContainer;

View File

@ -0,0 +1,8 @@
.non-style-button {
cursor: pointer;
color: #757575;
background: none;
border: 0;
padding: 0;
margin: 0;
}

View File

@ -0,0 +1,17 @@
.container {
position: absolute;
top: 0;
bottom: 0;
left: 0;
right: 0;
width: 100%;
height: auto;
overflow-y: auto;
}
.navigation {
.active {
background-color: #EEE;
}
}

View File

@ -0,0 +1,24 @@
import { throwIfNotSuccess, headers } from './helper';
const URI = '/archive';
function fetchAll () {
return fetch(`${URI}/features`)
.then(throwIfNotSuccess)
.then(response => response.json());
}
function revive (feature) {
return fetch(`${URI}/revive`, {
method: 'POST',
headers,
body: JSON.stringify(feature),
}).then(throwIfNotSuccess);
}
module.exports = {
fetchAll,
revive,
};

View File

@ -0,0 +1,13 @@
import { throwIfNotSuccess, headers } from './helper';
const URI = '/client/instances';
function fetchAll () {
return fetch(URI, { headers })
.then(throwIfNotSuccess)
.then(response => response.json());
}
module.exports = {
fetchAll,
};

View File

@ -0,0 +1,13 @@
import { throwIfNotSuccess, headers } from './helper';
const URI = '/client/strategies';
function fetchAll () {
return fetch(URI, { headers })
.then(throwIfNotSuccess)
.then(response => response.json());
}
module.exports = {
fetchAll,
};

View File

@ -0,0 +1,48 @@
import { throwIfNotSuccess, headers } from './helper';
const URI = '/features';
const URI_VALIDATE = '/features-validate';
function fetchAll () {
return fetch(URI)
.then(throwIfNotSuccess)
.then(response => response.json());
}
function create (featureToggle) {
return fetch(URI, {
method: 'POST',
headers,
body: JSON.stringify(featureToggle),
}).then(throwIfNotSuccess);
}
function validate (featureToggle) {
return fetch(URI_VALIDATE, {
method: 'POST',
headers,
body: JSON.stringify(featureToggle),
}).then(throwIfNotSuccess);
}
function update (featureToggle) {
return fetch(`${URI}/${featureToggle.name}`, {
method: 'PUT',
headers,
body: JSON.stringify(featureToggle),
}).then(throwIfNotSuccess);
}
function remove (featureToggleName) {
return fetch(`${URI}/${featureToggleName}`, {
method: 'DELETE',
}).then(throwIfNotSuccess);
}
module.exports = {
fetchAll,
create,
validate,
update,
remove,
};

View File

@ -0,0 +1,25 @@
const defaultErrorMessage = 'Unexptected exception when talking to unleash-api';
export function throwIfNotSuccess (response) {
if (!response.ok) {
if (response.status > 399 && response.status < 404) {
return new Promise((resolve, reject) => {
response.json().then(body => {
const errorMsg = body && body.length > 0 ? body[0].msg : defaultErrorMessage;
let error = new Error(errorMsg);
error.statusCode = response.status;
reject(error);
});
});
} else {
return Promise.reject(new Error(defaultErrorMessage));
}
}
return Promise.resolve(response);
};
export const headers = {
'Accept': 'application/json',
'Content-Type': 'application/json',
};

View File

@ -0,0 +1,13 @@
import { throwIfNotSuccess } from './helper';
const URI = '/events';
function fetchAll () {
return fetch(URI)
.then(throwIfNotSuccess)
.then(response => response.json());
}
module.exports = {
fetchAll,
};

View File

@ -0,0 +1,13 @@
import { throwIfNotSuccess } from './helper';
const URI = '/metrics';
function fetchAll () {
return fetch(URI)
.then(throwIfNotSuccess)
.then(response => response.json());
}
module.exports = {
fetchAll,
};

View File

@ -0,0 +1,30 @@
import { throwIfNotSuccess, headers } from './helper';
const URI = '/strategies';
function fetchAll () {
return fetch(URI)
.then(throwIfNotSuccess)
.then(response => response.json());
}
function create (strategy) {
return fetch(URI, {
method: 'POST',
headers,
body: JSON.stringify(strategy),
}).then(throwIfNotSuccess);
}
function remove (strategy) {
return fetch(`${URI}/${strategy.name}`, {
method: 'DELETE',
headers,
}).then(throwIfNotSuccess);
}
module.exports = {
fetchAll,
create,
remove,
};

46
frontend/src/index.jsx Normal file
View File

@ -0,0 +1,46 @@
import React from 'react';
import ReactDOM from 'react-dom';
import { Router, Route, IndexRedirect, hashHistory } from 'react-router';
import { Provider } from 'react-redux';
import thunkMiddleware from 'redux-thunk';
import { createStore, applyMiddleware } from 'redux';
import store from './store';
import App from './component/app';
import Features from './page/features';
import CreateFeatureToggle from './page/features/create';
import EditFeatureToggle from './page/features/edit';
import Strategies from './page/strategies';
import CreateStrategies from './page/strategies/create';
import HistoryPage from './page/history';
import Archive from './page/archive';
import Metrics from './page/metrics';
import ClientStrategies from './page/client-strategies';
import ClientInstances from './page/client-instances';
const unleashStore = createStore(
store,
applyMiddleware(
thunkMiddleware
)
);
ReactDOM.render(
<Provider store={unleashStore}>
<Router history={hashHistory}>
<Route path="/" component={App}>
<IndexRedirect to="/features" />
<Route path="/features" component={Features} />
<Route path="/features/create" component={CreateFeatureToggle} />
<Route path="/features/edit/:name" component={EditFeatureToggle} />
<Route path="/strategies" component={Strategies} />
<Route path="/strategies/create" component={CreateStrategies} />
<Route path="/history" component={HistoryPage} />
<Route path="/archive" component={Archive} />
<Route path="/metrics" component={Metrics} />
<Route path="/client-strategies" component={ClientStrategies} />
<Route path="/client-instances" component={ClientInstances} />
</Route>
</Router>
</Provider>, document.getElementById('app'));

View File

@ -0,0 +1,6 @@
import React from 'react';
import Archive from '../../component/archive/archive-container';
const render = () => <Archive />;
export default render;

View File

@ -0,0 +1,6 @@
import React from 'react';
import ClientInstance from '../../component/client-instance/client-instance-container';
const render = () => <ClientInstance />;
export default render;

View File

@ -0,0 +1,11 @@
import React from 'react';
import ClientStrategy from '../../component/client-strategy/strategy-container';
const render = () => (
<div>
<h5>Client Strategies</h5>
<ClientStrategy />
</div>
);
export default render;

View File

@ -0,0 +1,11 @@
import React from 'react';
import AddFeatureToggleForm from '../../component/feature/form-add-container';
const render = () => (
<div>
<h3>Create feature toggle</h3>
<AddFeatureToggleForm />
</div>
);
export default render;

View File

@ -0,0 +1,19 @@
import React, { Component, PropTypes } from 'react';
import EditFeatureToggleForm from '../../component/feature/form-edit-container';
export default class Features extends Component {
static propTypes () {
return {
params: PropTypes.object.isRequired,
};
}
render () {
return (
<div>
<h3>Edit feature toggle</h3>
<EditFeatureToggleForm featureToggleName={this.props.params.name} />
</div>
);
}
};

View File

@ -0,0 +1,8 @@
import React from 'react';
import FeatureListContainer from '../../component/feature/list-container';
const render = () => (<FeatureListContainer />);
export default render;

View File

@ -0,0 +1,6 @@
import React from 'react';
import HistoryComponent from '../../component/history/history-container';
const render = () => <HistoryComponent />;
export default render;

View File

@ -0,0 +1,6 @@
import React from 'react';
import Metrics from '../../component/metrics/metrics-container';
const render = () => <Metrics />;
export default render;

View File

@ -0,0 +1,4 @@
import React from 'react';
import AddStrategies from '../../component/strategies/add-container';
export default () => (<AddStrategies />);

View File

@ -0,0 +1,4 @@
import React from 'react';
import Strategies from '../../component/strategies/list-container';
export default () => (<Strategies />);

View File

@ -0,0 +1,33 @@
import api from '../data/archive-api';
export const REVIVE_TOGGLE = 'REVIVE_TOGGLE';
export const RECEIVE_ARCHIVE = 'RECEIVE_ARCHIVE';
export const ERROR_RECEIVE_ARCHIVE = 'ERROR_RECEIVE_ARCHIVE';
const receiveArchive = (json) => ({
type: RECEIVE_ARCHIVE,
value: json.features,
});
const reviveToggle = (archiveFeatureToggle) => ({
type: REVIVE_TOGGLE,
value: archiveFeatureToggle,
});
const errorReceiveArchive = (statusCode) => ({
type: ERROR_RECEIVE_ARCHIVE,
statusCode,
});
export function revive (featureToggle) {
return dispatch => api.revive(featureToggle)
.then(() => dispatch(reviveToggle(featureToggle)))
.catch(error => dispatch(errorReceiveArchive(error)));
}
export function fetchArchive () {
return dispatch => api.fetchAll()
.then(json => dispatch(receiveArchive(json)))
.catch(error => dispatch(errorReceiveArchive(error)));
}

View File

@ -0,0 +1,19 @@
import { List, Map as $Map } from 'immutable';
import { RECEIVE_ARCHIVE, REVIVE_TOGGLE } from './archive-actions';
function getInitState () {
return new $Map({ list: new List() });
}
const archiveStore = (state = getInitState(), action) => {
switch (action.type) {
case REVIVE_TOGGLE:
return state.update('list', (list) => list.remove(list.indexOf(action.value)));
case RECEIVE_ARCHIVE:
return state.set('list', new List(action.value));
default:
return state;
}
};
export default archiveStore;

View File

@ -0,0 +1,20 @@
import api from '../data/client-instance-api';
export const RECEIVE_CLIENT_INSTANCES = 'RECEIVE_CLIENT_INSTANCES';
export const ERROR_RECEIVE_CLIENT_INSTANCES = 'ERROR_RECEIVE_CLIENT_INSTANCES';
const receiveClientInstances = (json) => ({
type: RECEIVE_CLIENT_INSTANCES,
value: json,
});
const errorReceiveClientInstances = (statusCode) => ({
type: RECEIVE_CLIENT_INSTANCES,
statusCode,
});
export function fetchClientInstances () {
return dispatch => api.fetchAll()
.then(json => dispatch(receiveClientInstances(json)))
.catch(error => dispatch(errorReceiveClientInstances(error)));
}

View File

@ -0,0 +1,17 @@
import { fromJS } from 'immutable';
import { RECEIVE_CLIENT_INSTANCES } from './client-instance-actions';
function getInitState () {
return fromJS([]);
}
const store = (state = getInitState(), action) => {
switch (action.type) {
case RECEIVE_CLIENT_INSTANCES:
return fromJS(action.value);
default:
return state;
}
};
export default store;

View File

@ -0,0 +1,20 @@
import api from '../data/client-strategy-api';
export const RECEIVE_CLIENT_STRATEGIES = 'RECEIVE_CLIENT_STRATEGIES';
export const ERROR_RECEIVE_CLIENT_STRATEGIES = 'ERROR_RECEIVE_CLIENT_STRATEGIES';
const receiveMetrics = (json) => ({
type: RECEIVE_CLIENT_STRATEGIES,
value: json,
});
const errorReceiveMetrics = (statusCode) => ({
type: RECEIVE_CLIENT_STRATEGIES,
statusCode,
});
export function fetchClientStrategies () {
return dispatch => api.fetchAll()
.then(json => dispatch(receiveMetrics(json)))
.catch(error => dispatch(errorReceiveMetrics(error)));
}

View File

@ -0,0 +1,17 @@
import { fromJS } from 'immutable';
import { RECEIVE_CLIENT_STRATEGIES } from './client-strategy-actions';
function getInitState () {
return fromJS([]);
}
const store = (state = getInitState(), action) => {
switch (action.type) {
case RECEIVE_CLIENT_STRATEGIES:
return fromJS(action.value);
default:
return state;
}
};
export default store;

View File

@ -0,0 +1,8 @@
export const MUTE_ERRORS = 'MUTE_ERRORS';
export const MUTE_ERROR = 'MUTE_ERROR';
export const muteErrors = () => ({ type: MUTE_ERRORS });
export const muteError = (error) => ({ type: MUTE_ERROR, error });

View File

@ -0,0 +1,40 @@
import { List, Map as $Map } from 'immutable';
import { MUTE_ERROR } from './error-actions';
import {
ERROR_FETCH_FEATURE_TOGGLES,
ERROR_CREATING_FEATURE_TOGGLE,
ERROR_REMOVE_FEATURE_TOGGLE,
ERROR_UPDATE_FEATURE_TOGGLE,
} from './feature-actions';
const debug = require('debug')('unleash:error-store');
function getInitState () {
return new $Map({
list: new List(),
});
}
function addErrorIfNotAlreadyInList (state, error) {
debug('Got error', error);
if (state.get('list').indexOf(error) < 0) {
return state.update('list', (list) => list.push(error));
}
return state;
}
const strategies = (state = getInitState(), action) => {
switch (action.type) {
case ERROR_CREATING_FEATURE_TOGGLE:
case ERROR_REMOVE_FEATURE_TOGGLE:
case ERROR_FETCH_FEATURE_TOGGLES:
case ERROR_UPDATE_FEATURE_TOGGLE:
return addErrorIfNotAlreadyInList(state, action.error.message);
case MUTE_ERROR:
return state.update('list', (list) => list.remove(list.indexOf(action.error)));
default:
return state;
}
};
export default strategies;

View File

@ -0,0 +1,93 @@
import api from '../data/feature-api';
const debug = require('debug')('unleash:feature-actions');
export const ADD_FEATURE_TOGGLE = 'ADD_FEATURE_TOGGLE';
export const REMOVE_FEATURE_TOGGLE = 'REMOVE_FEATURE_TOGGLE';
export const UPDATE_FEATURE_TOGGLE = 'UPDATE_FEATURE_TOGGLE';
export const TOGGLE_FEATURE_TOGGLE = 'TOGGLE_FEATURE_TOGGLE';
export const START_FETCH_FEATURE_TOGGLES = 'START_FETCH_FEATURE_TOGGLES';
export const START_UPDATE_FEATURE_TOGGLE = 'START_UPDATE_FEATURE_TOGGLE';
export const START_CREATE_FEATURE_TOGGLE = 'START_CREATE_FEATURE_TOGGLE';
export const START_REMOVE_FEATURE_TOGGLE = 'START_REMOVE_FEATURE_TOGGLE';
export const RECEIVE_FEATURE_TOGGLES = 'RECEIVE_FEATURE_TOGGLES';
export const ERROR_FETCH_FEATURE_TOGGLES = 'ERROR_FETCH_FEATURE_TOGGLES';
export const ERROR_CREATING_FEATURE_TOGGLE = 'ERROR_CREATING_FEATURE_TOGGLE';
export const ERROR_UPDATE_FEATURE_TOGGLE = 'ERROR_UPDATE_FEATURE_TOGGLE';
export const ERROR_REMOVE_FEATURE_TOGGLE = 'ERROR_REMOVE_FEATURE_TOGGLE';
export function toggleFeature (featureToggle) {
debug('Toggle feature toggle ', featureToggle);
return dispatch => {
const newValue = Object.assign({}, featureToggle, { enabled: !featureToggle.enabled });
dispatch(requestUpdateFeatureToggle(newValue));
};
};
export function editFeatureToggle (featureToggle) {
debug('Update feature toggle ', featureToggle);
return dispatch => {
dispatch(requestUpdateFeatureToggle(featureToggle));
};
};
function receiveFeatureToggles (json) {
debug('reviced feature toggles', json);
return {
type: RECEIVE_FEATURE_TOGGLES,
featureToggles: json.features.map(features => features),
receivedAt: Date.now(),
};
}
function dispatchAndThrow (dispatch, type) {
return (error) => {
dispatch({ type, error, receivedAt: Date.now() });
throw error;
};
}
export function fetchFeatureToggles () {
debug('Start fetching feature toggles');
return dispatch => {
dispatch({ type: START_FETCH_FEATURE_TOGGLES });
return api.fetchAll()
.then(json => dispatch(receiveFeatureToggles(json)))
.catch(dispatchAndThrow(dispatch, ERROR_FETCH_FEATURE_TOGGLES));
};
}
export function createFeatureToggles (featureToggle) {
return dispatch => {
dispatch({ type: START_CREATE_FEATURE_TOGGLE });
return api.create(featureToggle)
.then(() => dispatch({ type: ADD_FEATURE_TOGGLE, featureToggle }))
.catch(dispatchAndThrow(dispatch, ERROR_CREATING_FEATURE_TOGGLE));
};
}
export function requestUpdateFeatureToggle (featureToggle) {
return dispatch => {
dispatch({ type: START_UPDATE_FEATURE_TOGGLE });
return api.update(featureToggle)
.then(() => dispatch({ type: UPDATE_FEATURE_TOGGLE, featureToggle }))
.catch(dispatchAndThrow(dispatch, ERROR_UPDATE_FEATURE_TOGGLE));
};
}
export function removeFeatureToggle (featureToggleName) {
return dispatch => {
dispatch({ type: START_REMOVE_FEATURE_TOGGLE });
return api.remove(featureToggleName)
.then(() => dispatch({ type: REMOVE_FEATURE_TOGGLE, featureToggleName }))
.catch(dispatchAndThrow(dispatch, ERROR_REMOVE_FEATURE_TOGGLE));
};
}
export function validateName (featureToggleName) {
return api.validate({ name: featureToggleName });
}

View File

@ -0,0 +1,30 @@
import api from './feature-metrics-api';
export const START_FETCH_FEATURE_METRICS = 'START_FETCH_FEATURE_METRICS';
export const RECEIVE_FEATURE_METRICS = 'RECEIVE_FEATURE_METRICS';
export const ERROR_FETCH_FEATURE_TOGGLES = 'ERROR_FETCH_FEATURE_TOGGLES';
function receiveFeatureMetrics (json) {
return {
type: RECEIVE_FEATURE_METRICS,
metrics: json,
receivedAt: Date.now(),
};
}
function dispatchAndThrow (dispatch, type) {
return (error) => {
dispatch({ type, error, receivedAt: Date.now() });
throw error;
};
}
export function fetchFeatureMetrics () {
return dispatch => {
dispatch({ type: START_FETCH_FEATURE_METRICS });
return api.fetchFeatureMetrics()
.then(json => dispatch(receiveFeatureMetrics(json)))
.catch(dispatchAndThrow(dispatch, ERROR_FETCH_FEATURE_TOGGLES));
};
}

View File

@ -0,0 +1,29 @@
const defaultErrorMessage = 'Unexptected exception when talking to unleash-api';
function throwIfNotSuccess (response) {
if (!response.ok) {
if (response.status > 400 && response.status < 404) {
return new Promise((resolve, reject) => {
response.json().then(body => {
const errorMsg = body && body.length > 0 ? body[0].msg : defaultErrorMessage;
let error = new Error(errorMsg);
error.statusCode = response.status;
reject(error);
});
});
} else {
return Promise.reject(new Error(defaultErrorMessage));
}
}
return Promise.resolve(response);
}
function fetchFeatureMetrics () {
return fetch('/metrics/features')
.then(throwIfNotSuccess)
.then(response => response.json());
}
module.exports = {
fetchFeatureMetrics,
};

View File

@ -0,0 +1,21 @@
import { Map as $Map, fromJS } from 'immutable';
import {
RECEIVE_FEATURE_METRICS,
} from './feature-metrics-actions';
const metrics = (state = fromJS({ lastHour: {}, lastMinute: {} }), action) => {
switch (action.type) {
case RECEIVE_FEATURE_METRICS:
return state.withMutations((ctx) => {
ctx.set('lastHour', new $Map(action.metrics.lastHour));
ctx.set('lastMinute', new $Map(action.metrics.lastMinute));
return ctx;
});
default:
return state;
}
};
export default metrics;

View File

@ -0,0 +1,38 @@
import { List, Map as $Map } from 'immutable';
const debug = require('debug')('unleash:feature-store');
import {
ADD_FEATURE_TOGGLE,
RECEIVE_FEATURE_TOGGLES,
UPDATE_FEATURE_TOGGLE,
REMOVE_FEATURE_TOGGLE,
} from './feature-actions';
const features = (state = new List([]), action) => {
switch (action.type) {
case ADD_FEATURE_TOGGLE:
debug(ADD_FEATURE_TOGGLE, action);
return state.push(new $Map(action.featureToggle));
case REMOVE_FEATURE_TOGGLE:
debug(REMOVE_FEATURE_TOGGLE, action);
return state.filter(toggle => toggle.get('name') !== action.featureToggleName);
case UPDATE_FEATURE_TOGGLE:
debug(UPDATE_FEATURE_TOGGLE, action);
return state.map(toggle => {
if (toggle.get('name') === action.featureToggle.name) {
return new $Map(action.featureToggle);
} else {
return toggle;
}
});
case RECEIVE_FEATURE_TOGGLES:
debug(RECEIVE_FEATURE_TOGGLES, action);
return new List(action.featureToggles.map($Map));
default:
return state;
}
};
export default features;

View File

@ -0,0 +1,20 @@
import api from '../data/history-api';
export const RECEIVE_HISTORY = 'RECEIVE_HISTORY';
export const ERROR_RECEIVE_HISTORY = 'ERROR_RECEIVE_HISTORY';
const receiveHistory = (json) => ({
type: RECEIVE_HISTORY,
value: json.events,
});
const errorReceiveHistory = (statusCode) => ({
type: ERROR_RECEIVE_HISTORY,
statusCode,
});
export function fetchHistory () {
return dispatch => api.fetchAll()
.then(json => dispatch(receiveHistory(json)))
.catch(error => dispatch(errorReceiveHistory(error)));
}

View File

@ -0,0 +1,17 @@
import { List, Map as $Map } from 'immutable';
import { RECEIVE_HISTORY } from './history-actions';
function getInitState () {
return new $Map({ list: new List() });
}
const historyStore = (state = getInitState(), action) => {
switch (action.type) {
case RECEIVE_HISTORY:
return state.set('list', new List(action.value));
default:
return state;
}
};
export default historyStore;

View File

@ -0,0 +1,26 @@
import { combineReducers } from 'redux';
import features from './feature-store';
import featureMetrics from './feature-metrics-store';
import strategies from './strategy-store';
import input from './input-store';
import history from './history-store'; // eslint-disable-line
import archive from './archive-store';
import error from './error-store';
import metrics from './metrics-store';
import clientStrategies from './client-strategy-store';
import clientInstances from './client-instance-store';
const unleashStore = combineReducers({
features,
featureMetrics,
strategies,
input,
history,
archive,
error,
metrics,
clientStrategies,
clientInstances,
});
export default unleashStore;

View File

@ -0,0 +1,19 @@
export const actions = {
SET_VALUE: 'SET_VALUE',
INCREMENT_VALUE: 'INCREMENT_VALUE',
LIST_PUSH: 'LIST_PUSH',
LIST_POP: 'LIST_POP',
LIST_UP: 'LIST_UP',
CLEAR: 'CLEAR',
INIT: 'INIT',
};
export const createInit = ({ id, value }) => ({ type: actions.INIT, id, value });
export const createInc = ({ id, key }) => ({ type: actions.INCREMENT_VALUE, id, key });
export const createSet = ({ id, key, value }) => ({ type: actions.SET_VALUE, id, key, value });
export const createPush = ({ id, key, value }) => ({ type: actions.LIST_PUSH, id, key, value });
export const createPop = ({ id, key, value }) => ({ type: actions.LIST_POP, id, key, value });
export const createUp = ({ id, key, value, newValue }) => ({ type: actions.LIST_UP, id, key, value, newValue });
export const createClear = ({ id }) => ({ type: actions.CLEAR, id });
export default actions;

View File

@ -0,0 +1,94 @@
import { Map as $Map, List, fromJS } from 'immutable';
import actions from './input-actions';
function getInitState () {
return new $Map();
}
function init (state, { id, value }) {
state = assertId(state, id);
return state.setIn(id, fromJS(value));
}
function assertId (state, id) {
if (!state.hasIn(id)) {
return state.setIn(id, new $Map({ inputId: id }));
}
return state;
}
function assertList (state, id, key) {
if (!state.getIn(id).has(key)) {
return state.setIn(id.concat([key]), new List());
}
return state;
}
function setKeyValue (state, { id, key, value }) {
state = assertId(state, id);
return state.setIn(id.concat([key]), value);
}
function increment (state, { id, key }) {
state = assertId(state, id);
return state.updateIn(id.concat([key]), (value = 0) => value + 1);
}
function clear (state, { id }) {
if (state.hasIn(id)) {
return state.removeIn(id);
}
return state;
}
function addToList (state, { id, key, value }) {
state = assertId(state, id);
state = assertList(state, id, key);
return state.updateIn(id.concat([key]), (list) => list.push(value));
}
function updateInList (state, { id, key, value, newValue }) {
state = assertId(state, id);
state = assertList(state, id, key);
return state.updateIn(id.concat([key]), (list) => list.set(list.indexOf(value), newValue));
}
function removeFromList (state, { id, key, value }) {
state = assertId(state, id);
state = assertList(state, id, key);
return state.updateIn(id.concat([key]), (list) => list.remove(list.indexOf(value)));
}
const inputState = (state = getInitState(), action) => {
if (!action.id) {
return state;
}
switch (action.type) {
case actions.INIT:
return init(state, action);
case actions.SET_VALUE:
if (actions.key != null && actions.value != null) {
throw new Error('Missing required key / value');
}
return setKeyValue(state, action);
case actions.INCREMENT_VALUE:
return increment(state, action);
case actions.LIST_PUSH:
return addToList(state, action);
case actions.LIST_POP:
return removeFromList(state, action);
case actions.LIST_UP:
return updateInList(state, action);
case actions.CLEAR:
return clear(state, action);
default:
// console.log('TYPE', action.type, action);
return state;
}
};
export default inputState;

View File

@ -0,0 +1,20 @@
import api from '../data/metrics-api';
export const RECEIVE_METRICS = 'RECEIVE_METRICS';
export const ERROR_RECEIVE_METRICS = 'ERROR_RECEIVE_METRICS';
const receiveMetrics = (json) => ({
type: RECEIVE_METRICS,
value: json,
});
const errorReceiveMetrics = (statusCode) => ({
type: ERROR_RECEIVE_METRICS,
statusCode,
});
export function fetchMetrics () {
return dispatch => api.fetchAll()
.then(json => dispatch(receiveMetrics(json)))
.catch(error => dispatch(errorReceiveMetrics(error)));
}

View File

@ -0,0 +1,21 @@
import { fromJS } from 'immutable';
import { RECEIVE_METRICS } from './metrics-actions';
function getInitState () {
return fromJS({
totalCount: 0,
apps: [],
clients: {},
});
}
const historyStore = (state = getInitState(), action) => {
switch (action.type) {
case RECEIVE_METRICS:
return fromJS(action.value);
default:
return state;
}
};
export default historyStore;

View File

@ -0,0 +1,61 @@
import api from '../data/strategy-api';
export const ADD_STRATEGY = 'ADD_STRATEGY';
export const REMOVE_STRATEGY = 'REMOVE_STRATEGY';
export const REQUEST_STRATEGIES = 'REQUEST_STRATEGIES';
export const START_CREATE_STRATEGY = 'START_CREATE_STRATEGY';
export const RECEIVE_STRATEGIES = 'RECEIVE_STRATEGIES';
export const ERROR_RECEIVE_STRATEGIES = 'ERROR_RECEIVE_STRATEGIES';
export const ERROR_CREATING_STRATEGY = 'ERROR_CREATING_STRATEGY';
const addStrategy = (strategy) => ({ type: ADD_STRATEGY, strategy });
const createRemoveStrategy = (strategy) => ({ type: REMOVE_STRATEGY, strategy });
const errorCreatingStrategy = (statusCode) => ({
type: ERROR_CREATING_STRATEGY,
statusCode,
});
const startRequest = () => ({ type: REQUEST_STRATEGIES });
const receiveStrategies = (json) => ({
type: RECEIVE_STRATEGIES,
value: json.strategies,
});
const startCreate = () => ({ type: START_CREATE_STRATEGY });
const errorReceiveStrategies = (statusCode) => ({
type: ERROR_RECEIVE_STRATEGIES,
statusCode,
});
export function fetchStrategies () {
return dispatch => {
dispatch(startRequest());
return api.fetchAll()
.then(json => dispatch(receiveStrategies(json)))
.catch(error => dispatch(errorReceiveStrategies(error)));
};
}
export function createStrategy (strategy) {
return dispatch => {
dispatch(startCreate());
return api.create(strategy)
.then(() => dispatch(addStrategy(strategy)))
.catch(error => dispatch(errorCreatingStrategy(error)));
};
}
export function removeStrategy (strategy) {
return dispatch => api.remove(strategy)
.then(() => dispatch(createRemoveStrategy(strategy)))
.catch(error => dispatch(errorCreatingStrategy(error)));
}

View File

@ -0,0 +1,29 @@
import { List, Map as $Map } from 'immutable';
import { RECEIVE_STRATEGIES, REMOVE_STRATEGY, ADD_STRATEGY } from './strategy-actions';
function getInitState () {
return new $Map({ list: new List() });
}
function removeStrategy (state, action) {
const indexToRemove = state.get('list').indexOf(action.strategy);
if (indexToRemove !== -1) {
return state.update('list', (list) => list.remove(indexToRemove));
}
return state;
}
const strategies = (state = getInitState(), action) => {
switch (action.type) {
case RECEIVE_STRATEGIES:
return state.set('list', new List(action.value));
case REMOVE_STRATEGY:
return removeStrategy(state, action);
case ADD_STRATEGY:
return state.update('list', (list) => list.push(action.strategy));
default:
return state;
}
};
export default strategies;

View File

@ -0,0 +1,31 @@
@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

@ -0,0 +1,86 @@
// docs: http://webpack.github.io/docs/configuration.html
'use strict';
const path = require('path');
const webpack = require('webpack');
const ExtractTextPlugin = require('extract-text-webpack-plugin');
module.exports = {
entry: [
'webpack-dev-server/client?http://localhost:3000',
'webpack/hot/only-dev-server',
'./src/index',
],
resolve: {
root: [path.join(__dirname, 'src')],
extensions: ['', '.scss', '.css', '.js', '.jsx', '.json'],
},
output: {
path: path.join(__dirname, 'dist'),
filename: 'bundle.js',
publicPath: '/static/',
},
module: {
loaders: [
{
test: /\.jsx?$/,
exclude: /node_modules/,
loaders: ['babel'],
include: path.join(__dirname, 'src'),
},
{
test: /(\.scss|\.css)$/,
loader: ExtractTextPlugin.extract('style',
'css?sourceMap&modules&importLoaders=1&localIdentName=[name]__[local]___[hash:base64:5]!postcss!sass'),
},
],
},
plugins: [
new ExtractTextPlugin('bundle.css', { allChunks: true }),
new webpack.HotModuleReplacementPlugin(),
],
sassLoader: {
data: '@import "theme/_config.scss";',
includePaths: [path.resolve(__dirname, './src')],
},
devtool: 'source-map',
externals: {
// stuff not in node_modules can be resolved here.
},
devServer: {
proxy: {
'/features': {
target: 'http://localhost:4242',
secure: false,
},
'/strategies': {
target: 'http://localhost:4242',
secure: false,
},
'/archive': {
target: 'http://localhost:4242',
secure: false,
},
'/events': {
target: 'http://localhost:4242',
secure: false,
},
'/metrics': {
target: 'http://localhost:4242',
secure: false,
},
'/client': {
target: 'http://localhost:4242',
secure: false,
},
},
},
};