Merge pull request #993 from influxdata/feature/admin

Add Admin for Users, Roles, Permissions, and Queries for InfluxDB and InfluxEnterprise
pull/10616/head
Jared Scheib 2017-03-10 18:45:11 -08:00 committed by GitHub
commit 4abf2408e3
48 changed files with 2701 additions and 202 deletions

View File

@ -6,9 +6,13 @@
3. [#968](https://github.com/influxdata/chronograf/issue/968): Fix wrong database used in dashboards
### Features
1. [#993](https://github.com/influxdata/chronograf/pull/993): Add Admin page with ability to manage Users, Roles, and Permissions for InfluxDB and Enterprise
2. [#993](https://github.com/influxdata/chronograf/pull/993): Add ability to manage Queries for InfluxDB and Enterprise
### UI Improvements
1. [#989](https://github.com/influxdata/chronograf/pull/989) Add a canned dashboard for mesos
2. [#993](https://github.com/influxdata/chronograf/pull/993): Improve multi-select dropdown
3. [#993](https://github.com/influxdata/chronograf/pull/993): Provide better error information to users
## v1.2.0-beta4 [2017-02-24]

View File

@ -66,16 +66,20 @@ func (c *RolesStore) Get(ctx context.Context, name string) (*chronograf.Role, er
// Update the Role's permissions and roles
func (c *RolesStore) Update(ctx context.Context, u *chronograf.Role) error {
if u.Permissions != nil {
perms := ToEnterprise(u.Permissions)
if err := c.Ctrl.SetRolePerms(ctx, u.Name, perms); err != nil {
return err
}
}
if u.Users != nil {
users := make([]string, len(u.Users))
for i, u := range u.Users {
users[i] = u.Name
}
return c.Ctrl.SetRoleUsers(ctx, u.Name, users)
}
return nil
}
// All is all Roles in influx

View File

@ -18,6 +18,7 @@ func (c *UserStore) Add(ctx context.Context, u *chronograf.User) (*chronograf.Us
return nil, err
}
perms := ToEnterprise(u.Permissions)
if err := c.Ctrl.SetUserPerms(ctx, u.Name, perms); err != nil {
return nil, err
}

View File

@ -46,7 +46,7 @@
'arrow-parens': 0,
'comma-dangle': [2, 'always-multiline'],
'no-cond-assign': 2,
'no-console': 2,
'no-console': ['error', {allow: ['error']}],
'no-constant-condition': 2,
'no-control-regex': 2,
'no-debugger': 2,

View File

@ -1,28 +0,0 @@
const express = require('express');
const request = require('request');
const app = express();
app.use('/', (req, res) => {
console.log(`${req.method} ${req.url}`);
const headers = {};
headers['Access-Control-Allow-Origin'] = '*';
headers['Access-Control-Allow-Methods'] = 'POST, GET, PUT, DELETE, OPTIONS';
headers['Access-Control-Allow-Credentials'] = false;
headers['Access-Control-Max-Age'] = '86400'; // 24 hours
headers['Access-Control-Allow-Headers'] = 'X-Requested-With, X-HTTP-Method-Override, Content-Type, Accept';
res.writeHead(200, headers);
if (req.method === 'OPTIONS') {
res.end();
}
else {
const url = 'http://localhost:8888' + req.url;
req.pipe(request(url)).pipe(res);
}
});
app.listen(3888, () => {
console.log('corsless proxy server now running')
});

View File

@ -17,9 +17,7 @@
"test:lint": "npm run lint; npm run test",
"test:dev": "nodemon --exec npm run test:lint",
"clean": "rm -rf build",
"storybook": "start-storybook -p 6006",
"build-storybook": "build-storybook",
"proxy": "node ./corsless"
"storybook": "node ./storybook"
},
"author": "",
"eslintConfig": {

View File

@ -0,0 +1,292 @@
import reducer from 'src/admin/reducers/admin'
import {
addUser,
addRole,
syncUser,
syncRole,
editUser,
editRole,
loadRoles,
loadPermissions,
deleteRole,
deleteUser,
filterRoles,
filterUsers,
} from 'src/admin/actions'
let state = undefined
// Users
const u1 = {
name: 'acidburn',
roles: [
{
name: 'hax0r',
permissions: {
allowed: [
'ViewAdmin',
'ViewChronograf',
'CreateDatabase',
'CreateUserAndRole',
'AddRemoveNode',
'DropDatabase',
'DropData',
'ReadData',
'WriteData',
'Rebalance',
'ManageShard',
'ManageContinuousQuery',
'ManageQuery',
'ManageSubscription',
'Monitor',
'CopyShard',
'KapacitorAPI',
'KapacitorConfigAPI'
],
scope: 'all',
},
}
],
permissions: [],
links: {self: '/chronograf/v1/sources/1/users/acidburn'},
}
const u2 = {
name: 'zerocool',
roles: [],
permissions: [],
links: {self: '/chronograf/v1/sources/1/users/zerocool'},
}
const users = [u1, u2]
const newDefaultUser = {
name: '',
password: '',
roles: [],
permissions: [],
links: {self: ''},
isNew: true,
}
// Roles
const r1 = {
name: 'hax0r',
users: [],
permissions: [
{
allowed: [
'ViewAdmin',
'ViewChronograf',
'CreateDatabase',
'CreateUserAndRole',
'AddRemoveNode',
'DropDatabase',
'DropData',
'ReadData',
'WriteData',
'Rebalance',
'ManageShard',
'ManageContinuousQuery',
'ManageQuery',
'ManageSubscription',
'Monitor',
'CopyShard',
'KapacitorAPI',
'KapacitorConfigAPI'
],
scope: 'all',
},
],
links: {self: '/chronograf/v1/sources/1/roles/hax0r'}
}
const r2 = {
name: 'l33tus3r',
links: {self: '/chronograf/v1/sources/1/roles/l33tus3r'}
}
const roles = [r1, r2]
const newDefaultRole = {
name: '',
users: [],
permissions: [],
links: {self: ''},
isNew: true,
}
// Permissions
const global = {scope: 'all', allowed: ['p1', 'p2']}
const scoped = {scope: 'db1', allowed: ['p1', 'p3']}
const permissions = [global, scoped]
describe('Admin.Reducers', () => {
it('it can add a user', () => {
state = {
users: [
u1,
]
}
const actual = reducer(state, addUser())
const expected = {
users: [
{...newDefaultUser, isEditing: true},
u1,
],
}
expect(actual.users).to.deep.equal(expected.users)
})
it('it can sync a stale user', () => {
const staleUser = {...u1, roles: []}
state = {users: [u2, staleUser]}
const actual = reducer(state, syncUser(staleUser, u1))
const expected = {
users: [u2, u1],
}
expect(actual.users).to.deep.equal(expected.users)
})
it('it can edit a user', () => {
const updates = {name: 'onecool'}
state = {
users: [u2, u1],
}
const actual = reducer(state, editUser(u2, updates))
const expected = {
users: [{...u2, ...updates}, u1]
}
expect(actual.users).to.deep.equal(expected.users)
})
it('it can add a role', () => {
state = {
roles: [
r1,
]
}
const actual = reducer(state, addRole())
const expected = {
roles: [
{...newDefaultRole, isEditing: true},
r1,
],
}
expect(actual.roles).to.deep.equal(expected.roles)
})
it('it can sync a stale role', () => {
const staleRole = {...r1, permissions: []}
state = {roles: [r2, staleRole]}
const actual = reducer(state, syncRole(staleRole, r1))
const expected = {
roles: [r2, r1],
}
expect(actual.roles).to.deep.equal(expected.roles)
})
it('it can edit a role', () => {
const updates = {name: 'onecool'}
state = {
roles: [r2, r1],
}
const actual = reducer(state, editRole(r2, updates))
const expected = {
roles: [{...r2, ...updates}, r1]
}
expect(actual.roles).to.deep.equal(expected.roles)
})
it('it can load the roles', () => {
const actual = reducer(state, loadRoles({roles}))
const expected = {
roles,
}
expect(actual.roles).to.deep.equal(expected.roles)
})
it('it can delete a role', () => {
state = {
roles: [
r1,
]
}
const actual = reducer(state, deleteRole(r1))
const expected = {
roles: [],
}
expect(actual.roles).to.deep.equal(expected.roles)
})
it('it can delete a user', () => {
state = {
users: [
u1,
]
}
const actual = reducer(state, deleteUser(u1))
const expected = {
users: [],
}
expect(actual.users).to.deep.equal(expected.users)
})
it('can filter roles w/ "x" text', () => {
state = {
roles,
}
const text = 'x'
const actual = reducer(state, filterRoles(text))
const expected = {
roles: [
{...r1, hidden: false},
{...r2, hidden: true},
],
}
expect(actual.roles).to.deep.equal(expected.roles)
})
it('can filter users w/ "zero" text', () => {
state = {
users,
}
const text = 'zero'
const actual = reducer(state, filterUsers(text))
const expected = {
users: [
{...u1, hidden: true},
{...u2, hidden: false},
],
}
expect(actual.users).to.deep.equal(expected.users)
})
// Permissions
it('it can load the permissions', () => {
const actual = reducer(state, loadPermissions({permissions}))
const expected = {
permissions,
}
expect(actual.permissions).to.deep.equal(expected.permissions)
})
})

View File

@ -0,0 +1,230 @@
import {
getUsers as getUsersAJAX,
getRoles as getRolesAJAX,
getPermissions as getPermissionsAJAX,
createUser as createUserAJAX,
createRole as createRoleAJAX,
deleteUser as deleteUserAJAX,
deleteRole as deleteRoleAJAX,
updateRole as updateRoleAJAX,
updateUser as updateUserAJAX,
} from 'src/admin/apis'
import {killQuery as killQueryProxy} from 'shared/apis/metaQuery'
import {publishNotification} from 'src/shared/actions/notifications';
import {ADMIN_NOTIFICATION_DELAY} from 'src/admin/constants'
export const loadUsers = ({users}) => ({
type: 'LOAD_USERS',
payload: {
users,
},
})
export const loadRoles = ({roles}) => ({
type: 'LOAD_ROLES',
payload: {
roles,
},
})
export const loadPermissions = ({permissions}) => ({
type: 'LOAD_PERMISSIONS',
payload: {
permissions,
},
})
export const addUser = () => ({
type: 'ADD_USER',
})
export const addRole = () => ({
type: 'ADD_ROLE',
})
export const syncUser = (staleUser, syncedUser) => ({
type: 'SYNC_USER',
payload: {
staleUser,
syncedUser,
},
})
export const syncRole = (staleRole, syncedRole) => ({
type: 'SYNC_ROLE',
payload: {
staleRole,
syncedRole,
},
})
export const editUser = (user, updates) => ({
type: 'EDIT_USER',
payload: {
user,
updates,
},
})
export const editRole = (role, updates) => ({
type: 'EDIT_ROLE',
payload: {
role,
updates,
},
})
export const killQuery = (queryID) => ({
type: 'KILL_QUERY',
payload: {
queryID,
},
})
export const setQueryToKill = (queryIDToKill) => ({
type: 'SET_QUERY_TO_KILL',
payload: {
queryIDToKill,
},
})
export const loadQueries = (queries) => ({
type: 'LOAD_QUERIES',
payload: {
queries,
},
})
export const deleteUser = (user) => ({
type: 'DELETE_USER',
payload: {
user,
},
})
export const deleteRole = (role) => ({
type: 'DELETE_ROLE',
payload: {
role,
},
})
export const filterUsers = (text) => ({
type: 'FILTER_USERS',
payload: {
text,
},
})
export const filterRoles = (text) => ({
type: 'FILTER_ROLES',
payload: {
text,
},
})
// async actions
export const loadUsersAsync = (url) => async (dispatch) => {
const {data} = await getUsersAJAX(url)
dispatch(loadUsers(data))
}
export const loadRolesAsync = (url) => async (dispatch) => {
const {data} = await getRolesAJAX(url)
dispatch(loadRoles(data))
}
export const loadPermissionsAsync = (url) => async (dispatch) => {
const {data} = await getPermissionsAJAX(url)
dispatch(loadPermissions(data))
}
export const createUserAsync = (url, user) => async (dispatch) => {
try {
const {data} = await createUserAJAX(url, user)
dispatch(publishNotification('success', 'User created successfully'))
dispatch(syncUser(user, data))
} catch (error) {
// undo optimistic update
dispatch(publishNotification('error', `Failed to create user: ${error.data.message}`))
setTimeout(() => dispatch(deleteUser(user)), ADMIN_NOTIFICATION_DELAY)
}
}
export const createRoleAsync = (url, role) => async (dispatch) => {
try {
const {data} = await createRoleAJAX(url, role)
dispatch(publishNotification('success', 'Role created successfully'))
dispatch(syncRole(role, data))
} catch (error) {
// undo optimistic update
dispatch(publishNotification('error', `Failed to create role: ${error.data.message}`))
setTimeout(() => dispatch(deleteRole(role)), ADMIN_NOTIFICATION_DELAY)
}
}
export const killQueryAsync = (source, queryID) => (dispatch) => {
// optimistic update
dispatch(killQuery(queryID))
dispatch(setQueryToKill(null))
// kill query on server
killQueryProxy(source, queryID)
}
export const deleteRoleAsync = (role, addFlashMessage) => (dispatch) => {
// optimistic update
dispatch(deleteRole(role))
// delete role on server
deleteRoleAJAX(role.links.self, addFlashMessage, role.name)
}
export const deleteUserAsync = (user, addFlashMessage) => (dispatch) => {
// optimistic update
dispatch(deleteUser(user))
// delete user on server
deleteUserAJAX(user.links.self, addFlashMessage, user.name)
}
export const updateRoleUsersAsync = (role, users) => async (dispatch) => {
try {
const {data} = await updateRoleAJAX(role.links.self, users, role.permissions)
dispatch(publishNotification('success', 'Role users updated'))
dispatch(syncRole(role, data))
} catch (error) {
dispatch(publishNotification('error', `Failed to update role: ${error.data.message}`))
}
}
export const updateRolePermissionsAsync = (role, permissions) => async (dispatch) => {
try {
const {data} = await updateRoleAJAX(role.links.self, role.users, permissions)
dispatch(publishNotification('success', 'Role permissions updated'))
dispatch(syncRole(role, data))
} catch (error) {
dispatch(publishNotification('error', `Failed to updated role: ${error.data.message}`))
}
}
export const updateUserPermissionsAsync = (user, permissions) => async (dispatch) => {
try {
const {data} = await updateUserAJAX(user.links.self, user.roles, permissions)
dispatch(publishNotification('success', 'User permissions updated'))
dispatch(syncUser(user, data))
} catch (error) {
dispatch(publishNotification('error', `Failed to updated user: ${error.data.message}`))
}
}
export const updateUserRolesAsync = (user, roles) => async (dispatch) => {
try {
const {data} = await updateUserAJAX(user.links.self, roles, user.permissions)
dispatch(publishNotification('success', 'User roles updated'))
dispatch(syncUser(user, data))
} catch (error) {
dispatch(publishNotification('error', `Failed to updated user: ${error.data.message}`))
}
}

133
ui/src/admin/apis/index.js Normal file
View File

@ -0,0 +1,133 @@
import AJAX from 'src/utils/ajax'
export const getUsers = async (url) => {
try {
return await AJAX({
method: 'GET',
url,
})
} catch (error) {
console.error(error)
throw error
}
}
export const getRoles = async (url) => {
try {
return await AJAX({
method: 'GET',
url,
})
} catch (error) {
console.error(error)
throw error
}
}
export const getPermissions = async (url) => {
try {
return await AJAX({
method: 'GET',
url,
})
} catch (error) {
console.error(error)
throw error
}
}
export const createUser = async (url, user) => {
try {
return await AJAX({
method: 'POST',
url,
data: user,
})
} catch (error) {
throw error
}
}
export const createRole = async (url, role) => {
try {
return await AJAX({
method: 'POST',
url,
data: role,
})
} catch (error) {
throw error
}
}
export const deleteRole = async (url, addFlashMessage, rolename) => {
try {
const response = await AJAX({
method: 'DELETE',
url,
})
addFlashMessage({
type: 'success',
text: `${rolename} successfully deleted.`,
})
return response
} catch (error) {
console.error(error)
addFlashMessage({
type: 'error',
text: `Error deleting: ${rolename}.`,
})
}
}
export const deleteUser = async (url, addFlashMessage, username) => {
try {
const response = await AJAX({
method: 'DELETE',
url,
})
addFlashMessage({
type: 'success',
text: `${username} successfully deleted.`,
})
return response
} catch (error) {
console.error(error)
addFlashMessage({
type: 'error',
text: `Error deleting: ${username}.`,
})
}
}
export const updateRole = async (url, users, permissions) => {
try {
return await AJAX({
method: 'PATCH',
url,
data: {
users,
permissions,
},
})
} catch (error) {
console.error(error)
throw error
}
}
export const updateUser = async (url, roles, permissions) => {
try {
return await AJAX({
method: 'PATCH',
url,
data: {
roles,
permissions,
},
})
} catch (error) {
console.error(error)
throw error
}
}

View File

@ -0,0 +1,135 @@
import React, {PropTypes} from 'react'
import {Tab, Tabs, TabPanel, TabPanels, TabList} from 'src/shared/components/Tabs';
import UsersTable from 'src/admin/components/UsersTable'
import RolesTable from 'src/admin/components/RolesTable'
import QueriesPage from 'src/admin/containers/QueriesPage'
const AdminTabs = ({
users,
roles,
permissions,
source,
hasRoles,
isEditingUsers,
isEditingRoles,
onClickCreate,
onEditUser,
onSaveUser,
onCancelEditUser,
onEditRole,
onSaveRole,
onCancelEditRole,
onDeleteRole,
onDeleteUser,
onFilterRoles,
onFilterUsers,
onUpdateRoleUsers,
onUpdateRolePermissions,
onUpdateUserRoles,
onUpdateUserPermissions,
}) => {
let tabs = [
{
type: 'Users',
component: (
<UsersTable
users={users}
allRoles={roles}
hasRoles={hasRoles}
permissions={permissions}
isEditing={isEditingUsers}
onSave={onSaveUser}
onCancel={onCancelEditUser}
onClickCreate={onClickCreate}
onEdit={onEditUser}
onDelete={onDeleteUser}
onFilter={onFilterUsers}
onUpdatePermissions={onUpdateUserPermissions}
onUpdateRoles={onUpdateUserRoles}
/>
),
},
{
type: 'Roles',
component: (
<RolesTable
roles={roles}
allUsers={users}
permissions={permissions}
isEditing={isEditingRoles}
onClickCreate={onClickCreate}
onEdit={onEditRole}
onSave={onSaveRole}
onCancel={onCancelEditRole}
onDelete={onDeleteRole}
onFilter={onFilterRoles}
onUpdateRoleUsers={onUpdateRoleUsers}
onUpdateRolePermissions={onUpdateRolePermissions}
/>
),
},
{
type: 'Queries',
component: (<QueriesPage source={source} />),
},
]
if (!hasRoles) {
tabs = tabs.filter(t => t.type !== 'Roles')
}
return (
<Tabs className="row">
<TabList customClass="col-md-3 admin-tabs">
{
tabs.map((t, i) => (<Tab key={tabs[i].type}>{tabs[i].type}</Tab>))
}
</TabList>
<TabPanels customClass="col-md-9">
{
tabs.map((t, i) => (<TabPanel key={tabs[i].type}>{t.component}</TabPanel>))
}
</TabPanels>
</Tabs>
)
}
const {
arrayOf,
bool,
func,
shape,
string,
} = PropTypes
AdminTabs.propTypes = {
users: arrayOf(shape({
name: string.isRequired,
roles: arrayOf(shape({
name: string,
})),
})),
roles: arrayOf(shape()),
source: shape(),
permissions: arrayOf(string),
isEditingUsers: bool,
isEditingRoles: bool,
onClickCreate: func.isRequired,
onEditUser: func.isRequired,
onSaveUser: func.isRequired,
onCancelEditUser: func.isRequired,
onEditRole: func.isRequired,
onSaveRole: func.isRequired,
onCancelEditRole: func.isRequired,
onDeleteRole: func.isRequired,
onDeleteUser: func.isRequired,
onFilterRoles: func.isRequired,
onFilterUsers: func.isRequired,
onUpdateRoleUsers: func.isRequired,
onUpdateRolePermissions: func.isRequired,
hasRoles: bool.isRequired,
onUpdateUserPermissions: func,
onUpdateUserRoles: func,
}
export default AdminTabs

View File

@ -0,0 +1,31 @@
import React, {PropTypes} from 'react'
const ConfirmButtons = ({onConfirm, item, onCancel}) => (
<div>
<button
className="btn btn-xs btn-info"
onClick={() => onCancel(item)}
>
<span className="icon remove"></span>
</button>
<button
className="btn btn-xs btn-success"
onClick={() => onConfirm(item)}
>
<span className="icon checkmark"></span>
</button>
</div>
)
const {
func,
shape,
} = PropTypes
ConfirmButtons.propTypes = {
onConfirm: func.isRequired,
item: shape({}).isRequired,
onCancel: func.isRequired,
}
export default ConfirmButtons

View File

@ -0,0 +1,71 @@
import React, {PropTypes, Component} from 'react'
import OnClickOutside from 'shared/components/OnClickOutside'
import ConfirmButtons from 'src/admin/components/ConfirmButtons'
const DeleteButton = ({onConfirm}) => (
<button
className="btn btn-xs btn-danger admin-table--delete"
onClick={onConfirm}
>
Delete
</button>
)
class DeleteRow extends Component {
constructor(props) {
super(props)
this.state = {
isConfirmed: false,
}
this.handleConfirm = ::this.handleConfirm
this.handleCancel = ::this.handleCancel
}
handleConfirm() {
this.setState({isConfirmed: true})
}
handleCancel() {
this.setState({isConfirmed: false})
}
handleClickOutside() {
this.setState({isConfirmed: false})
}
render() {
const {onDelete, item} = this.props
const {isConfirmed} = this.state
if (isConfirmed) {
return (
<ConfirmButtons
onConfirm={onDelete}
item={item}
onCancel={this.handleCancel}
/>
)
}
return (
<DeleteButton onConfirm={this.handleConfirm} />
)
}
}
const {
func,
shape,
} = PropTypes
DeleteButton.propTypes = {
onConfirm: func.isRequired,
}
DeleteRow.propTypes = {
item: shape({}),
onDelete: func.isRequired,
}
export default OnClickOutside(DeleteRow)

View File

@ -0,0 +1,19 @@
import React, {PropTypes} from 'react'
const EmptyRow = ({tableName}) => (
<tr className="table-empty-state">
<th colSpan="5">
<p>You don&#39;t have any {tableName},<br/>why not create one?</p>
</th>
</tr>
)
const {
string,
} = PropTypes
EmptyRow.propTypes = {
tableName: string.isRequired,
}
export default EmptyRow

View File

@ -0,0 +1,59 @@
import React, {Component, PropTypes} from 'react'
class FilterBar extends Component {
constructor(props) {
super(props)
this.state = {
filterText: '',
}
this.handleText = ::this.handleText
}
handleText(e) {
this.setState(
{filterText: e.target.value},
this.props.onFilter(e.target.value)
)
}
componentWillUnmount() {
this.props.onFilter('')
}
render() {
const {type, isEditing, onClickCreate} = this.props
return (
<div className="panel-heading u-flex u-ai-center u-jc-space-between">
<div className="users__search-widget input-group admin__search-widget">
<input
type="text"
className="form-control"
placeholder={`Filter ${type}...`}
value={this.state.filterText}
onChange={this.handleText}
/>
<div className="input-group-addon">
<span className="icon search" aria-hidden="true"></span>
</div>
</div>
<button className="btn btn-primary" disabled={isEditing} onClick={() => onClickCreate(type)}>Create {type.substring(0, type.length - 1)}</button>
</div>
)
}
}
const {
bool,
func,
string,
} = PropTypes
FilterBar.propTypes = {
onFilter: func.isRequired,
type: string,
isEditing: bool,
onClickCreate: func,
}
export default FilterBar

View File

@ -0,0 +1,67 @@
import React, {PropTypes} from 'react'
const QueriesTable = ({queries, onKillQuery, onConfirm}) => (
<div>
<div className="panel panel-minimal">
<div className="panel-body">
<table className="table v-center">
<thead>
<tr>
<th>Database</th>
<th>Query</th>
<th>Running</th>
<th></th>
</tr>
</thead>
<tbody>
{queries.map((q) => {
return (
<tr key={q.id}>
<td>{q.database}</td>
<td><code>{q.query}</code></td>
<td>{q.duration}</td>
<td className="text-right">
<button className="btn btn-xs btn-link-danger" onClick={onKillQuery} data-toggle="modal" data-query-id={q.id} data-target="#killModal">
Kill
</button>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
<div className="modal fade" id="killModal" tabIndex="-1" role="dialog" aria-labelledby="myModalLabel">
<div className="modal-dialog" role="document">
<div className="modal-content">
<div className="modal-header">
<button type="button" className="close" data-dismiss="modal" aria-label="Close">
<span aria-hidden="true">×</span>
</button>
<h4 className="modal-title" id="myModalLabel">Are you sure you want to kill this query?</h4>
</div>
<div className="modal-footer">
<button type="button" className="btn btn-default" data-dismiss="modal">No</button>
<button type="button" className="btn btn-danger" data-dismiss="modal" onClick={onConfirm}>Yes, kill it!</button>
</div>
</div>
</div>
</div>
</div>
)
const {
arrayOf,
func,
shape,
} = PropTypes
QueriesTable.propTypes = {
queries: arrayOf(shape()),
onConfirm: func,
onKillQuery: func,
}
export default QueriesTable

View File

@ -0,0 +1,59 @@
import React, {Component, PropTypes} from 'react'
class RoleEditingRow extends Component {
constructor(props) {
super(props)
this.handleKeyPress = ::this.handleKeyPress
this.handleEdit = ::this.handleEdit
}
handleKeyPress(role) {
return (e) => {
if (e.key === 'Enter') {
this.props.onSave(role)
}
}
}
handleEdit(role) {
return (e) => {
this.props.onEdit(role, {[e.target.name]: e.target.value})
}
}
render() {
const {role} = this.props
return (
<td>
<div className="admin-table--edit-cell">
<input
className="form-control"
name="name"
type="text"
value={role.name || ''}
placeholder="role name"
onChange={this.handleEdit(role)}
onKeyPress={this.handleKeyPress(role)}
autoFocus={true}
/>
</div>
</td>
)
}
}
const {
bool,
func,
shape,
} = PropTypes
RoleEditingRow.propTypes = {
role: shape().isRequired,
isNew: bool,
onEdit: func.isRequired,
onSave: func.isRequired,
}
export default RoleEditingRow

View File

@ -0,0 +1,109 @@
import React, {PropTypes} from 'react'
import _ from 'lodash'
import RoleEditingRow from 'src/admin/components/RoleEditingRow'
import MultiSelectDropdown from 'shared/components/MultiSelectDropdown'
import ConfirmButtons from 'src/admin/components/ConfirmButtons'
import DeleteRow from 'src/admin/components/DeleteRow'
const RoleRow = ({
role: {name, permissions, users},
role,
allUsers,
allPermissions,
isNew,
isEditing,
onEdit,
onSave,
onCancel,
onDelete,
onUpdateRoleUsers,
onUpdateRolePermissions,
}) => {
const handleUpdateUsers = (u) => {
onUpdateRoleUsers(role, u.map((n) => ({name: n})))
}
const handleUpdatePermissions = (allowed) => {
onUpdateRolePermissions(role, [{scope: 'all', allowed}])
}
const perms = _.get(permissions, ['0', 'allowed'], [])
if (isEditing) {
return (
<tr className="admin-table--edit-row">
<RoleEditingRow role={role} onEdit={onEdit} onSave={onSave} isNew={isNew} />
<td></td>
<td></td>
<td className="text-right" style={{width: "85px"}}>
<ConfirmButtons item={role} onConfirm={onSave} onCancel={onCancel} />
</td>
</tr>
)
}
return (
<tr>
<td>{name}</td>
<td>
{
allPermissions && allPermissions.length ?
<MultiSelectDropdown
items={allPermissions}
selectedItems={perms}
label={perms.length ? '' : 'Select Permissions'}
onApply={handleUpdatePermissions}
/> : null
}
</td>
<td>
{
allUsers && allUsers.length ?
<MultiSelectDropdown
items={allUsers.map((u) => u.name)}
selectedItems={users === undefined ? [] : users.map((u) => u.name)}
label={users && users.length ? '' : 'Select Users'}
onApply={handleUpdateUsers}
/> : null
}
</td>
<td className="text-right" style={{width: "85px"}}>
<DeleteRow onDelete={onDelete} item={role} />
</td>
</tr>
)
}
const {
arrayOf,
bool,
func,
shape,
string,
} = PropTypes
RoleRow.propTypes = {
role: shape({
name: string,
permissions: arrayOf(shape({
name: string,
})),
users: arrayOf(shape({
name: string,
})),
}).isRequired,
isNew: bool,
isEditing: bool,
onCancel: func,
onEdit: func,
onSave: func,
onDelete: func.isRequired,
allUsers: arrayOf(shape()),
allPermissions: arrayOf(string),
onUpdateRoleUsers: func.isRequired,
onUpdateRolePermissions: func.isRequired,
}
export default RoleRow

View File

@ -0,0 +1,90 @@
import React, {PropTypes} from 'react'
import RoleRow from 'src/admin/components/RoleRow'
import EmptyRow from 'src/admin/components/EmptyRow'
import FilterBar from 'src/admin/components/FilterBar'
const RolesTable = ({
roles,
allUsers,
permissions,
isEditing,
onClickCreate,
onEdit,
onSave,
onCancel,
onDelete,
onFilter,
onUpdateRoleUsers,
onUpdateRolePermissions,
}) => (
<div className="panel panel-info">
<FilterBar type="roles" onFilter={onFilter} isEditing={isEditing} onClickCreate={onClickCreate} />
<div className="panel-body">
<table className="table v-center admin-table">
<thead>
<tr>
<th>Name</th>
<th>Permissions</th>
<th>Users</th>
<th></th>
</tr>
</thead>
<tbody>
{
roles.length ?
roles.filter(r => !r.hidden).map((role) =>
<RoleRow
key={role.links.self}
allUsers={allUsers}
allPermissions={permissions}
role={role}
onEdit={onEdit}
onSave={onSave}
onCancel={onCancel}
onDelete={onDelete}
onUpdateRoleUsers={onUpdateRoleUsers}
onUpdateRolePermissions={onUpdateRolePermissions}
isEditing={role.isEditing}
isNew={role.isNew}
/>
) : <EmptyRow tableName={'Roles'} />
}
</tbody>
</table>
</div>
</div>
)
const {
arrayOf,
bool,
func,
shape,
string,
} = PropTypes
RolesTable.propTypes = {
roles: arrayOf(shape({
name: string.isRequired,
permissions: arrayOf(shape({
name: string,
scope: string.isRequired,
})),
users: arrayOf(shape({
name: string,
})),
})),
isEditing: bool,
onClickCreate: func.isRequired,
onEdit: func.isRequired,
onSave: func.isRequired,
onCancel: func.isRequired,
onDelete: func.isRequired,
onFilter: func,
allUsers: arrayOf(shape()),
permissions: arrayOf(string),
onUpdateRoleUsers: func.isRequired,
onUpdateRolePermissions: func.isRequired,
}
export default RolesTable

View File

@ -0,0 +1,72 @@
import React, {Component, PropTypes} from 'react'
class UserEditingRow extends Component {
constructor(props) {
super(props)
this.handleKeyPress = ::this.handleKeyPress
this.handleEdit = ::this.handleEdit
}
handleKeyPress(user) {
return (e) => {
if (e.key === 'Enter') {
this.props.onSave(user)
}
}
}
handleEdit(user) {
return (e) => {
this.props.onEdit(user, {[e.target.name]: e.target.value})
}
}
render() {
const {user, isNew} = this.props
return (
<td>
<div className="admin-table--edit-cell">
<input
className="form-control"
name="name"
type="text"
value={user.name || ''}
placeholder="Username"
onChange={this.handleEdit(user)}
onKeyPress={this.handleKeyPress(user)}
autoFocus={true}
/>
{
isNew ?
<input
className="form-control"
name="password"
type="text"
value={user.password || ''}
placeholder="Password"
onChange={this.handleEdit(user)}
onKeyPress={this.handleKeyPress(user)}
/> :
null
}
</div>
</td>
)
}
}
const {
bool,
func,
shape,
} = PropTypes
UserEditingRow.propTypes = {
user: shape().isRequired,
isNew: bool,
onEdit: func.isRequired,
onSave: func.isRequired,
}
export default UserEditingRow

View File

@ -0,0 +1,110 @@
import React, {PropTypes} from 'react'
import _ from 'lodash'
import UserEditingRow from 'src/admin/components/UserEditingRow'
import MultiSelectDropdown from 'shared/components/MultiSelectDropdown'
import ConfirmButtons from 'src/admin/components/ConfirmButtons'
import DeleteRow from 'src/admin/components/DeleteRow'
const UserRow = ({
user: {name, roles, permissions},
user,
allRoles,
allPermissions,
hasRoles,
isNew,
isEditing,
onEdit,
onSave,
onCancel,
onDelete,
onUpdatePermissions,
onUpdateRoles,
}) => {
const handleUpdatePermissions = (allowed) => {
onUpdatePermissions(user, [{scope: 'all', allowed}])
}
const handleUpdateRoles = (roleNames) => {
onUpdateRoles(user, allRoles.filter(r => roleNames.find(rn => rn === r.name)))
}
if (isEditing) {
return (
<tr className="admin-table--edit-row">
<UserEditingRow user={user} onEdit={onEdit} onSave={onSave} isNew={isNew} />
{hasRoles ? <td></td> : null}
<td></td>
<td className="text-right" style={{width: "85px"}}>
<ConfirmButtons item={user} onConfirm={onSave} onCancel={onCancel} />
</td>
</tr>
)
}
return (
<tr>
<td>{name}</td>
{
hasRoles ?
<td>
<MultiSelectDropdown
items={allRoles.map((r) => r.name)}
selectedItems={roles ? roles.map((r) => r.name) : []/* TODO remove check when server returns empty list */}
label={roles && roles.length ? '' : 'Select Roles'}
onApply={handleUpdateRoles}
/>
</td> :
null
}
<td>
{
allPermissions && allPermissions.length ?
<MultiSelectDropdown
items={allPermissions}
selectedItems={_.get(permissions, ['0', 'allowed'], [])}
label={permissions && permissions.length ? '' : 'Select Permissions'}
onApply={handleUpdatePermissions}
/> : null
}
</td>
<td className="text-right" style={{width: "85px"}}>
<DeleteRow onDelete={onDelete} item={user} />
</td>
</tr>
)
}
const {
arrayOf,
bool,
func,
shape,
string,
} = PropTypes
UserRow.propTypes = {
user: shape({
name: string,
roles: arrayOf(shape({
name: string,
})),
permissions: arrayOf(shape({
name: string,
})),
}).isRequired,
allRoles: arrayOf(shape()),
allPermissions: arrayOf(string),
hasRoles: bool,
isNew: bool,
isEditing: bool,
onCancel: func,
onEdit: func,
onSave: func,
onDelete: func.isRequired,
onUpdatePermissions: func,
onUpdateRoles: func,
}
export default UserRow

View File

@ -0,0 +1,94 @@
import React, {PropTypes} from 'react'
import UserRow from 'src/admin/components/UserRow'
import EmptyRow from 'src/admin/components/EmptyRow'
import FilterBar from 'src/admin/components/FilterBar'
const UsersTable = ({
users,
allRoles,
hasRoles,
permissions,
isEditing,
onClickCreate,
onEdit,
onSave,
onCancel,
onDelete,
onFilter,
onUpdatePermissions,
onUpdateRoles,
}) => (
<div className="panel panel-info">
<FilterBar type="users" onFilter={onFilter} isEditing={isEditing} onClickCreate={onClickCreate} />
<div className="panel-body">
<table className="table v-center admin-table">
<thead>
<tr>
<th>User</th>
{hasRoles && <th>Roles</th>}
<th>Permissions</th>
<th></th>
</tr>
</thead>
<tbody>
{
users.length ?
users.filter(u => !u.hidden).map(user =>
<UserRow
key={user.links.self}
user={user}
onEdit={onEdit}
onSave={onSave}
onCancel={onCancel}
onDelete={onDelete}
isEditing={user.isEditing}
isNew={user.isNew}
allRoles={allRoles}
hasRoles={hasRoles}
allPermissions={permissions}
onUpdatePermissions={onUpdatePermissions}
onUpdateRoles={onUpdateRoles}
/>) :
<EmptyRow tableName={'Users'} />
}
</tbody>
</table>
</div>
</div>
)
const {
arrayOf,
bool,
func,
shape,
string,
} = PropTypes
UsersTable.propTypes = {
users: arrayOf(shape({
name: string.isRequired,
roles: arrayOf(shape({
name: string,
})),
permissions: arrayOf(shape({
name: string,
scope: string.isRequired,
})),
})),
isEditing: bool,
onClickCreate: func.isRequired,
onEdit: func.isRequired,
onSave: func.isRequired,
onCancel: func.isRequired,
onDelete: func.isRequired,
onFilter: func,
allRoles: arrayOf(shape()),
permissions: arrayOf(string),
hasRoles: bool.isRequired,
onUpdatePermissions: func,
onUpdateRoles: func,
}
export default UsersTable

View File

@ -0,0 +1,11 @@
export const TIMES = [
{test: /ns/, magnitude: 0},
{test: /µs/, magnitude: 1},
{test: /u/, magnitude: 1},
{test: /^\d*ms/, magnitude: 2},
{test: /^\d*s/, magnitude: 3},
{test: /^\d*m\d*s/, magnitude: 4},
{test: /^\d*h\d*m\d*s/, magnitude: 5},
];
export const ADMIN_NOTIFICATION_DELAY = 1500 // milliseconds

View File

@ -0,0 +1,263 @@
import React, {Component, PropTypes} from 'react'
import {connect} from 'react-redux'
import {bindActionCreators} from 'redux'
import {
loadUsersAsync,
loadRolesAsync,
loadPermissionsAsync,
addUser,
addRole,
deleteUser, // TODO rename to removeUser throughout + tests
deleteRole, // TODO rename to removeUser throughout + tests
editUser,
editRole,
createUserAsync,
createRoleAsync,
deleteUserAsync,
deleteRoleAsync,
updateRoleUsersAsync,
updateRolePermissionsAsync,
updateUserPermissionsAsync,
updateUserRolesAsync,
filterUsers as filterUsersAction,
filterRoles as filterRolesAction,
} from 'src/admin/actions'
import AdminTabs from 'src/admin/components/AdminTabs'
const isValidUser = (user) => {
const minLen = 3
return (user.name.length >= minLen && user.password.length >= minLen)
}
const isValidRole = (role) => {
const minLen = 3
return (role.name.length >= minLen)
}
class AdminPage extends Component {
constructor(props) {
super(props)
this.handleClickCreate = ::this.handleClickCreate
this.handleEditUser = ::this.handleEditUser
this.handleEditRole = ::this.handleEditRole
this.handleSaveUser = ::this.handleSaveUser
this.handleSaveRole = ::this.handleSaveRole
this.handleCancelEditUser = ::this.handleCancelEditUser
this.handleCancelEditRole = ::this.handleCancelEditRole
this.handleDeleteRole = ::this.handleDeleteRole
this.handleDeleteUser = ::this.handleDeleteUser
this.handleUpdateRoleUsers = ::this.handleUpdateRoleUsers
this.handleUpdateRolePermissions = ::this.handleUpdateRolePermissions
this.handleUpdateUserPermissions = ::this.handleUpdateUserPermissions
this.handleUpdateUserRoles = ::this.handleUpdateUserRoles
}
componentDidMount() {
const {source, loadUsers, loadRoles, loadPermissions} = this.props
loadUsers(source.links.users)
loadPermissions(source.links.permissions)
if (source.links.roles) {
loadRoles(source.links.roles)
}
}
handleClickCreate(type) {
if (type === 'users') {
this.props.addUser()
} else if (type === 'roles') {
this.props.addRole()
}
}
handleEditUser(user, updates) {
this.props.editUser(user, updates)
}
handleEditRole(role, updates) {
this.props.editRole(role, updates)
}
async handleSaveUser(user) {
if (!isValidUser(user)) {
this.props.addFlashMessage({type: 'error', text: 'Username and/or password too short'})
return
}
if (user.isNew) {
this.props.createUser(this.props.source.links.users, user)
} else {
// TODO update user
}
}
async handleSaveRole(role) {
if (!isValidRole(role)) {
this.props.addFlashMessage({type: 'error', text: 'Role name too short'})
return
}
if (role.isNew) {
this.props.createRole(this.props.source.links.roles, role)
} else {
// TODO update role
// console.log('update')
}
}
handleCancelEditUser(user) {
this.props.removeUser(user)
}
handleCancelEditRole(role) {
this.props.removeRole(role)
}
handleDeleteRole(role) {
this.props.deleteRole(role, this.props.addFlashMessage)
}
handleDeleteUser(user) {
this.props.deleteUser(user, this.props.addFlashMessage)
}
handleUpdateRoleUsers(role, users) {
this.props.updateRoleUsers(role, users)
}
handleUpdateRolePermissions(role, permissions) {
this.props.updateRolePermissions(role, permissions)
}
handleUpdateUserPermissions(user, permissions) {
this.props.updateUserPermissions(user, permissions)
}
handleUpdateUserRoles(user, roles) {
this.props.updateUserRoles(user, roles)
}
render() {
const {users, roles, source, permissions, filterUsers, filterRoles} = this.props
const hasRoles = !!source.links.roles
const globalPermissions = permissions.find((p) => p.scope === 'all')
const allowed = globalPermissions ? globalPermissions.allowed : []
return (
<div className="page">
<div className="page-header">
<div className="page-header__container">
<div className="page-header__left">
<h1>
Admin
</h1>
</div>
</div>
</div>
<div className="page-contents">
<div className="container-fluid">
<div className="row">
{
users ?
<AdminTabs
users={users}
roles={roles}
source={source}
permissions={allowed}
hasRoles={hasRoles}
isEditingUsers={users.some(u => u.isEditing)}
isEditingRoles={roles.some(r => r.isEditing)}
onClickCreate={this.handleClickCreate}
onEditUser={this.handleEditUser}
onEditRole={this.handleEditRole}
onSaveUser={this.handleSaveUser}
onSaveRole={this.handleSaveRole}
onCancelEditUser={this.handleCancelEditUser}
onCancelEditRole={this.handleCancelEditRole}
onDeleteUser={this.handleDeleteUser}
onDeleteRole={this.handleDeleteRole}
onFilterUsers={filterUsers}
onFilterRoles={filterRoles}
onUpdateRoleUsers={this.handleUpdateRoleUsers}
onUpdateRolePermissions={this.handleUpdateRolePermissions}
onUpdateUserPermissions={this.handleUpdateUserPermissions}
onUpdateUserRoles={this.handleUpdateUserRoles}
/> :
<span>Loading...</span>
}
</div>
</div>
</div>
</div>
)
}
}
const {
arrayOf,
func,
shape,
string,
} = PropTypes
AdminPage.propTypes = {
source: shape({
id: string.isRequired,
links: shape({
users: string.isRequired,
}),
}).isRequired,
users: arrayOf(shape()),
roles: arrayOf(shape()),
permissions: arrayOf(shape()),
loadUsers: func,
loadRoles: func,
loadPermissions: func,
addUser: func,
addRole: func,
removeUser: func,
removeRole: func,
editUser: func,
editRole: func,
createUser: func,
createRole: func,
deleteRole: func,
deleteUser: func,
addFlashMessage: func,
filterRoles: func,
filterUsers: func,
updateRoleUsers: func,
updateRolePermissions: func,
updateUserPermissions: func,
updateUserRoles: func,
}
const mapStateToProps = ({admin: {users, roles, permissions}}) => ({
users,
roles,
permissions,
})
const mapDispatchToProps = (dispatch) => ({
loadUsers: bindActionCreators(loadUsersAsync, dispatch),
loadRoles: bindActionCreators(loadRolesAsync, dispatch),
loadPermissions: bindActionCreators(loadPermissionsAsync, dispatch),
addUser: bindActionCreators(addUser, dispatch),
addRole: bindActionCreators(addRole, dispatch),
removeUser: bindActionCreators(deleteUser, dispatch),
removeRole: bindActionCreators(deleteRole, dispatch),
editUser: bindActionCreators(editUser, dispatch),
editRole: bindActionCreators(editRole, dispatch),
createUser: bindActionCreators(createUserAsync, dispatch),
createRole: bindActionCreators(createRoleAsync, dispatch),
deleteUser: bindActionCreators(deleteUserAsync, dispatch),
deleteRole: bindActionCreators(deleteRoleAsync, dispatch),
filterUsers: bindActionCreators(filterUsersAction, dispatch),
filterRoles: bindActionCreators(filterRolesAction, dispatch),
updateRoleUsers: bindActionCreators(updateRoleUsersAsync, dispatch),
updateRolePermissions: bindActionCreators(updateRolePermissionsAsync, dispatch),
updateUserPermissions: bindActionCreators(updateUserPermissionsAsync, dispatch),
updateUserRoles: bindActionCreators(updateUserRolesAsync, dispatch),
})
export default connect(mapStateToProps, mapDispatchToProps)(AdminPage)

View File

@ -0,0 +1,134 @@
import React, {PropTypes, Component} from 'react'
import {connect} from 'react-redux';
import {bindActionCreators} from 'redux';
import flatten from 'lodash/flatten'
import uniqBy from 'lodash/uniqBy'
import {
showDatabases,
showQueries,
} from 'shared/apis/metaQuery'
import QueriesTable from 'src/admin/components/QueriesTable'
import showDatabasesParser from 'shared/parsing/showDatabases'
import showQueriesParser from 'shared/parsing/showQueries'
import {TIMES} from 'src/admin/constants'
import {
loadQueries as loadQueriesAction,
setQueryToKill as setQueryToKillAction,
killQueryAsync,
} from 'src/admin/actions'
class QueriesPage extends Component {
constructor(props) {
super(props)
this.updateQueries = ::this.updateQueries
this.handleConfirmKillQuery = ::this.handleConfirmKillQuery
this.handleKillQuery = ::this.handleKillQuery
}
componentDidMount() {
this.updateQueries()
const updateInterval = 5000
this.intervalID = setInterval(this.updateQueries, updateInterval)
}
componentWillUnmount() {
clearInterval(this.intervalID)
}
render() {
const {queries} = this.props;
return (
<QueriesTable queries={queries} onConfirm={this.handleConfirmKillQuery} onKillQuery={this.handleKillQuery} />
);
}
updateQueries() {
const {source, addFlashMessage, loadQueries} = this.props
showDatabases(source.links.proxy).then((resp) => {
const {databases, errors} = showDatabasesParser(resp.data)
if (errors.length) {
errors.forEach((message) => addFlashMessage({type: 'error', text: message}))
return;
}
const fetches = databases.map((db) => showQueries(source.links.proxy, db))
Promise.all(fetches).then((queryResponses) => {
const allQueries = [];
queryResponses.forEach((queryResponse) => {
const result = showQueriesParser(queryResponse.data);
if (result.errors.length) {
result.erorrs.forEach((message) => this.props.addFlashMessage({type: 'error', text: message}));
}
allQueries.push(...result.queries);
});
const queries = uniqBy(flatten(allQueries), (q) => q.id);
// sorting queries by magnitude, so generally longer queries will appear atop the list
const sortedQueries = queries.sort((a, b) => {
const aTime = TIMES.find((t) => a.duration.match(t.test));
const bTime = TIMES.find((t) => b.duration.match(t.test));
return +aTime.magnitude <= +bTime.magnitude;
});
loadQueries(sortedQueries)
});
});
}
handleKillQuery(e) {
e.stopPropagation();
const id = e.target.dataset.queryId;
this.props.setQueryToKill(id)
}
handleConfirmKillQuery() {
const {queryIDToKill, source, killQuery} = this.props;
if (queryIDToKill === null) {
return;
}
killQuery(source.links.proxy, queryIDToKill)
}
}
const {
arrayOf,
func,
string,
shape,
} = PropTypes
QueriesPage.propTypes = {
source: shape({
links: shape({
proxy: string,
}),
}),
queries: arrayOf(shape()),
addFlashMessage: func,
loadQueries: func,
queryIDToKill: string,
setQueryToKill: func,
killQuery: func,
}
const mapStateToProps = ({admin: {queries, queryIDToKill}}) => ({
queries,
queryIDToKill,
})
const mapDispatchToProps = (dispatch) => ({
loadQueries: bindActionCreators(loadQueriesAction, dispatch),
setQueryToKill: bindActionCreators(setQueryToKillAction, dispatch),
killQuery: bindActionCreators(killQueryAsync, dispatch),
})
export default connect(mapStateToProps, mapDispatchToProps)(QueriesPage)

2
ui/src/admin/index.js Normal file
View File

@ -0,0 +1,2 @@
import AdminPage from './containers/AdminPage';
export {AdminPage};

View File

@ -0,0 +1,154 @@
import reject from 'lodash/reject'
const newDefaultUser = {
name: '',
password: '',
roles: [],
permissions: [],
links: {self: ''},
isNew: true,
}
const newDefaultRole = {
name: '',
permissions: [],
users: [],
links: {self: ''},
isNew: true,
}
const initialState = {
users: null,
roles: [],
permissions: [],
queries: [],
queryIDToKill: null,
}
export default function admin(state = initialState, action) {
switch (action.type) {
case 'LOAD_USERS': {
return {...state, ...action.payload}
}
case 'LOAD_ROLES': {
return {...state, ...action.payload}
}
case 'LOAD_PERMISSIONS': {
return {...state, ...action.payload}
}
case 'ADD_USER': {
const newUser = {...newDefaultUser, isEditing: true}
return {
...state,
users: [
newUser,
...state.users,
],
}
}
case 'ADD_ROLE': {
const newRole = {...newDefaultRole, isEditing: true}
return {
...state,
roles: [
newRole,
...state.roles,
],
}
}
case 'SYNC_USER': {
const {staleUser, syncedUser} = action.payload
const newState = {
users: state.users.map(u => u.links.self === staleUser.links.self ? {...syncedUser} : u),
}
return {...state, ...newState}
}
case 'SYNC_ROLE': {
const {staleRole, syncedRole} = action.payload
const newState = {
roles: state.roles.map(r => r.links.self === staleRole.links.self ? {...syncedRole} : r),
}
return {...state, ...newState}
}
case 'EDIT_USER': {
const {user, updates} = action.payload
const newState = {
users: state.users.map(u => u.links.self === user.links.self ? {...u, ...updates} : u),
}
return {...state, ...newState}
}
case 'EDIT_ROLE': {
const {role, updates} = action.payload
const newState = {
roles: state.roles.map(r => r.links.self === role.links.self ? {...r, ...updates} : r),
}
return {...state, ...newState}
}
case 'DELETE_USER': {
const {user} = action.payload
const newState = {
users: state.users.filter(u => u.links.self !== user.links.self),
}
return {...state, ...newState}
}
case 'DELETE_ROLE': {
const {role} = action.payload
const newState = {
roles: state.roles.filter(r => r.links.self !== role.links.self),
}
return {...state, ...newState}
}
case 'LOAD_QUERIES': {
return {...state, ...action.payload}
}
case 'FILTER_USERS': {
const {text} = action.payload
const newState = {
users: state.users.map(u => {
u.hidden = !u.name.toLowerCase().includes(text)
return u
}),
}
return {...state, ...newState}
}
case 'FILTER_ROLES': {
const {text} = action.payload
const newState = {
roles: state.roles.map(r => {
r.hidden = !r.name.toLowerCase().includes(text)
return r
}),
}
return {...state, ...newState}
}
case 'KILL_QUERY': {
const {queryID} = action.payload
const nextState = {
queries: reject(state.queries, (q) => +q.id === +queryID),
}
return {...state, ...nextState}
}
case 'SET_QUERY_TO_KILL': {
return {...state, ...action.payload}
}
}
return state
}

View File

@ -6,10 +6,6 @@ import AJAX from 'utils/ajax';
import _ from 'lodash';
import NoKapacitorError from '../../shared/components/NoKapacitorError';
// Kevin: because we were getting strange errors saying
// "Failed prop type: Required prop `source` was not specified in `AlertsApp`."
// Tim and I decided to make the source and addFlashMessage props not required.
// FIXME: figure out why that wasn't working
const AlertsApp = React.createClass({
propTypes: {
source: PropTypes.shape({

View File

@ -2,7 +2,7 @@ import React, {PropTypes} from 'react';
import classNames from 'classnames';
import _ from 'lodash';
import MultiSelectDropdown from './MultiSelectDropdown';
import MultiSelectDropdown from 'src/shared/components/MultiSelectDropdown';
import Dropdown from 'src/shared/components/Dropdown';
import {INFLUXQL_FUNCTIONS} from '../constants';

View File

@ -1,106 +0,0 @@
import React, {PropTypes} from 'react';
import OnClickOutside from 'shared/components/OnClickOutside';
import classNames from 'classnames';
import _ from 'lodash';
const {func, arrayOf, string} = PropTypes;
const MultiSelectDropdown = React.createClass({
propTypes: {
onApply: func.isRequired,
items: arrayOf(PropTypes.string.isRequired).isRequired,
selectedItems: arrayOf(string.isRequired).isRequired,
},
getInitialState() {
return {
isOpen: false,
localSelectedItems: this.props.selectedItems,
};
},
componentWillReceiveProps(nextProps) {
if (!_.isEqual(this.state.localSelectedItems, nextProps.selectedItems)) {
this.setState({
localSelectedItems: nextProps.selectedItems,
});
}
},
handleClickOutside() {
this.setState({isOpen: false});
},
toggleMenu(e) {
e.stopPropagation();
this.setState({isOpen: !this.state.isOpen});
},
onSelect(item, e) {
e.stopPropagation();
const {localSelectedItems} = this.state;
let nextItems;
if (this.isSelected(item)) {
nextItems = localSelectedItems.filter((i) => i !== item);
} else {
nextItems = localSelectedItems.concat(item);
}
this.setState({localSelectedItems: nextItems});
},
isSelected(item) {
return this.state.localSelectedItems.indexOf(item) > -1;
},
onApplyFunctions(e) {
e.stopPropagation();
this.setState({isOpen: false});
this.props.onApply(this.state.localSelectedItems);
},
render() {
const {localSelectedItems} = this.state;
const {isOpen} = this.state;
const labelText = isOpen ? "0 Selected" : "Apply Function";
return (
<div className={classNames('dropdown multi-select-dropdown', {open: isOpen})}>
<div onClick={this.toggleMenu} className="btn btn-xs btn-info dropdown-toggle" type="button">
<span className="multi-select-dropdown__label">
{
localSelectedItems.length ? localSelectedItems.map((s) => s).join(', ') : labelText
}
</span>
<span className="caret"></span>
</div>
{this.renderMenu()}
</div>
);
},
renderMenu() {
const {items} = this.props;
return (
<div className="dropdown-options">
<li className="multi-select-dropdown__apply" onClick={this.onApplyFunctions}>
<div className="btn btn-xs btn-info btn-block">Apply</div>
</li>
<ul className="dropdown-menu multi-select-dropdown__menu" aria-labelledby="dropdownMenu1">
{items.map((listItem, i) => {
return (
<li className={classNames('multi-select-dropdown__item', {active: this.isSelected(listItem)})} key={i} onClick={_.wrap(listItem, this.onSelect)}>
<a href="#">{listItem}</a>
</li>
);
})}
</ul>
</div>
);
},
});
export default OnClickOutside(MultiSelectDropdown);

View File

@ -14,6 +14,7 @@ import {KapacitorPage, KapacitorRulePage, KapacitorRulesPage, KapacitorTasksPage
import DataExplorer from 'src/data_explorer';
import {DashboardsPage, DashboardPage} from 'src/dashboards';
import {CreateSource, SourcePage, ManageSources} from 'src/sources';
import {AdminPage} from 'src/admin';
import NotFound from 'src/shared/components/NotFound';
import configureStore from 'src/store/configureStore';
import {getMe, getSources} from 'shared/apis';
@ -127,6 +128,7 @@ const Root = React.createClass({
<Route path="alert-rules" component={KapacitorRulesPage} />
<Route path="alert-rules/:ruleID" component={KapacitorRulePage} />
<Route path="alert-rules/new" component={KapacitorRulePage} />
<Route path="admin" component={AdminPage} />
</Route>
</Route>
<Route path="*" component={NotFound} />

View File

@ -7,18 +7,16 @@ export function showDatabases(source) {
return proxy({source, query});
}
export function showQueries(host, db, clusterID) {
const statement = 'SHOW QUERIES';
const url = buildInfluxUrl({host, statement, database: db});
export function showQueries(source, db) {
const query = 'SHOW QUERIES';
return proxy(url, clusterID);
return proxy({source, query, db});
}
export function killQuery(host, queryId, clusterID) {
const statement = `KILL QUERY ${queryId}`;
const url = buildInfluxUrl({host, statement});
export function killQuery(source, queryId) {
const query = `KILL QUERY ${queryId}`;
return proxy(url, clusterID);
return proxy({source, query});
}
export function showMeasurements(source, db) {

View File

@ -0,0 +1,134 @@
import React, {Component, PropTypes} from 'react'
import OnClickOutside from 'shared/components/OnClickOutside'
import classNames from 'classnames'
import _ from 'lodash'
const labelText = ({localSelectedItems, isOpen, label}) => {
if (label) {
return label
} else if (localSelectedItems.length) {
return localSelectedItems.map((s) => s).join(', ')
}
// TODO: be smarter about the text displayed here
if (isOpen) {
return '0 Selected'
}
return 'None'
}
class MultiSelectDropdown extends Component {
constructor(props) {
super(props)
this.state = {
isOpen: false,
localSelectedItems: this.props.selectedItems,
}
this.onSelect = ::this.onSelect
this.onApplyFunctions = ::this.onApplyFunctions
}
componentWillReceiveProps(nextProps) {
if (!_.isEqual(this.state.localSelectedItems, nextProps.selectedItems)) {
this.setState({
localSelectedItems: nextProps.selectedItems,
})
}
}
handleClickOutside() {
this.setState({isOpen: false})
}
toggleMenu(e) {
e.stopPropagation()
this.setState({isOpen: !this.state.isOpen})
}
onSelect(item, e) {
e.stopPropagation()
const {localSelectedItems} = this.state
let nextItems
if (this.isSelected(item)) {
nextItems = localSelectedItems.filter((i) => i !== item)
} else {
nextItems = localSelectedItems.concat(item)
}
this.setState({localSelectedItems: nextItems})
}
isSelected(item) {
return this.state.localSelectedItems.indexOf(item) > -1
}
onApplyFunctions(e) {
e.stopPropagation()
this.setState({isOpen: false})
this.props.onApply(this.state.localSelectedItems)
}
render() {
const {localSelectedItems, isOpen} = this.state
const {label} = this.props
return (
<div className={classNames('dropdown multi-select-dropdown', {open: isOpen})}>
<div onClick={::this.toggleMenu} className="btn btn-xs btn-info dropdown-toggle" type="button">
<div className="multi-select-dropdown__label">
{
labelText({localSelectedItems, isOpen, label})
}
</div>
<span className="caret"></span>
</div>
{this.renderMenu()}
</div>
)
}
renderMenu() {
const {items} = this.props
return (
<div className="dropdown-options">
<li className="multi-select-dropdown__apply" onClick={this.onApplyFunctions} style={{listStyle: 'none'}}>
<div className="btn btn-xs btn-info btn-block">Apply</div>
</li>
<ul className="dropdown-menu multi-select-dropdown__menu" aria-labelledby="dropdownMenu1">
{items.map((listItem, i) => {
return (
<li
key={i}
className={classNames('multi-select-dropdown__item', {active: this.isSelected(listItem)})}
onClick={_.wrap(listItem, this.onSelect)}
>
<a href="#">{listItem}</a>
</li>
)
})}
</ul>
</div>
)
}
}
const {
arrayOf,
func,
string,
} = PropTypes
MultiSelectDropdown.propTypes = {
onApply: func.isRequired,
items: arrayOf(string.isRequired).isRequired,
selectedItems: arrayOf(string.isRequired).isRequired,
label: string,
}
export default OnClickOutside(MultiSelectDropdown)

View File

@ -28,6 +28,7 @@ export const TabList = React.createClass({
activeIndex: number,
onActivate: func,
isKapacitorTabs: string,
customClass: string,
},
getDefaultProps() {
@ -53,6 +54,14 @@ export const TabList = React.createClass({
);
}
if (this.props.customClass) {
return (
<div className={this.props.customClass}>
<div className="btn-group btn-group-lg tab-group">{children}</div>
</div>
);
}
return (
<div className="btn-group btn-group-lg tab-group">{children}</div>
);
@ -63,11 +72,13 @@ export const TabPanels = React.createClass({
propTypes: {
children: node.isRequired,
activeIndex: number,
customClass: string,
},
// if only 1 child, children array index lookup will fail
render() {
return (
<div>
<div className={this.props.customClass ? this.props.customClass : null}>
{this.props.children[this.props.activeIndex]}
</div>
);

View File

@ -0,0 +1,21 @@
import React, {PropTypes} from 'react'
import ReactTooltip from 'react-tooltip'
const Tooltip = ({tip, children}) => (
<div>
<div data-tip={tip}>{children}</div>
<ReactTooltip effect="solid" html={true} offset={{top: 2}} place="bottom" class="influx-tooltip place-bottom" />
</div>
)
const {
shape,
string,
} = PropTypes
Tooltip.propTypes = {
tip: string,
children: shape({}),
}
export default Tooltip

View File

@ -471,4 +471,6 @@ export const STROKE_WIDTH = {
export const PRESENTATION_MODE_ANIMATION_DELAY = 0 // In milliseconds.
export const PRESENTATION_MODE_NOTIFICATION_DELAY = 2000 // In milliseconds.
export const RES_UNAUTHORIZED = 401
export const AUTOREFRESH_DEFAULT = 15000 // in milliseconds

View File

@ -47,6 +47,9 @@ const SideNav = React.createClass({
<NavListItem link={`${sourcePrefix}/manage-sources`}>InfluxDB</NavListItem>
<NavListItem link={`${sourcePrefix}/kapacitor-config`}>Kapacitor</NavListItem>
</NavBlock>
<NavBlock icon="crown" link={`${sourcePrefix}/admin`}>
<NavHeader link={`${sourcePrefix}/admin`} title="Admin" />
</NavBlock>
{loggedIn ? (
<NavBlock icon="user-outline" className="sidebar__square-last">
<a className="sidebar__menu-item" href="/oauth/logout">Logout</a>

View File

@ -3,6 +3,7 @@ import {combineReducers} from 'redux';
import thunkMiddleware from 'redux-thunk';
import makeQueryExecuter from 'src/shared/middleware/queryExecuter';
import resizeLayout from 'src/shared/middleware/resizeLayout';
import adminReducer from 'src/admin/reducers/admin';
import sharedReducers from 'src/shared/reducers';
import dataExplorerReducers from 'src/data_explorer/reducers';
import rulesReducer from 'src/kapacitor/reducers/rules';
@ -12,6 +13,7 @@ import persistStateEnhancer from './persistStateEnhancer';
const rootReducer = combineReducers({
...sharedReducers,
...dataExplorerReducers,
admin: adminReducer,
rules: rulesReducer,
dashboardUI,
});

View File

@ -1,6 +1,80 @@
$ms-normal-left-padding: 9px;
$ms-item-height: 26px;
$ms-checkbox-size: 14px;
$ms-checkbox-dot-size: 6px;
$ms-checkbox-bg: $c-sapphire;
$ms-checkbox-bg-hover: $c-ocean;
$ms-checkbox-dot: $g20-white;
.multi-select-dropdown {
.multi-select-dropdown__item > a {
color: $c-neutrino !important;
height: $ms-item-height;
line-height: $ms-item-height;
position: relative;
padding-top: 0;
padding-bottom: 0;
padding-right: $ms-normal-left-padding;
padding-left: ($ms-normal-left-padding + $ms-checkbox-size + ($ms-normal-left-padding - 2px));
&,
&:focus,
&:active,
&:active:focus {
background: none !important;
&:hover {
background: $c-pool;
background: -moz-linear-gradient(left, $c-pool 0%, $c-pool 100%) !important;
background: -webkit-linear-gradient(left, $c-pool 0%,$c-pool 100%) !important;
background: linear-gradient(to right, $c-pool 0%,$c-pool 100%) !important;
filter: progid:DXImageTransform.Microsoft.gradient( startColorstr='$c-pool', endColorstr='$c-pool',GradientType=1 ) !important;
}
}
/* Shared Checkbox Styles */
&:before,
&:after {
content: '';
position: absolute;
display: block;
top: 50%;
}
/* Before = Checkbox */
&:before {
width: $ms-checkbox-size;
height: $ms-checkbox-size;
border-radius: $radius-small;
background-color: $ms-checkbox-bg;
left: $ms-normal-left-padding;
transform: translateY(-50%);
}
/* After = Dot */
&:after {
width: $ms-checkbox-dot-size;
height: $ms-checkbox-dot-size;
background-color: $ms-checkbox-dot;
border-radius: 50%;
transform: translate(-50%,-50%) scale(2,2);
opacity: 0;
left: ($ms-normal-left-padding + ($ms-checkbox-size / 2));
transition:
opacity 0.25s ease,
transform 0.25s ease;
}
/* Hover State */
&:hover {
color: $g20-white !important;
}
}
.dropdown-toggle {
width: 110px;
&.btn-xs {
height: 22px;
line-height: 22px;
padding-left: 0;
padding-right: 0;
}
}
&__apply {
margin: 0;
@ -45,6 +119,22 @@
}
}
}
/* Checked State */
.multi-select-dropdown li.multi-select-dropdown__item.active > a {
&,
&:focus,
&:active,
&:active:focus {
background: none !important;
}
color: $g20-white !important;
&:after {
transform: translate(-50%,-50%) scale(1,1);
opacity: 1;
}
}
/* Open State */
.multi-select-dropdown.open {
.dropdown-options {
@ -56,3 +146,14 @@
opacity: 1;
}
}
.multi-select-dropdown__label {
top: 50%;
transform: translateY(-50%);
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
padding-right: 10px;
position: absolute;
width: calc(100% - #{($ms-normal-left-padding * 2)});
left: $ms-normal-left-padding;
}

View File

@ -2,10 +2,13 @@
Custom Search Widget
----------------------------------------------
*/
$search-widget-height: 36px;
.users__search-widget {
position: relative;
input.form-control {
height: $search-widget-height;
position: relative;
width: 100%;
z-index: 1;
@ -19,7 +22,7 @@
.input-group-addon {
padding: 0;
text-align: center;
line-height: 38px;
line-height: calc(#{$search-widget-height} - 2px);
position: absolute;
color: $g10-wolf;
top: 0;
@ -33,3 +36,6 @@
color 0.25s ease;
}
}
.admin__search-widget {
width: 300px;
}

View File

@ -108,6 +108,64 @@ table .monotype {
}
}
/*
Table Styles for Admin Pages
----------------------------------------------
*/
.admin-table {
.admin-table--delete {
visibility: hidden;
}
tbody tr {
transition: background-color 0.25s ease;
&:hover {
background-color: $g4-onyx;
}
}
tbody tr:hover .admin-table--delete {
visibility: visible;
}
.multi-select-dropdown {
width: 100%;
min-width: 150px;
}
.dropdown-toggle {
background-color: transparent;
width: 100%;
.caret {
opacity: 0;
}
}
tbody tr:hover .dropdown-toggle {
color: $g20-white !important;
background-color: $c-pool;
.caret {opacity: 1;}
}
}
.admin-table--edit-row {
background-color: $g4-onyx;
}
.admin-table--edit-cell {
width: 100%;
margin: 0 !important;
display: flex !important;
justify-content: space-between;
> input {
height: 30px;
padding: 0 9px;
flex-grow: 1;
margin: 0 2px;
min-width: 110px;
&:first-child {margin-left: 0;}
&:last-child {margin-right: 0;}
}
}
/*
Responsive Tables
----------------------------------------------

View File

@ -29,7 +29,6 @@
.panel-title {
color: $g10-wolf !important;
}
.panel-body {
padding: 30px;
background-color: $g3-castle;
@ -42,8 +41,27 @@
> *:last-child {
margin-bottom: 0;
}
}
}
.panel.panel-info {
background-color: $g3-castle;
border: 0;
.panel-body,
.panel-heading {
background-color: transparent;
}
.panel-body {
padding: 30px;
}
.panel-heading {
border-color: $g4-onyx;
.panel-title { color: $g14-chromium;}
}
}
.panel .panel-body table {
margin: 0;
table {
th,td {
border-color: $g5-pepper;
}
@ -56,9 +74,8 @@
tbody tr:last-child td {
border-bottom: 2px solid $g5-pepper;
}
}
}
}
table thead th {
@include no-user-select();
}
@ -258,7 +275,6 @@ input {
max-width: 100%;
margin: 0 !important;
padding: 0 !important;
min-height: 70px;
max-height: 290px;
overflow: auto;
@include custom-scrollbar($c-pool, $c-laser);
@ -752,7 +768,44 @@ $form-static-checkbox-size: 16px;
/*
Admin Tabs
----------------------------------------------
*/
.admin-tabs .btn-group {
margin: 0;
width: 100%;
display: flex;
flex-direction: column;
align-items: stretch;
.tab {
font-weight: 500 !important;
border-radius: $radius !important;
transition:
background-color 0.25s ease,
color 0.25s ease !important;
border: 0 !important;
text-align: left;
height: 60px !important;
line-height: 60px !important;
padding: 0 0 0 16px !important;
font-size: 17px;
background-color: transparent !important;
color: $g11-sidewalk !important;
&:hover,
&:active,
&:active:hover {
background-color: $g3-castle !important;
color: $g15-platinum !important;
}
&.active {
background-color: $g4-onyx !important;
color: $g18-cloud !important;
}
}
}
br {
@include no-user-select();

View File

@ -2,7 +2,7 @@ import axios from 'axios';
let links
const UNAUTHORIZED = 401
import {RES_UNAUTHORIZED} from 'shared/constants'
export default async function AJAX({
url,
@ -13,10 +13,9 @@ export default async function AJAX({
params = {},
headers = {},
}) {
let response
try {
const basepath = window.basepath || ''
let response
url = `${basepath}${url}`
@ -47,9 +46,11 @@ export default async function AJAX({
...response,
}
} catch (error) {
if (!response.status === UNAUTHORIZED) {
const {response} = error
if (!response.status === RES_UNAUTHORIZED) {
console.error(error) // eslint-disable-line no-console
}
// console.error(error) // eslint-disable-line no-console
const {auth} = links
throw {auth, ...response} // eslint-disable-line no-throw-literal
}

View File

@ -1,13 +1,8 @@
import AJAX from 'utils/ajax';
// TODO: delete this once all references
// to it have been removed
export function buildInfluxUrl() {
return "You dont need me anymore";
}
export function proxy({source, query, db, rp}) {
return AJAX({
export const proxy = async ({source, query, db, rp}) => {
try {
return await AJAX({
method: 'POST',
url: source,
data: {
@ -15,5 +10,8 @@ export function proxy({source, query, db, rp}) {
db,
rp,
},
});
})
} catch (error) {
console.error(error) // eslint-disable-line no-console
}
}

71
ui/stories/admin.js Normal file
View File

@ -0,0 +1,71 @@
import React from 'react'
import {storiesOf, action, linkTo} from '@kadira/storybook'
import Center from './components/Center'
import MultiSelectDropdown from 'shared/components/MultiSelectDropdown'
import Tooltip from 'shared/components/Tooltip'
storiesOf('MultiSelectDropdown', module)
.add('Select Roles w/label', () => (
<Center>
<MultiSelectDropdown
items={[
'Admin',
'User',
'Chrono Girafferoo',
'Prophet',
'Susford',
]}
selectedItems={[
'User',
'Chrono Girafferoo',
]}
label={'Select Roles'}
onApply={action('onApply')}
/>
</Center>
))
.add('Selected Item list', () => (
<Center>
<MultiSelectDropdown
items={[
'Admin',
'User',
'Chrono Giraffe',
'Prophet',
'Susford',
]}
selectedItems={[
'User',
'Chrono Giraffe',
]}
onApply={action('onApply')}
/>
</Center>
))
.add('0 selected items', () => (
<Center>
<MultiSelectDropdown
items={[
'Admin',
'User',
'Chrono Giraffe',
'Prophet',
'Susford',
]}
selectedItems={[]}
onApply={action('onApply')}
/>
</Center>
))
storiesOf('Tooltip', module)
.add('Delete', () => (
<Center>
<Tooltip tip={`Are you sure? TrashIcon`}>
<div className="btn btn-info btn-sm">
Delete
</div>
</Tooltip>
</Center>
))

View File

@ -0,0 +1,14 @@
import React from 'react'
const Center = ({children}) => (
<div style={{
position: 'absolute',
top: '50%',
left: '50%',
transform: 'translate(-50%)',
}}>
{children}
</div>
)
export default Center

View File

@ -3,3 +3,4 @@ import 'src/style/chronograf.scss';
// Kapacitor Stories
import './kapacitor'
import './admin'

View File

@ -12,7 +12,7 @@ import queryConfigs from './stubs/queryConfigs';
// Actions for Spies
import * as kapacitorActions from 'src/kapacitor/actions/view'
import * as queryActions from 'src/chronograf/actions/view';
import * as queryActions from 'src/data_explorer/actions/view';
// Components
import KapacitorRule from 'src/kapacitor/components/KapacitorRule';

19
ui/storybook.js Normal file
View File

@ -0,0 +1,19 @@
const express = require('express')
const request = require('request')
const {default: storybook} = require('@kadira/storybook/dist/server/middleware')
const app = express()
const handler = (req, res) => {
console.log(`${req.method} ${req.url}`)
const url = 'http://localhost:8888' + req.url
req.pipe(request(url)).pipe(res)
}
app.use(storybook('./.storybook'))
app.get('/chronograf/v1/*', handler)
app.post('/chronograf/v1/*', handler)
app.listen(6006, () => {
console.log('storybook proxy server now running')
})