Merge pull request #1660 from influxdata/feature/custom_user_links-1550

Add ability to add custom links to User menu via server CLI or ENV vars
pull/10616/head
Jared Scheib 2017-06-27 14:32:01 -07:00 committed by GitHub
commit 2579e07bce
11 changed files with 235 additions and 49 deletions

View File

@ -5,6 +5,7 @@
### Features
1. [#1645](https://github.com/influxdata/chronograf/pull/1645): Add Auth0 as a supported OAuth2 provider
1. [#1660](https://github.com/influxdata/chronograf/pull/1660): Add ability to add custom links to User menu via server CLI or ENV vars
### UI Improvements
1. [#1644](https://github.com/influxdata/chronograf/pull/1644): Redesign Alerts History table to have sticky headers

43
server/links.go Normal file
View File

@ -0,0 +1,43 @@
package server
import (
"errors"
"net/url"
)
type getExternalLinksResponse struct {
StatusFeed *string `json:"statusFeed,omitempty"` // Location of the a JSON Feed for client's Status page News Feed
CustomLinks []CustomLink `json:"custom,omitempty"` // Any custom external links for client's User menu
}
// CustomLink is a handler that returns a custom link to be used in server's routes response, within ExternalLinks
type CustomLink struct {
Name string `json:"name"`
URL string `json:"url"`
}
// NewCustomLinks transforms `--custom-link` CLI flag data or `CUSTOM_LINKS` ENV
// var data into a data structure that the Chronograf client will expect
func NewCustomLinks(links map[string]string) ([]CustomLink, error) {
customLinks := make([]CustomLink, 0, len(links))
for name, link := range links {
if name == "" {
return nil, errors.New("CustomLink missing key for Name")
}
if link == "" {
return nil, errors.New("CustomLink missing value for URL")
}
_, err := url.Parse(link)
if err != nil {
return nil, err
}
customLink := CustomLink{
Name: name,
URL: link,
}
customLinks = append(customLinks, customLink)
}
return customLinks, nil
}

60
server/links_test.go Normal file
View File

@ -0,0 +1,60 @@
package server
import (
"reflect"
"testing"
)
func TestNewCustomLinks(t *testing.T) {
tests := []struct {
name string
args map[string]string
want []CustomLink
wantErr bool
}{
{
name: "Unknown error in NewCustomLinks",
args: map[string]string{
"cubeapple": "https://cube.apple",
},
want: []CustomLink{
{
Name: "cubeapple",
URL: "https://cube.apple",
},
},
},
{
name: "CustomLink missing Name",
args: map[string]string{
"": "https://cube.apple",
},
wantErr: true,
},
{
name: "CustomLink missing URL",
args: map[string]string{
"cubeapple": "",
},
wantErr: true,
},
{
name: "Missing protocol scheme",
args: map[string]string{
"cubeapple": ":k%8a#",
},
wantErr: true,
},
}
for _, tt := range tests {
got, err := NewCustomLinks(tt.args)
if (err != nil) != tt.wantErr {
t.Errorf("%q. NewCustomLinks() error = %v, wantErr %v", tt.name, err, tt.wantErr)
continue
}
if !reflect.DeepEqual(got, tt.want) {
t.Errorf("%q. NewCustomLinks() = %v, want %v", tt.name, got, tt.want)
}
}
}

View File

@ -28,7 +28,8 @@ type MuxOpts struct {
UseAuth bool // UseAuth turns on Github OAuth and JWT
Auth oauth2.Authenticator // Auth is used to authenticate and authorize
ProviderFuncs []func(func(oauth2.Provider, oauth2.Mux))
StatusFeedURL string // JSON Feed URL for the client Status page News Feed
StatusFeedURL string // JSON Feed URL for the client Status page News Feed
CustomLinks map[string]string // Any custom external links for client's User menu
}
// NewMux attaches all the route handlers; handler returned servers chronograf.
@ -182,8 +183,9 @@ func NewMux(opts MuxOpts, service Service) http.Handler {
router.DELETE("/chronograf/v1/sources/:id/dbs/:dbid/rps/:rpid", service.DropRetentionPolicy)
allRoutes := &AllRoutes{
Logger: opts.Logger,
StatusFeed: opts.StatusFeedURL,
Logger: opts.Logger,
StatusFeed: opts.StatusFeedURL,
CustomLinks: opts.CustomLinks,
}
router.Handler("GET", "/chronograf/v1/", allRoutes)

View File

@ -39,22 +39,25 @@ type getRoutesResponse struct {
ExternalLinks getExternalLinksResponse `json:"external"` // All external links for the client to use
}
type getExternalLinksResponse struct {
StatusFeed *string `json:"statusFeed,omitempty"` // Location of the a JSON Feed for client's Status page News Feed
}
// AllRoutes is a handler that returns all links to resources in Chronograf server, as well as
// external links for the client to know about, such as for JSON feeds or custom side nav buttons.
// Optionally, routes for authentication can be returned.
type AllRoutes struct {
AuthRoutes []AuthRoute // Location of all auth routes. If no auth, this can be empty.
LogoutLink string // Location of the logout route for all auth routes. If no auth, this can be empty.
StatusFeed string // External link to the JSON Feed for the News Feed on the client's Status Page
Logger chronograf.Logger
AuthRoutes []AuthRoute // Location of all auth routes. If no auth, this can be empty.
LogoutLink string // Location of the logout route for all auth routes. If no auth, this can be empty.
StatusFeed string // External link to the JSON Feed for the News Feed on the client's Status Page
CustomLinks map[string]string // Custom external links for client's User menu, as passed in via CLI/ENV
Logger chronograf.Logger
}
// ServeHTTP returns all top level routes within chronograf
// ServeHTTP returns all top level routes and external links within chronograf
func (a *AllRoutes) ServeHTTP(w http.ResponseWriter, r *http.Request) {
customLinks, err := NewCustomLinks(a.CustomLinks)
if err != nil {
Error(w, http.StatusInternalServerError, err.Error(), a.Logger)
return
}
routes := getRoutesResponse{
Sources: "/chronograf/v1/sources",
Layouts: "/chronograf/v1/layouts",
@ -63,7 +66,8 @@ func (a *AllRoutes) ServeHTTP(w http.ResponseWriter, r *http.Request) {
Dashboards: "/chronograf/v1/dashboards",
Auth: make([]AuthRoute, len(a.AuthRoutes)), // We want to return at least an empty array, rather than null
ExternalLinks: getExternalLinksResponse{
StatusFeed: &a.StatusFeed,
StatusFeed: &a.StatusFeed,
CustomLinks: customLinks,
},
}

View File

@ -76,10 +76,14 @@ func TestAllRoutesWithAuth(t *testing.T) {
func TestAllRoutesWithExternalLinks(t *testing.T) {
statusFeedURL := "http://pineapple.life/feed.json"
customLinks := map[string]string{
"cubeapple": "https://cube.apple",
}
logger := log.New(log.DebugLevel)
handler := &AllRoutes{
StatusFeed: statusFeedURL,
Logger: logger,
StatusFeed: statusFeedURL,
CustomLinks: customLinks,
Logger: logger,
}
req := httptest.NewRequest("GET", "http://docbrowns-inventions.com", nil)
w := httptest.NewRecorder()
@ -96,7 +100,7 @@ func TestAllRoutesWithExternalLinks(t *testing.T) {
if err := json.Unmarshal(body, &routes); err != nil {
t.Error("TestAllRoutesWithExternalLinks not able to unmarshal JSON response")
}
want := `{"layouts":"/chronograf/v1/layouts","mappings":"/chronograf/v1/mappings","sources":"/chronograf/v1/sources","me":"/chronograf/v1/me","dashboards":"/chronograf/v1/dashboards","auth":[],"external":{"statusFeed":"http://pineapple.life/feed.json"}}
want := `{"layouts":"/chronograf/v1/layouts","mappings":"/chronograf/v1/mappings","sources":"/chronograf/v1/sources","me":"/chronograf/v1/me","dashboards":"/chronograf/v1/dashboards","auth":[],"external":{"statusFeed":"http://pineapple.life/feed.json","custom":[{"name":"cubeapple","url":"https://cube.apple"}]}}
`
if want != string(body) {
t.Errorf("TestAllRoutesWithExternalLinks\nwanted\n*%s*\ngot\n*%s*", want, string(body))

View File

@ -80,6 +80,8 @@ type Server struct {
StatusFeedURL string `long:"status-feed-url" description:"URL of a JSON Feed to display as a News Feed on the client Status page." default:"https://www.influxdata.com/feed/json" env:"STATUS_FEED_URL"`
CustomLinks map[string]string `long:"custom-link" description:"Custom link to be added to the client User menu" env:"CUSTOM_LINKS" env-delim:","`
Auth0Domain string `long:"auth0-domain" description:"Subdomain of auth0.com used for Auth0 OAuth2 authentication" env:"AUTH0_DOMAIN"`
Auth0ClientID string `long:"auth0-client-id" description:"Auth0 Client ID for OAuth2 support" env:"AUTH0_CLIENT_ID"`
Auth0ClientSecret string `long:"auth0-client-secret" description:"Auth0 Client Secret for OAuth2 support" env:"AUTH0_CLIENT_SECRET"`
@ -287,6 +289,14 @@ func (s *Server) Serve(ctx context.Context) error {
KapacitorUsername: s.KapacitorUsername,
KapacitorPassword: s.KapacitorPassword,
}
_, err := NewCustomLinks(s.CustomLinks)
if err != nil {
logger.
WithField("component", "server").
WithField("CustomLink", "invalid").
Error(err)
return err
}
service := openService(ctx, s.BoltPath, layoutBuilder, sourcesBuilder, kapacitorBuilder, logger, s.useAuth())
basepath = s.Basepath
if basepath != "" && s.PrefixRoutes == false {
@ -313,6 +323,7 @@ func (s *Server) Serve(ctx context.Context) error {
Basepath: basepath,
PrefixRoutes: s.PrefixRoutes,
StatusFeedURL: s.StatusFeedURL,
CustomLinks: s.CustomLinks,
}, service)
// Add chronograf's version header to all requests

View File

@ -4115,6 +4115,22 @@
"description": "link to a JSON Feed for the News Feed on client's Status Page",
"type": "string",
"format": "url"
},
"custom": {
"description": "a collection of custom links set by the user to be rendered in the client User menu",
"type": "array",
"items": {
"type": "object",
"properties": {
"name": {
"type": "string"
},
"url": {
"type": "string",
"format": "url"
}
}
},
}
}
}
@ -4126,7 +4142,13 @@
"me": "/chronograf/v1/me",
"dashboards": "/chronograf/v1/dashboards",
"external": {
"statusFeed": "http://news.influxdata.com/feed.json"
"statusFeed": "http://news.influxdata.com/feed.json",
"custom": [
{
"name": "InfluxData",
"url": "https://www.influxdata.com"
}
]
}
}
},

View File

@ -2,6 +2,7 @@ import * as actionTypes from 'shared/constants/actionTypes'
const initialState = {
external: {statusFeed: ''},
custom: [],
}
const linksReducer = (state = initialState, action) => {

View File

@ -9,20 +9,28 @@ const NavListItem = React.createClass({
link: string.isRequired,
children: node,
location: string,
useAnchor: bool,
isExternal: bool,
},
render() {
const {link, children, location} = this.props
const {link, children, location, useAnchor, isExternal} = this.props
const isActive = location.startsWith(link)
return (
<Link
className={classnames('sidebar-menu--item', {active: isActive})}
to={link}
>
{children}
</Link>
)
return useAnchor
? <a
className={classnames('sidebar-menu--item', {active: isActive})}
href={link}
target={isExternal ? '_blank' : '_self'}
>
{children}
</a>
: <Link
className={classnames('sidebar-menu--item', {active: isActive})}
to={link}
>
{children}
</Link>
},
})

View File

@ -11,7 +11,7 @@ import {
import {DEFAULT_HOME_PAGE} from 'shared/constants'
const {bool, shape, string} = PropTypes
const {arrayOf, bool, shape, string} = PropTypes
const V_NUMBER = VERSION // eslint-disable-line no-undef
@ -25,6 +25,37 @@ const SideNav = React.createClass({
}).isRequired,
isHidden: bool.isRequired,
logoutLink: string,
customLinks: arrayOf(
shape({
name: string.isRequired,
url: string.isRequired,
})
),
},
renderUserMenuBlockWithCustomLinks(customLinks, logoutLink) {
return [
<NavHeader key={0} title="User" />,
...customLinks
.sort((a, b) => a.name.toLowerCase() > b.name.toLowerCase())
.map(({name, url}, i) =>
<NavListItem
key={i + 1}
useAnchor={true}
isExternal={true}
link={url}
>
{name}
</NavListItem>
),
<NavListItem
key={customLinks.length + 1}
useAnchor={true}
link={logoutLink}
>
Logout
</NavListItem>,
]
},
render() {
@ -33,11 +64,12 @@ const SideNav = React.createClass({
location: {pathname: location},
isHidden,
logoutLink,
customLinks,
} = this.props
const sourcePrefix = `/sources/${sourceID}`
const dataExplorerLink = `${sourcePrefix}/chronograf/data-explorer`
const showLogout = !!logoutLink
const isUsingAuth = !!logoutLink
return isHidden
? null
@ -84,27 +116,23 @@ const SideNav = React.createClass({
title="Configuration"
/>
</NavBlock>
<div className="sidebar--bottom">
<div className="sidebar--item">
<div className="sidebar--square">
<span className="sidebar--icon icon zap" />
</div>
<div className="sidebar-menu">
<div className="sidebar-menu--heading">
Version: {V_NUMBER}
</div>
</div>
</div>
{showLogout
? <NavBlock icon="user" className="sidebar--item-last">
<NavHeader
useAnchor={true}
link={logoutLink}
title="Logout"
/>
</NavBlock>
: null}
</div>
{isUsingAuth
? <NavBlock icon="user">
{customLinks
? this.renderUserMenuBlockWithCustomLinks(
customLinks,
logoutLink
)
: <NavHeader
useAnchor={true}
link={logoutLink}
title="Logout"
/>}
</NavBlock>
: null}
<NavBlock icon="zap">
<NavHeader title={`Version: ${V_NUMBER}`} />
</NavBlock>
</NavBar>
},
})
@ -112,9 +140,11 @@ const SideNav = React.createClass({
const mapStateToProps = ({
auth: {logoutLink},
app: {ephemeral: {inPresentationMode}},
links: {external: {custom: customLinks}},
}) => ({
isHidden: inPresentationMode,
logoutLink,
customLinks,
})
export default connect(mapStateToProps)(withRouter(SideNav))