Merge branch 'master' into e2e/chronograf_admin_current_org

pull/5939/head
k3yi0 2022-06-20 11:25:45 +02:00
commit b5017d1c32
17 changed files with 527 additions and 246 deletions

View File

@ -9,6 +9,7 @@
1. [#5926](https://github.com/influxdata/chronograf/pull/5926): Improve InfluxDB role creation.
1. [#5927](https://github.com/influxdata/chronograf/pull/5927): Show effective permissions on Users page.
1. [#5929](https://github.com/influxdata/chronograf/pull/5926): Add refresh button to InfluxDB Users/Roles/Databases page.
1. [#5940](https://github.com/influxdata/chronograf/pull/5940): Support InfluxDB behind proxy under subpath.
### Bug Fixes
@ -16,6 +17,7 @@
1. [#5913](https://github.com/influxdata/chronograf/pull/5913): Improve InfluxDB Enterprise detection.
1. [#5917](https://github.com/influxdata/chronograf/pull/5917): Improve InfluxDB Enterprise user creation process.
1. [#5917](https://github.com/influxdata/chronograf/pull/5917): Avoid stale reads in communication with InfluxDB Enterprise meta nodes.
1. [#5938](https://github.com/influxdata/chronograf/pull/5938): Properly detect unsupported values in Alert Rule builder.
### Other

View File

@ -1,15 +1,11 @@
package flux
import (
"context"
"errors"
"io/ioutil"
"net/http"
"net/url"
"strings"
"time"
"github.com/influxdata/chronograf"
"github.com/influxdata/chronograf/util"
)
@ -26,36 +22,9 @@ type Client struct {
Timeout time.Duration
}
// Ping checks the connection of a Flux.
func (c *Client) Ping(ctx context.Context) error {
t := 2 * time.Second
if c.Timeout > 0 {
t = c.Timeout
}
ctx, cancel := context.WithTimeout(ctx, t)
defer cancel()
err := c.pingTimeout(ctx)
return err
}
func (c *Client) pingTimeout(ctx context.Context) error {
resps := make(chan (error))
go func() {
resps <- c.ping(c.URL)
}()
select {
case resp := <-resps:
return resp
case <-ctx.Done():
return chronograf.ErrUpstreamTimeout
}
}
// FluxEnabled returns true if the server has flux querying enabled.
func (c *Client) FluxEnabled() (bool, error) {
url := c.URL
url.Path = "/api/v2/query"
url := util.AppendPath(c.URL, "/api/v2/query")
req, err := http.NewRequest("POST", url.String(), nil)
if err != nil {
@ -84,36 +53,3 @@ func (c *Client) FluxEnabled() (bool, error) {
// {"code":"unauthorized","message":"unauthorized access"} is received
return strings.HasPrefix(contentType, "application/json"), nil
}
func (c *Client) ping(u *url.URL) error {
u.Path = "ping"
req, err := http.NewRequest("GET", u.String(), nil)
if err != nil {
return err
}
hc := &http.Client{}
if c.InsecureSkipVerify {
hc.Transport = skipVerifyTransport
} else {
hc.Transport = defaultTransport
}
resp, err := hc.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
body, err := ioutil.ReadAll(resp.Body)
if err != nil {
return err
}
if resp.StatusCode != http.StatusNoContent {
return errors.New(string(body))
}
return nil
}

56
flux/client_test.go Normal file
View File

@ -0,0 +1,56 @@
package flux_test
import (
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"time"
"github.com/influxdata/chronograf/flux"
)
// NewClient initializes an HTTP Client for InfluxDB.
func NewClient(urlStr string) *flux.Client {
u, _ := url.Parse(urlStr)
return &flux.Client{
URL: u,
Timeout: 500 * time.Millisecond,
}
}
func Test_FluxEnabled(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
path := r.URL.Path
if !strings.HasSuffix(path, "/api/v2/query") {
t.Error("Expected the path to contain `/api/v2/query` but was", path)
}
if strings.HasPrefix(path, "/enabled_v1") {
rw.Header().Add("Content-Type", "application/json")
rw.WriteHeader(http.StatusBadRequest)
rw.Write([]byte(`{}`))
return
}
if strings.HasPrefix(path, "/enabled_v2") {
rw.Header().Add("Content-Type", "application/json")
rw.WriteHeader(http.StatusUnauthorized)
rw.Write([]byte(`{"code":"unauthorized","message":"unauthorized access"}`))
return
}
rw.Header().Add("Content-Type", "text/plain")
rw.WriteHeader(http.StatusForbidden)
rw.Write([]byte(`Flux query service disabled.`))
}))
defer ts.Close()
if enabled, _ := NewClient(ts.URL).FluxEnabled(); enabled {
t.Errorf("Client.FluxEnabled() expected false value")
}
if enabled, _ := NewClient(ts.URL + "/enabled_v1").FluxEnabled(); !enabled {
t.Errorf("Client.FluxEnabled() expected true value")
}
if enabled, _ := NewClient(ts.URL + "/enabled_v2").FluxEnabled(); !enabled {
t.Errorf("Client.FluxEnabled() expected true value")
}
}

View File

@ -65,7 +65,8 @@ func (r *responseType) Error() string {
}
func (c *Client) query(u *url.URL, q chronograf.Query) (chronograf.Response, error) {
u.Path = "query"
u = util.AppendPath(u, "/query")
req, err := http.NewRequest("POST", u.String(), nil)
if err != nil {
return nil, err
@ -183,7 +184,7 @@ func (c *Client) validateAuthFlux(ctx context.Context, src *chronograf.Source) e
if err != nil {
return err
}
u.Path = "api/v2/query"
u = util.AppendPath(u, "/api/v2/query")
command := "buckets()"
req, err := http.NewRequest("POST", u.String(), strings.NewReader(command))
if err != nil {
@ -297,7 +298,7 @@ type pingResult struct {
}
func (c *Client) ping(u *url.URL) (string, string, error) {
u.Path = "ping"
u = util.AppendPath(u, "/ping")
req, err := http.NewRequest("GET", u.String(), nil)
if err != nil {
@ -392,7 +393,7 @@ func (c *Client) writePoint(ctx context.Context, point *chronograf.Point) error
}
func (c *Client) write(ctx context.Context, u *url.URL, db, rp, lp string) error {
u.Path = "write"
u = util.AppendPath(u, "/write")
req, err := http.NewRequest("POST", u.String(), strings.NewReader(lp))
if err != nil {
return err

View File

@ -4,6 +4,7 @@ import (
"context"
"encoding/base64"
"fmt"
"io/ioutil"
"net/http"
"net/http/httptest"
"net/url"
@ -539,14 +540,11 @@ func TestClient_write(t *testing.T) {
func Test_Influx_ValidateAuth_V1(t *testing.T) {
t.Parallel()
called := false
calledPath := ""
ts := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
rw.WriteHeader(http.StatusUnauthorized)
rw.Write([]byte(`{"error":"v1authfailed"}`))
called = true
if path := r.URL.Path; path != "/query" {
t.Error("Expected the path to contain `/query` but was: ", path)
}
calledPath = r.URL.Path
expectedAuth := "Basic " + base64.StdEncoding.EncodeToString(([]byte)("my-user:my-pwd"))
if auth := r.Header.Get("Authorization"); auth != expectedAuth {
t.Errorf("Expected Authorization '%v' but was: %v", expectedAuth, auth)
@ -554,66 +552,212 @@ func Test_Influx_ValidateAuth_V1(t *testing.T) {
}))
defer ts.Close()
client, err := NewClient(ts.URL, log.New(log.DebugLevel))
if err != nil {
t.Fatal("Unexpected error initializing client: err:", err)
}
for _, urlContext := range []string{"", "/ctx"} {
client, err := NewClient(ts.URL+urlContext, log.New(log.DebugLevel))
if err != nil {
t.Fatal("Unexpected error initializing client: err:", err)
}
source := &chronograf.Source{
URL: ts.URL + urlContext,
Username: "my-user",
Password: "my-pwd",
}
source := &chronograf.Source{
URL: ts.URL,
Username: "my-user",
Password: "my-pwd",
}
client.Connect(context.Background(), source)
err = client.ValidateAuth(context.Background(), &chronograf.Source{})
if err == nil {
t.Fatal("Expected error but nil")
}
if !strings.Contains(err.Error(), "v1authfailed") {
t.Errorf("Expected client error '%v' to contain server-sent error message", err)
}
if called == false {
t.Error("Expected http request to InfluxDB but there was none")
client.Connect(context.Background(), source)
err = client.ValidateAuth(context.Background(), &chronograf.Source{})
if err == nil {
t.Fatal("Expected error but nil")
}
if !strings.Contains(err.Error(), "v1authfailed") {
t.Errorf("Expected client error '%v' to contain server-sent error message", err)
}
if calledPath != urlContext+"/query" {
t.Errorf("Path received: %v, want: %v ", calledPath, urlContext+"/query")
}
}
}
func Test_Influx_ValidateAuth_V2(t *testing.T) {
t.Parallel()
called := false
calledPath := ""
ts := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
rw.WriteHeader(http.StatusUnauthorized)
rw.Write([]byte(`{"message":"v2authfailed"}`))
called = true
calledPath = r.URL.Path
if auth := r.Header.Get("Authorization"); auth != "Token my-token" {
t.Error("Expected Authorization 'Token my-token' but was: ", auth)
}
if path := r.URL.Path; path != "/api/v2/query" {
if path := r.URL.Path; !strings.HasSuffix(path, "/api/v2/query") {
t.Error("Expected the path to contain `api/v2/query` but was: ", path)
}
}))
defer ts.Close()
for _, urlContext := range []string{"", "/ctx"} {
calledPath = ""
client, err := NewClient(ts.URL+urlContext, log.New(log.DebugLevel))
if err != nil {
t.Fatal("Unexpected error initializing client: err:", err)
}
source := &chronograf.Source{
URL: ts.URL + urlContext,
Type: chronograf.InfluxDBv2,
Username: "my-org",
Password: "my-token",
}
client, err := NewClient(ts.URL, log.New(log.DebugLevel))
if err != nil {
t.Fatal("Unexpected error initializing client: err:", err)
}
source := &chronograf.Source{
URL: ts.URL,
Type: chronograf.InfluxDBv2,
Username: "my-org",
Password: "my-token",
}
client.Connect(context.Background(), source)
err = client.ValidateAuth(context.Background(), source)
if err == nil {
t.Fatal("Expected error but nil")
}
if !strings.Contains(err.Error(), "v2authfailed") {
t.Errorf("Expected client error '%v' to contain server-sent error message", err)
}
if called == false {
t.Error("Expected http request to InfluxDB but there was none")
client.Connect(context.Background(), source)
err = client.ValidateAuth(context.Background(), source)
if err == nil {
t.Fatal("Expected error but nil")
}
if !strings.Contains(err.Error(), "v2authfailed") {
t.Errorf("Expected client error '%v' to contain server-sent error message", err)
}
if calledPath != urlContext+"/api/v2/query" {
t.Errorf("Path received: %v, want: %v ", calledPath, urlContext+"/api/v2/query")
}
}
}
func Test_Influx_Version(t *testing.T) {
t.Parallel()
calledPath := ""
serverVersion := ""
ts := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
rw.Header().Add("X-Influxdb-Version", serverVersion)
rw.WriteHeader(http.StatusNoContent)
calledPath = r.URL.Path
}))
defer ts.Close()
for _, urlContext := range []string{"", "/ctx"} {
calledPath = ""
client, err := NewClient(ts.URL+urlContext, log.New(log.DebugLevel))
if err != nil {
t.Fatal("Unexpected error initializing client: err:", err)
}
source := &chronograf.Source{
URL: ts.URL + urlContext,
Type: chronograf.InfluxDBv2,
Username: "my-org",
Password: "my-token",
}
client.Connect(context.Background(), source)
versions := []struct {
server string
expected string
}{
{
server: "1.8.3",
expected: "1.8.3",
},
{
server: "v2.2.0",
expected: "2.2.0",
},
}
for _, testPair := range versions {
serverVersion = testPair.server
version, err := client.Version(context.Background())
if err != nil {
t.Fatalf("No error expected, but received: %v", err)
}
if version != testPair.expected {
t.Errorf("Version received: %v, want: %v ", version, testPair.expected)
}
if calledPath != urlContext+"/ping" {
t.Errorf("Path received: %v, want: %v ", calledPath, urlContext+"/ping")
}
}
}
}
func Test_Write(t *testing.T) {
t.Parallel()
calledPath := ""
data := ""
ts := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
calledPath = r.URL.Path
content, _ := ioutil.ReadAll(r.Body)
data = string(content)
rw.WriteHeader(http.StatusNoContent)
}))
defer ts.Close()
for _, urlContext := range []string{"", "/ctx"} {
calledPath = ""
client, err := NewClient(ts.URL+urlContext, log.New(log.DebugLevel))
if err != nil {
t.Fatal("Unexpected error initializing client: err:", err)
}
source := &chronograf.Source{
URL: ts.URL + urlContext,
Type: chronograf.InfluxDBv2,
Username: "my-org",
Password: "my-token",
}
client.Connect(context.Background(), source)
err = client.Write(context.Background(), []chronograf.Point{
{
Database: "mydb",
RetentionPolicy: "default",
Measurement: "temperature",
Fields: map[string]interface{}{
"v": true,
},
},
})
if err != nil {
t.Fatalf("No error expected, but received: %v", err)
}
expectedLine := "temperature v=true"
if data != expectedLine {
t.Errorf("Data received: %v, want: %v ", data, expectedLine)
}
if calledPath != urlContext+"/write" {
t.Errorf("Path received: %v, want: %v ", calledPath, urlContext+"/write")
}
}
}
func Test_Query(t *testing.T) {
t.Parallel()
calledPath := ""
ts := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
calledPath = r.URL.Path
rw.WriteHeader(http.StatusOK)
rw.Write([]byte(`{"message":"hi"}`))
}))
defer ts.Close()
for _, urlContext := range []string{"", "/ctx"} {
calledPath = ""
client, err := NewClient(ts.URL+urlContext, log.New(log.DebugLevel))
if err != nil {
t.Fatal("Unexpected error initializing client: err:", err)
}
source := &chronograf.Source{
URL: ts.URL + urlContext,
Type: chronograf.InfluxDBv2,
Username: "my-org",
Password: "my-token",
}
client.Connect(context.Background(), source)
_, err = client.Query(context.Background(), chronograf.Query{
DB: "mydb",
RP: "default",
Command: "show databases",
})
if err != nil {
t.Fatalf("No error expected, but received: %v", err)
}
if calledPath != urlContext+"/query" {
t.Errorf("Path received: %v, want: %v ", calledPath, urlContext+"/query")
}
}
}

View File

@ -16,6 +16,7 @@ import (
"github.com/influxdata/chronograf"
uuid "github.com/influxdata/chronograf/id"
"github.com/influxdata/chronograf/influx"
"github.com/influxdata/chronograf/util"
)
// ValidInfluxRequest checks if queries specify a command.
@ -124,13 +125,13 @@ func (s *Service) Write(w http.ResponseWriter, r *http.Request) {
version := query.Get("v")
query.Del("v")
if strings.HasPrefix(version, "2") {
u.Path = "/api/v2/write"
u = util.AppendPath(u, "/api/v2/write")
// v2 organization name is stored in username (org does not matter against v1)
query.Set("org", src.Username)
query.Set("bucket", query.Get("db"))
query.Del("db")
} else {
u.Path = "/write"
u = util.AppendPath(u, "/write")
}
u.RawQuery = query.Encode()

View File

@ -216,3 +216,69 @@ func TestService_Influx_UseCommand(t *testing.T) {
})
}
}
func TestService_Influx_Write(t *testing.T) {
calledPath := ""
ts := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
calledPath = r.URL.Path
rw.WriteHeader(http.StatusOK)
rw.Write([]byte(`{"message":"hi"}`))
}))
defer ts.Close()
testPairs := []struct {
version string
ctx string
path string
}{
{version: "1.8.3", ctx: "", path: "/write"},
{version: "1.8.3", ctx: "/ctx", path: "/ctx/write"},
{version: "2.2.0", ctx: "", path: "/api/v2/write"},
{version: "2.2.0", ctx: "/ctx", path: "/ctx/api/v2/write"},
}
for _, testPair := range testPairs {
calledPath = ""
w := httptest.NewRecorder()
r := httptest.NewRequest(
"POST",
"http://any.url?v="+testPair.version,
ioutil.NopCloser(
bytes.NewReader([]byte(
`temperature v=1.0`,
)),
),
)
r = r.WithContext(httprouter.WithParams(
context.Background(),
httprouter.Params{
{
Key: "id",
Value: "1",
},
},
))
h := &Service{
Store: &mocks.Store{
SourcesStore: &mocks.SourcesStore{
GetF: func(ctx context.Context, ID int) (chronograf.Source, error) {
return chronograf.Source{
ID: 1337,
URL: ts.URL + testPair.ctx,
}, nil
},
},
},
Logger: log.New(log.ErrorLevel),
}
h.Write(w, r)
resp := w.Result()
ioutil.ReadAll(resp.Body)
if calledPath != testPair.path {
t.Errorf("Path received: %v, want: %v ", calledPath, testPair.path)
}
}
}

