diff --git a/frontend/.babelrc b/frontend/.babelrc
new file mode 100644
index 0000000000..4d3b7d3676
--- /dev/null
+++ b/frontend/.babelrc
@@ -0,0 +1,8 @@
+{
+ "presets": ["react", "es2015", "stage-2"],
+ "env": {
+ "development": {
+ "presets": ["react-hmre"]
+ }
+ }
+}
diff --git a/frontend/.eslintignore b/frontend/.eslintignore
new file mode 100644
index 0000000000..d1c1dbe227
--- /dev/null
+++ b/frontend/.eslintignore
@@ -0,0 +1,2 @@
+node_modules
+bundle.js
diff --git a/frontend/.eslintrc b/frontend/.eslintrc
new file mode 100644
index 0000000000..ee0b68514f
--- /dev/null
+++ b/frontend/.eslintrc
@@ -0,0 +1,9 @@
+{
+ "extends": [
+ "finn",
+ "finn/node"
+ ],
+ "rules": {
+ "no-shadow": 0
+ }
+}
diff --git a/frontend/.gitignore b/frontend/.gitignore
index 5148e527a7..e2af53d0a9 100644
--- a/frontend/.gitignore
+++ b/frontend/.gitignore
@@ -35,3 +35,6 @@ jspm_packages
# Optional REPL history
.node_repl_history
+
+# bundled assets
+dist
diff --git a/frontend/README.md b/frontend/README.md
new file mode 100644
index 0000000000..9e0a26fa5b
--- /dev/null
+++ b/frontend/README.md
@@ -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!
diff --git a/frontend/index.html b/frontend/index.html
new file mode 100644
index 0000000000..7c0bb5e22d
--- /dev/null
+++ b/frontend/index.html
@@ -0,0 +1,13 @@
+
+
+
+ Unleash Admin
+
+
+
+
+
+
+
+
+
diff --git a/frontend/index.js b/frontend/index.js
new file mode 100644
index 0000000000..621880f22a
--- /dev/null
+++ b/frontend/index.js
@@ -0,0 +1,7 @@
+'use strict';
+
+const path = require('path');
+
+module.exports = {
+ publicFolder: path.join(__dirname, 'dist'),
+};
diff --git a/frontend/jest-preprocessor.js b/frontend/jest-preprocessor.js
new file mode 100644
index 0000000000..66c8d10d00
--- /dev/null
+++ b/frontend/jest-preprocessor.js
@@ -0,0 +1,9 @@
+// preprocessor.js
+'use strict';
+
+const ReactTools = require('react-tools');
+module.exports = {
+ process (src) {
+ return ReactTools.transform(src);
+ },
+};
diff --git a/frontend/mock-api.json b/frontend/mock-api.json
new file mode 100644
index 0000000000..0bc4ee1726
--- /dev/null
+++ b/frontend/mock-api.json
@@ -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": {}
+ }
+ ]
+ }
+ ]
+ }
+}
diff --git a/frontend/package.json b/frontend/package.json
new file mode 100644
index 0000000000..eb6e15f538
--- /dev/null
+++ b/frontend/package.json
@@ -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": "/jest-preprocessor.js",
+ "modulePathIgnorePatterns": [
+ "/node_modules/npm"
+ ],
+ "unmockedModulePathPatterns": [
+ "/node_modules/react",
+ "/node_modules/reflux"
+ ],
+ "moduleFileExtensions": [
+ "jsx",
+ "js"
+ ]
+ },
+ "pre-commit": [
+ "lint"
+ ]
+}
diff --git a/frontend/src/.eslintrc b/frontend/src/.eslintrc
new file mode 100644
index 0000000000..67c2bde374
--- /dev/null
+++ b/frontend/src/.eslintrc
@@ -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
+ }
+}
diff --git a/frontend/src/component/app.jsx b/frontend/src/component/app.jsx
new file mode 100644
index 0000000000..34f65038f4
--- /dev/null
+++ b/frontend/src/component/app.jsx
@@ -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 (
+
+
+
+
+
+
+
+
+
+ {this.props.children}
+
+
+
+
+
+
+ );
+ }
+};
diff --git a/frontend/src/component/archive/archive-container.js b/frontend/src/component/archive/archive-container.js
new file mode 100644
index 0000000000..a7b589e5d7
--- /dev/null
+++ b/frontend/src/component/archive/archive-container.js
@@ -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;
diff --git a/frontend/src/component/archive/archive-list-component.jsx b/frontend/src/component/archive/archive-list-component.jsx
new file mode 100644
index 0000000000..5dcd783ba5
--- /dev/null
+++ b/frontend/src/component/archive/archive-list-component.jsx
@@ -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 = [
+ {strategies && strategies.map(s => {s.name})}
,
+ revive(feature)} />,
+ ];
+
+ const leftActions = [
+ ,
+ ];
+
+ return (
+
+ );
+};
+
+class ArchiveList extends Component {
+ componentDidMount () {
+ this.props.fetchArchive();
+ }
+
+ render () {
+ const { archive, revive } = this.props;
+ return (
+
+
+ {archive.length > 0 ?
+ archive.map((feature, i) => ) :
+ }
+
+ );
+ }
+}
+
+
+export default ArchiveList;
diff --git a/frontend/src/component/client-instance/client-instance-component.js b/frontend/src/component/client-instance/client-instance-component.js
new file mode 100644
index 0000000000..6d22b39433
--- /dev/null
+++ b/frontend/src/component/client-instance/client-instance-component.js
@@ -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 (
+
+ );
+ }
+}
+
+
+export default ClientStrategies;
diff --git a/frontend/src/component/client-instance/client-instance-container.js b/frontend/src/component/client-instance/client-instance-container.js
new file mode 100644
index 0000000000..4c13df8eab
--- /dev/null
+++ b/frontend/src/component/client-instance/client-instance-container.js
@@ -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;
diff --git a/frontend/src/component/client-strategy/strategy-component.js b/frontend/src/component/client-strategy/strategy-component.js
new file mode 100644
index 0000000000..3814f84dcb
--- /dev/null
+++ b/frontend/src/component/client-strategy/strategy-component.js
@@ -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 (
+
+ );
+ }
+}
+
+
+export default ClientStrategies;
diff --git a/frontend/src/component/client-strategy/strategy-container.js b/frontend/src/component/client-strategy/strategy-container.js
new file mode 100644
index 0000000000..e227c65a8a
--- /dev/null
+++ b/frontend/src/component/client-strategy/strategy-container.js
@@ -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;
diff --git a/frontend/src/component/error/error-component.jsx b/frontend/src/component/error/error-component.jsx
new file mode 100644
index 0000000000..22e3d6faf2
--- /dev/null
+++ b/frontend/src/component/error/error-component.jsx
@@ -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 (
+ this.props.muteError(error)}
+ type="warning"
+ />
+ );
+ }
+}
+
+export default ErrorComponent;
diff --git a/frontend/src/component/error/error-container.jsx b/frontend/src/component/error/error-container.jsx
new file mode 100644
index 0000000000..04dd974fd0
--- /dev/null
+++ b/frontend/src/component/error/error-container.jsx
@@ -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);
diff --git a/frontend/src/component/feature/feature-component.jsx b/frontend/src/component/feature/feature-component.jsx
new file mode 100644
index 0000000000..84920fcad5
--- /dev/null
+++ b/frontend/src/component/feature/feature-component.jsx
@@ -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 = [
+ {strategies && strategies.map((s, i) => {s.name})}
,
+ ({created.toLocaleDateString('nb-NO')})
,
+
+
+ ,
+ onFeatureRemove(name)} />,
+ ];
+
+ const leftActions = [
+ {metricsLastHour.yes} / {metricsLastHour.no},
+ {metricsLastMinute.yes} / {metricsLastMinute.no},
+ onFeatureClick(feature)} checked={enabled} />,
+ ];
+
+ return (
+
+ );
+};
+
+Feature.propTypes = {
+ feature: PropTypes.object,
+ onFeatureClick: PropTypes.func,
+ onFeatureRemove: PropTypes.func,
+};
+
+export default Feature;
diff --git a/frontend/src/component/feature/feature.scss b/frontend/src/component/feature/feature.scss
new file mode 100644
index 0000000000..636272d55f
--- /dev/null
+++ b/frontend/src/component/feature/feature.scss
@@ -0,0 +1,16 @@
+.link {
+ color: #212121;
+}
+
+.action {
+ color: #aaa !important;
+ cursor: pointer;
+}
+
+.yes {
+ color: green;
+}
+
+.no {
+ color: red;
+}
diff --git a/frontend/src/component/feature/form-add-container.jsx b/frontend/src/component/feature/form-add-container.jsx
new file mode 100644
index 0000000000..8a2ab56105
--- /dev/null
+++ b/frontend/src/component/feature/form-add-container.jsx
@@ -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);
diff --git a/frontend/src/component/feature/form-edit-container.jsx b/frontend/src/component/feature/form-edit-container.jsx
new file mode 100644
index 0000000000..b9264f7b82
--- /dev/null
+++ b/frontend/src/component/feature/form-edit-container.jsx
@@ -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);
diff --git a/frontend/src/component/feature/form/index.jsx b/frontend/src/component/feature/form/index.jsx
new file mode 100644
index 0000000000..f292085a01
--- /dev/null
+++ b/frontend/src/component/feature/form/index.jsx
@@ -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 (
+
+ );
+ }
+
+};
+
+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;
diff --git a/frontend/src/component/feature/form/strategies-add.jsx b/frontend/src/component/feature/form/strategies-add.jsx
new file mode 100644
index 0000000000..b775fa827b
--- /dev/null
+++ b/frontend/src/component/feature/form/strategies-add.jsx
@@ -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 (
+
+
+
+ {item.name}
+ {item.description}
+
+
+ );
+ }
+
+ render () {
+ const strats = this.props.strategies.map(s => {
+ s.value = s.name;
+ return s;
+ });
+
+ return (
+
+ );
+ }
+}
+
+
+export default AddStrategy;
diff --git a/frontend/src/component/feature/form/strategies-list.jsx b/frontend/src/component/feature/form/strategies-list.jsx
new file mode 100644
index 0000000000..35a96cb8f1
--- /dev/null
+++ b/frontend/src/component/feature/form/strategies-list.jsx
@@ -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 No strategies added;
+ }
+
+ const blocks = configuredStrategies.map((strat, i) => (
+ s.name === strat.name)} />
+ ));
+ return (
+
+ {blocks}
+
+ );
+ }
+}
+
+export default StrategiesList;
diff --git a/frontend/src/component/feature/form/strategies-section-container.jsx b/frontend/src/component/feature/form/strategies-section-container.jsx
new file mode 100644
index 0000000000..7f12216935
--- /dev/null
+++ b/frontend/src/component/feature/form/strategies-section-container.jsx
@@ -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);
diff --git a/frontend/src/component/feature/form/strategies-section.jsx b/frontend/src/component/feature/form/strategies-section.jsx
new file mode 100644
index 0000000000..06d91567aa
--- /dev/null
+++ b/frontend/src/component/feature/form/strategies-section.jsx
@@ -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 Loding available strategies;
+ }
+
+ return (
+
+ );
+ }
+}
+
+export default StrategiesSection;
diff --git a/frontend/src/component/feature/form/strategy-configure.jsx b/frontend/src/component/feature/form/strategy-configure.jsx
new file mode 100644
index 0000000000..fdef8863ce
--- /dev/null
+++ b/frontend/src/component/feature/form/strategy-configure.jsx
@@ -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 => (
+
+ ));
+ }
+ }
+
+ render () {
+ const leftActions = [
+ ,
+ ];
+
+ if (!this.props.strategyDefinition) {
+ return (
+ Strategy "{this.props.strategy.name}" deleted}
+ />
+ );
+ }
+
+ const inputFields = this.renderInputFields(this.props.strategyDefinition) || [];
+
+ return (
+
+ );
+ }
+}
+
+export default StrategyConfigure;
diff --git a/frontend/src/component/feature/list-component.jsx b/frontend/src/component/feature/list-component.jsx
new file mode 100644
index 0000000000..c51311ffb5
--- /dev/null
+++ b/frontend/src/component/feature/list-component.jsx
@@ -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 (
+
+
+ {features.map((feature, i) =>
+
+ )}
+
+ this.context.router.push('/features/create')}
+ caption="Add" legend="new feature toggle" leftIcon="add" />
+
+ );
+ }
+}
diff --git a/frontend/src/component/feature/list-container.jsx b/frontend/src/component/feature/list-container.jsx
new file mode 100644
index 0000000000..e15f2d5bba
--- /dev/null
+++ b/frontend/src/component/feature/list-container.jsx
@@ -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;
diff --git a/frontend/src/component/history/history-container.js b/frontend/src/component/history/history-container.js
new file mode 100644
index 0000000000..6986b6d3d5
--- /dev/null
+++ b/frontend/src/component/history/history-container.js
@@ -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;
diff --git a/frontend/src/component/history/history-list-component.jsx b/frontend/src/component/history/history-list-component.jsx
new file mode 100644
index 0000000000..9fff000308
--- /dev/null
+++ b/frontend/src/component/history/history-list-component.jsx
@@ -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 (
+
+
+ {history.length > 0 ? history.map((log, i) => {
+ const actions = [];
+
+
+ const icon = this.getIcon(log.type);
+
+ const caption = {log.data.name} {log.type}
;
+
+ return (
+
+ );
+ }) : }
+
+
+ );
+ }
+}
+
+
+export default HistoryList;
diff --git a/frontend/src/component/input-helpers.js b/frontend/src/component/input-helpers.js
new file mode 100644
index 0000000000..afcb2e7c95
--- /dev/null
+++ b/frontend/src/component/input-helpers.js
@@ -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));
+}
diff --git a/frontend/src/component/metrics/metrics-component.js b/frontend/src/component/metrics/metrics-component.js
new file mode 100644
index 0000000000..c8369d9a0c
--- /dev/null
+++ b/frontend/src/component/metrics/metrics-component.js
@@ -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 (
+
+ Total of {globalCount} toggles } />
+
+ {clientList.map(({ name, count, ping, appName }, i) =>
+ {count}]}
+ key={name + i}
+ caption={appName}
+ legend={`${name} pinged ${ping}`} />
+ )}
+
+ );
+ }
+}
+
+
+export default Metrics;
diff --git a/frontend/src/component/metrics/metrics-container.js b/frontend/src/component/metrics/metrics-container.js
new file mode 100644
index 0000000000..e4ab36af79
--- /dev/null
+++ b/frontend/src/component/metrics/metrics-container.js
@@ -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;
diff --git a/frontend/src/component/nav.jsx b/frontend/src/component/nav.jsx
new file mode 100644
index 0000000000..d4f6dfd435
--- /dev/null
+++ b/frontend/src/component/nav.jsx
@@ -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) =>
+ ;
+
+ return (
+
+ {createListItem('/features', 'Feature toggles')}
+ {createListItem('/strategies', 'Strategies')}
+ {createListItem('/history', 'Event history')}
+ {createListItem('/archive', 'Archived toggles')}
+
+
+
+
+ {createListItem('/metrics', 'Client metrics')}
+ {createListItem('/client-strategies', 'Client strategies')}
+ {createListItem('/client-instances', 'Client instances')}
+
+
+
+
+ {createListItem('/docs', 'Documentation')}
+
+
+
+
+
+
+ A product by FINN.no
+
+
+
+ );
+ }
+};
diff --git a/frontend/src/component/strategies/add-container.js b/frontend/src/component/strategies/add-container.js
new file mode 100644
index 0000000000..b9228ff1cf
--- /dev/null
+++ b/frontend/src/component/strategies/add-container.js
@@ -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);
diff --git a/frontend/src/component/strategies/add-strategy.jsx b/frontend/src/component/strategies/add-strategy.jsx
new file mode 100644
index 0000000000..2818d441a1
--- /dev/null
+++ b/frontend/src/component/strategies/add-strategy.jsx
@@ -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) => ({gerArrayWithEntries(num).map((v, i) => {
+ const key = `${PARAM_PREFIX}${i + 1}`;
+ return (
+ setValue(key, value)}
+ value={input[key]} />
+ );
+})}
);
+
+const AddStrategy = ({
+ input,
+ setValue,
+ incValue,
+ // clear,
+ onCancel,
+ onSubmit,
+}) => (
+
+);
+
+AddStrategy.propTypes = {
+ input: PropTypes.object,
+ setValue: PropTypes.func,
+ incValue: PropTypes.func,
+ clear: PropTypes.func,
+ onCancel: PropTypes.func,
+ onSubmit: PropTypes.func,
+};
+
+export default AddStrategy;
diff --git a/frontend/src/component/strategies/list-component.jsx b/frontend/src/component/strategies/list-component.jsx
new file mode 100644
index 0000000000..4a3208652b
--- /dev/null
+++ b/frontend/src/component/strategies/list-component.jsx
@@ -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 => (
+ {k}
+ ));
+ }
+
+ render () {
+ const { strategies, removeStrategy } = this.props;
+
+ return (
+
+
+ {strategies.length > 0 ? strategies.map((strategy, i) => {
+ const actions = this.getParameterMap(strategy).concat([
+ ,
+ ]);
+
+
+ return (
+
+ );
+ }) : }
+
+ this.context.router.push('/strategies/create')}
+ caption="Add" legend="new strategy" leftIcon="add" />
+
+ );
+ }
+}
+
+
+export default StrategiesListComponent;
diff --git a/frontend/src/component/strategies/list-container.jsx b/frontend/src/component/strategies/list-container.jsx
new file mode 100644
index 0000000000..1b1d1eb87e
--- /dev/null
+++ b/frontend/src/component/strategies/list-container.jsx
@@ -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;
diff --git a/frontend/src/component/strategies/strategies.scss b/frontend/src/component/strategies/strategies.scss
new file mode 100644
index 0000000000..544d25fa08
--- /dev/null
+++ b/frontend/src/component/strategies/strategies.scss
@@ -0,0 +1,8 @@
+.non-style-button {
+ cursor: pointer;
+ color: #757575;
+ background: none;
+ border: 0;
+ padding: 0;
+ margin: 0;
+}
diff --git a/frontend/src/component/styles.scss b/frontend/src/component/styles.scss
new file mode 100644
index 0000000000..2bb912f3e9
--- /dev/null
+++ b/frontend/src/component/styles.scss
@@ -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;
+ }
+}
+
diff --git a/frontend/src/data/archive-api.js b/frontend/src/data/archive-api.js
new file mode 100644
index 0000000000..29f6ae3743
--- /dev/null
+++ b/frontend/src/data/archive-api.js
@@ -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,
+};
+
diff --git a/frontend/src/data/client-instance-api.js b/frontend/src/data/client-instance-api.js
new file mode 100644
index 0000000000..bb56fd43a2
--- /dev/null
+++ b/frontend/src/data/client-instance-api.js
@@ -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,
+};
diff --git a/frontend/src/data/client-strategy-api.js b/frontend/src/data/client-strategy-api.js
new file mode 100644
index 0000000000..d97642f233
--- /dev/null
+++ b/frontend/src/data/client-strategy-api.js
@@ -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,
+};
diff --git a/frontend/src/data/feature-api.js b/frontend/src/data/feature-api.js
new file mode 100644
index 0000000000..5cf22290f8
--- /dev/null
+++ b/frontend/src/data/feature-api.js
@@ -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,
+};
diff --git a/frontend/src/data/helper.js b/frontend/src/data/helper.js
new file mode 100644
index 0000000000..4d396f379d
--- /dev/null
+++ b/frontend/src/data/helper.js
@@ -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',
+};
diff --git a/frontend/src/data/history-api.js b/frontend/src/data/history-api.js
new file mode 100644
index 0000000000..48037fa52a
--- /dev/null
+++ b/frontend/src/data/history-api.js
@@ -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,
+};
diff --git a/frontend/src/data/metrics-api.js b/frontend/src/data/metrics-api.js
new file mode 100644
index 0000000000..dbbdf19b76
--- /dev/null
+++ b/frontend/src/data/metrics-api.js
@@ -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,
+};
diff --git a/frontend/src/data/strategy-api.js b/frontend/src/data/strategy-api.js
new file mode 100644
index 0000000000..9aa061fe10
--- /dev/null
+++ b/frontend/src/data/strategy-api.js
@@ -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,
+};
diff --git a/frontend/src/index.jsx b/frontend/src/index.jsx
new file mode 100644
index 0000000000..84fb685f3e
--- /dev/null
+++ b/frontend/src/index.jsx
@@ -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(
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ , document.getElementById('app'));
diff --git a/frontend/src/page/archive/index.js b/frontend/src/page/archive/index.js
new file mode 100644
index 0000000000..c88ee8d0e1
--- /dev/null
+++ b/frontend/src/page/archive/index.js
@@ -0,0 +1,6 @@
+import React from 'react';
+import Archive from '../../component/archive/archive-container';
+
+const render = () => ;
+
+export default render;
diff --git a/frontend/src/page/client-instances/index.js b/frontend/src/page/client-instances/index.js
new file mode 100644
index 0000000000..75b27b52da
--- /dev/null
+++ b/frontend/src/page/client-instances/index.js
@@ -0,0 +1,6 @@
+import React from 'react';
+import ClientInstance from '../../component/client-instance/client-instance-container';
+
+const render = () => ;
+
+export default render;
diff --git a/frontend/src/page/client-strategies/index.js b/frontend/src/page/client-strategies/index.js
new file mode 100644
index 0000000000..1c5e66849c
--- /dev/null
+++ b/frontend/src/page/client-strategies/index.js
@@ -0,0 +1,11 @@
+import React from 'react';
+import ClientStrategy from '../../component/client-strategy/strategy-container';
+
+const render = () => (
+
+
Client Strategies
+
+
+);
+
+export default render;
diff --git a/frontend/src/page/features/create.js b/frontend/src/page/features/create.js
new file mode 100644
index 0000000000..ff9afd5e6f
--- /dev/null
+++ b/frontend/src/page/features/create.js
@@ -0,0 +1,11 @@
+import React from 'react';
+import AddFeatureToggleForm from '../../component/feature/form-add-container';
+
+const render = () => (
+
+
Create feature toggle
+
+
+);
+
+export default render;
diff --git a/frontend/src/page/features/edit.js b/frontend/src/page/features/edit.js
new file mode 100644
index 0000000000..fed77e7f18
--- /dev/null
+++ b/frontend/src/page/features/edit.js
@@ -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 (
+
+
Edit feature toggle
+
+
+ );
+ }
+};
diff --git a/frontend/src/page/features/index.js b/frontend/src/page/features/index.js
new file mode 100644
index 0000000000..b632addcd0
--- /dev/null
+++ b/frontend/src/page/features/index.js
@@ -0,0 +1,8 @@
+import React from 'react';
+import FeatureListContainer from '../../component/feature/list-container';
+
+const render = () => ();
+
+export default render;
+
+
diff --git a/frontend/src/page/history/index.js b/frontend/src/page/history/index.js
new file mode 100644
index 0000000000..8b2b5a8c3f
--- /dev/null
+++ b/frontend/src/page/history/index.js
@@ -0,0 +1,6 @@
+import React from 'react';
+import HistoryComponent from '../../component/history/history-container';
+
+const render = () => ;
+
+export default render;
diff --git a/frontend/src/page/metrics/index.js b/frontend/src/page/metrics/index.js
new file mode 100644
index 0000000000..c18da93b5c
--- /dev/null
+++ b/frontend/src/page/metrics/index.js
@@ -0,0 +1,6 @@
+import React from 'react';
+import Metrics from '../../component/metrics/metrics-container';
+
+const render = () => ;
+
+export default render;
diff --git a/frontend/src/page/strategies/create.js b/frontend/src/page/strategies/create.js
new file mode 100644
index 0000000000..bd94fcf7d1
--- /dev/null
+++ b/frontend/src/page/strategies/create.js
@@ -0,0 +1,4 @@
+import React from 'react';
+import AddStrategies from '../../component/strategies/add-container';
+
+export default () => ();
diff --git a/frontend/src/page/strategies/index.js b/frontend/src/page/strategies/index.js
new file mode 100644
index 0000000000..8f41038bd0
--- /dev/null
+++ b/frontend/src/page/strategies/index.js
@@ -0,0 +1,4 @@
+import React from 'react';
+import Strategies from '../../component/strategies/list-container';
+
+export default () => ();
diff --git a/frontend/src/store/archive-actions.js b/frontend/src/store/archive-actions.js
new file mode 100644
index 0000000000..07ea4cbc6d
--- /dev/null
+++ b/frontend/src/store/archive-actions.js
@@ -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)));
+}
diff --git a/frontend/src/store/archive-store.js b/frontend/src/store/archive-store.js
new file mode 100644
index 0000000000..845b79d748
--- /dev/null
+++ b/frontend/src/store/archive-store.js
@@ -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;
diff --git a/frontend/src/store/client-instance-actions.js b/frontend/src/store/client-instance-actions.js
new file mode 100644
index 0000000000..03de9edcf5
--- /dev/null
+++ b/frontend/src/store/client-instance-actions.js
@@ -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)));
+}
diff --git a/frontend/src/store/client-instance-store.js b/frontend/src/store/client-instance-store.js
new file mode 100644
index 0000000000..d075fbf841
--- /dev/null
+++ b/frontend/src/store/client-instance-store.js
@@ -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;
diff --git a/frontend/src/store/client-strategy-actions.js b/frontend/src/store/client-strategy-actions.js
new file mode 100644
index 0000000000..b7a07a0dad
--- /dev/null
+++ b/frontend/src/store/client-strategy-actions.js
@@ -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)));
+}
diff --git a/frontend/src/store/client-strategy-store.js b/frontend/src/store/client-strategy-store.js
new file mode 100644
index 0000000000..1de24b1be0
--- /dev/null
+++ b/frontend/src/store/client-strategy-store.js
@@ -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;
diff --git a/frontend/src/store/error-actions.js b/frontend/src/store/error-actions.js
new file mode 100644
index 0000000000..5b8a3682df
--- /dev/null
+++ b/frontend/src/store/error-actions.js
@@ -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 });
+
+
diff --git a/frontend/src/store/error-store.js b/frontend/src/store/error-store.js
new file mode 100644
index 0000000000..129f8b6c37
--- /dev/null
+++ b/frontend/src/store/error-store.js
@@ -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;
diff --git a/frontend/src/store/feature-actions.js b/frontend/src/store/feature-actions.js
new file mode 100644
index 0000000000..c21304f8b7
--- /dev/null
+++ b/frontend/src/store/feature-actions.js
@@ -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 });
+}
diff --git a/frontend/src/store/feature-metrics-actions.js b/frontend/src/store/feature-metrics-actions.js
new file mode 100644
index 0000000000..4dc8eb66a6
--- /dev/null
+++ b/frontend/src/store/feature-metrics-actions.js
@@ -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));
+ };
+}
diff --git a/frontend/src/store/feature-metrics-api.js b/frontend/src/store/feature-metrics-api.js
new file mode 100644
index 0000000000..cc4d28cac1
--- /dev/null
+++ b/frontend/src/store/feature-metrics-api.js
@@ -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,
+};
diff --git a/frontend/src/store/feature-metrics-store.js b/frontend/src/store/feature-metrics-store.js
new file mode 100644
index 0000000000..87d7459686
--- /dev/null
+++ b/frontend/src/store/feature-metrics-store.js
@@ -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;
diff --git a/frontend/src/store/feature-store.js b/frontend/src/store/feature-store.js
new file mode 100644
index 0000000000..ef17ff6663
--- /dev/null
+++ b/frontend/src/store/feature-store.js
@@ -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;
diff --git a/frontend/src/store/history-actions.js b/frontend/src/store/history-actions.js
new file mode 100644
index 0000000000..7a45e89d0c
--- /dev/null
+++ b/frontend/src/store/history-actions.js
@@ -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)));
+}
diff --git a/frontend/src/store/history-store.js b/frontend/src/store/history-store.js
new file mode 100644
index 0000000000..790d85ec18
--- /dev/null
+++ b/frontend/src/store/history-store.js
@@ -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;
diff --git a/frontend/src/store/index.js b/frontend/src/store/index.js
new file mode 100644
index 0000000000..2710c6cc1e
--- /dev/null
+++ b/frontend/src/store/index.js
@@ -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;
diff --git a/frontend/src/store/input-actions.js b/frontend/src/store/input-actions.js
new file mode 100644
index 0000000000..f94fe5a09f
--- /dev/null
+++ b/frontend/src/store/input-actions.js
@@ -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;
diff --git a/frontend/src/store/input-store.js b/frontend/src/store/input-store.js
new file mode 100644
index 0000000000..3379123398
--- /dev/null
+++ b/frontend/src/store/input-store.js
@@ -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;
diff --git a/frontend/src/store/metrics-actions.js b/frontend/src/store/metrics-actions.js
new file mode 100644
index 0000000000..7010d3857d
--- /dev/null
+++ b/frontend/src/store/metrics-actions.js
@@ -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)));
+}
diff --git a/frontend/src/store/metrics-store.js b/frontend/src/store/metrics-store.js
new file mode 100644
index 0000000000..f0b7a4a650
--- /dev/null
+++ b/frontend/src/store/metrics-store.js
@@ -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;
diff --git a/frontend/src/store/strategy-actions.js b/frontend/src/store/strategy-actions.js
new file mode 100644
index 0000000000..8421a1384c
--- /dev/null
+++ b/frontend/src/store/strategy-actions.js
@@ -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)));
+}
+
+
diff --git a/frontend/src/store/strategy-store.js b/frontend/src/store/strategy-store.js
new file mode 100644
index 0000000000..72900eaf22
--- /dev/null
+++ b/frontend/src/store/strategy-store.js
@@ -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;
diff --git a/frontend/src/theme/_config.scss b/frontend/src/theme/_config.scss
new file mode 100644
index 0000000000..23243086ea
--- /dev/null
+++ b/frontend/src/theme/_config.scss
@@ -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;
+ }
+ }
+}
\ No newline at end of file
diff --git a/frontend/webpack.config.js b/frontend/webpack.config.js
new file mode 100644
index 0000000000..b3d338f878
--- /dev/null
+++ b/frontend/webpack.config.js
@@ -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,
+ },
+ },
+ },
+};