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 varspull/10616/head
commit
2579e07bce
|
@ -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
|
||||
|
|
|
@ -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
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
}
|
||||
|
||||
|
|
|
@ -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))
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
|
|
|
@ -2,6 +2,7 @@ import * as actionTypes from 'shared/constants/actionTypes'
|
|||
|
||||
const initialState = {
|
||||
external: {statusFeed: ''},
|
||||
custom: [],
|
||||
}
|
||||
|
||||
const linksReducer = (state = initialState, action) => {
|
||||
|
|
|
@ -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>
|
||||
},
|
||||
})
|
||||
|
||||
|
|
|
@ -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))
|
||||
|
|
Loading…
Reference in New Issue