View File

@ -252,41 +252,25 @@ export const editRetentionPolicyFailed = (
// async actions
export const loadUsersAsync = url => async dispatch => {
try {
const {data} = await getUsersAJAX(url)
dispatch(loadUsers(data))
} catch (error) {
dispatch(errorThrown(error))
}
const {data} = await getUsersAJAX(url)
dispatch(loadUsers(data))
}
export const loadRolesAsync = url => async dispatch => {
try {
const {data} = await getRolesAJAX(url)
dispatch(loadRoles(data))
} catch (error) {
dispatch(errorThrown(error))
}
const {data} = await getRolesAJAX(url)
dispatch(loadRoles(data))
}
export const loadPermissionsAsync = url => async dispatch => {
try {
const {data} = await getPermissionsAJAX(url)
dispatch(loadPermissions(data))
} catch (error) {
dispatch(errorThrown(error))
}
const {data} = await getPermissionsAJAX(url)
dispatch(loadPermissions(data))
}
export const loadDBsAndRPsAsync = url => async dispatch => {
try {
const {
data: {databases},
} = await getDbsAndRpsAJAX(url)
dispatch(loadDatabases(_.sortBy(databases, ({name}) => name.toLowerCase())))
} catch (error) {
dispatch(errorThrown(error))
}
const {
data: {databases},
} = await getDbsAndRpsAJAX(url)
dispatch(loadDatabases(_.sortBy(databases, ({name}) => name.toLowerCase())))
}
export const createUserAsync = (url, user) => async dispatch => {

View File

@ -1,23 +0,0 @@
import React, {FunctionComponent} from 'react'
interface Props {
entities: string
colSpan?: number
filtered?: boolean
}
const EmptyRow: FunctionComponent<Props> = ({entities, colSpan, filtered}) => (
<tr className="table-empty-state">
<th colSpan={colSpan || 5}>
{filtered ? (
<p>No Matching {entities}</p>
) : (
<p>
You don't have any {entities},<br />
why not create one?
</p>
)}
</th>
</tr>
)
export default EmptyRow

View File

@ -0,0 +1,17 @@
import React, {FunctionComponent} from 'react'
interface Props {
entities: string
filtered?: boolean
}
const NoEntities: FunctionComponent<Props> = ({entities, filtered}) =>
filtered ? (
<p className="empty">No Matching {entities} Found</p>
) : (
<p className="empty">
You don't have any {entities},<br />
why not create one?
</p>
)
export default NoEntities

View File

@ -111,8 +111,17 @@ export class AdminInfluxDBScopedPage extends PureComponent<Props, State> {
}
}
this.setState({loading: RemoteDataState.Done})
} catch (error) {
console.error(error)
} catch (e) {
console.error(e)
// extract error message for the UI
let error = e
if (error.message) {
error = error.message
} else if (error.data?.message) {
error = error.data?.message
} else if (error.statusText) {
error = error.statusText
}
this.setState({
loading: RemoteDataState.Error,
error,

View File

@ -18,7 +18,7 @@ import AdminInfluxDBTabbedPage, {
isConnectedToLDAP,
} from './AdminInfluxDBTabbedPage'
import FancyScrollbar from 'src/shared/components/FancyScrollbar'
import EmptyRow from 'src/admin/components/EmptyRow'
import NoEntities from 'src/admin/components/influxdb/NoEntities'
import RoleRow from 'src/admin/components/RoleRow'
import {useCallback} from 'react'
import allOrParticularSelection from '../../util/allOrParticularSelection'
@ -207,30 +207,30 @@ const RolesPage = ({
</div>
</div>
<div className="panel-body">
<FancyScrollbar>
<table className="table v-center admin-table table-highlight admin-table--compact">
<thead data-test="admin-table--head">
<tr>
<th>Role</th>
{showUsers && (
<th className="admin-table--left-offset">Users</th>
)}
{visibleRoles.length && visibleDBNames.length
? visibleDBNames.map(name => (
<th
className="admin-table__dbheader"
title={`effective permissions for db: ${name}`}
key={name}
>
{name}
</th>
))
: null}
</tr>
</thead>
<tbody data-test="admin-table--body">
{visibleRoles.length ? (
visibleRoles.map((role, roleIndex) => (
{visibleRoles.length ? (
<FancyScrollbar>
<table className="table v-center admin-table table-highlight admin-table--compact">
<thead data-test="admin-table--head">
<tr>
<th>Role</th>
{showUsers && (
<th className="admin-table--left-offset">Users</th>
)}
{visibleDBNames.length
? visibleDBNames.map(name => (
<th
className="admin-table__dbheader"
title={`effective permissions for db: ${name}`}
key={name}
>
{name}
</th>
))
: null}
</tr>
</thead>
<tbody data-test="admin-table--body">
{visibleRoles.map((role, roleIndex) => (
<RoleRow
key={role.name}
role={role}
@ -241,17 +241,13 @@ const RolesPage = ({
allUsers={users}
showUsers={showUsers}
/>
))
) : (
<EmptyRow
entities="Roles"
colSpan={1 + +showUsers}
filtered={!!filterText}
/>
)}
</tbody>
</table>
</FancyScrollbar>
))}
</tbody>
</table>
</FancyScrollbar>
) : (
<NoEntities entities="Roles" filtered={!!debouncedFilterText} />
)}
</div>
</div>
</AdminInfluxDBTabbedPage>

View File

@ -18,7 +18,7 @@ import AdminInfluxDBTabbedPage, {
isConnectedToLDAP,
} from './AdminInfluxDBTabbedPage'
import FancyScrollbar from 'src/shared/components/FancyScrollbar'
import EmptyRow from 'src/admin/components/EmptyRow'
import NoEntities from 'src/admin/components/influxdb/NoEntities'
import UserRow from 'src/admin/components/UserRow'
import useDebounce from 'src/utils/useDebounce'
import useChangeEffect from 'src/utils/useChangeEffect'
@ -218,32 +218,32 @@ const UsersPage = ({
</div>
</div>
<div className="panel-body">
<FancyScrollbar>
<table className="table v-center admin-table table-highlight admin-table--compact">
<thead>
<tr>
<th>User</th>
{showRoles && (
<th className="admin-table--left-offset">
{isEnterprise ? 'Roles' : 'Admin'}
</th>
)}
{visibleUsers.length && visibleDBNames.length
? visibleDBNames.map(name => (
<th
className="admin-table__dbheader"
title={`effective permissions for db: ${name}`}
key={name}
>
{name}
</th>
))
: null}
</tr>
</thead>
<tbody>
{visibleUsers.length ? (
visibleUsers.map((user, userIndex) => (
{visibleUsers.length ? (
<FancyScrollbar>
<table className="table v-center admin-table table-highlight admin-table--compact">
<thead>
<tr>
<th>User</th>
{showRoles && (
<th className="admin-table--left-offset">
{isEnterprise ? 'Roles' : 'Admin'}
</th>
)}
{visibleDBNames.length
? visibleDBNames.map(name => (
<th
className="admin-table__dbheader"
title={`effective permissions for db: ${name}`}
key={name}
>
{name}
</th>
))
: null}
</tr>
</thead>
<tbody>
{visibleUsers.map((user, userIndex) => (
<UserRow
key={user.name}
user={user}
@ -255,17 +255,13 @@ const UsersPage = ({
showRoles={showRoles}
hasRoles={isEnterprise}
/>
))
) : (
<EmptyRow
entities="Users"
colSpan={1 + +showRoles}
filtered={!!filterText}
/>
)}
</tbody>
</table>
</FancyScrollbar>
))}
</tbody>
</table>
</FancyScrollbar>
) : (
<NoEntities entities="Users" filtered={!!debouncedFilterText} />
)}
</div>
</div>
</AdminInfluxDBTabbedPage>

View File

@ -64,7 +64,7 @@ class RuleGraphDygraph extends Component<Props, State> {
if (!timeSeriesToDygraphResult) {
return null
}
if (timeSeriesToDygraphResult.unsupportedValue) {
if (timeSeriesToDygraphResult.unsupportedValue !== undefined) {
console.error(
'Unsupported y-axis value, cannot display data',
timeSeriesToDygraphResult

View File

@ -153,6 +153,12 @@ pre.admin-table--query {
padding: 0 30px;
min-height: 60px;
}
p.empty {
font-weight: 400;
font-size: 18px;
color: $g9-mountain;
}
.influxdb-admin--contents{
height: calc(100%-60px);
}

21
util/path.go Normal file
View File

@ -0,0 +1,21 @@
package util
import (
"net/url"
)
// AppendPath appends path to the supplied URL and returns a new URL instance.
func AppendPath(url *url.URL, path string) *url.URL {
retVal := *url
if len(path) == 0 {
return &retVal
}
if path[0] != '/' {
path = "/" + path
}
if len(retVal.Path) > 0 && retVal.Path[len(retVal.Path)-1] == '/' {
retVal.Path = retVal.Path[0 : len(retVal.Path)-1]
}
retVal.Path += path
return &retVal
}

69
util/path_test.go Normal file
View File

@ -0,0 +1,69 @@
package util_test
import (
"net/url"
"testing"
"github.com/influxdata/chronograf/util"
)
func Test_AppendPath(t *testing.T) {
tests := []struct {
url string
path string
expected string
}{
{
url: "http://localhost:8086?t=1#asdf",
path: "",
expected: "http://localhost:8086?t=1#asdf",
},
{
url: "http://localhost:8086?t=1#asdf",
path: "a",
expected: "http://localhost:8086/a?t=1#asdf",
},
{
url: "http://localhost:8086/?t=1#asdf",
path: "",
expected: "http://localhost:8086/?t=1#asdf",
},
{
url: "http://localhost:8086/a?t=1#asdf",
path: "",
expected: "http://localhost:8086/a?t=1#asdf",
},
{
url: "http://localhost:8086/a?t=1#asdf",
path: "b",
expected: "http://localhost:8086/a/b?t=1#asdf",
},
{
url: "http://localhost:8086/a?t=1#asdf",
path: "/b",
expected: "http://localhost:8086/a/b?t=1#asdf",
},
{
url: "http://localhost:8086/a/?t=1#asdf",
path: "b",
expected: "http://localhost:8086/a/b?t=1#asdf",
},
{
url: "http://localhost:8086/a/?t=1#asdf",
path: "/b",
expected: "http://localhost:8086/a/b?t=1#asdf",
},
}
for _, test := range tests {
inURL, _ := url.Parse(test.url)
outURL := util.AppendPath(inURL, test.path)
if inURL == outURL {
t.Errorf("AppendPath(\"%v\",\"%v\") does not return a new URL instance", inURL, test.path)
}
out := outURL.String()
if out != test.expected {
t.Errorf("AppendPath(\"%v\",\"%v\") != \"%v\"", inURL, test.path, test.expected)
}
}
}